From a11421c5267b0ff809753bf245eeed4fe0378a28 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 23:11:48 +0200 Subject: [PATCH 001/112] Add private Unix socket transport for AMY wire protocol Add a fixed-size AF_UNIX/SOCK_SEQPACKET transport for local AMY wire messages, including peer credential checks, bounded queueing, cleanup safeguards, standalone regression tests, and focused CI. --- .github/workflows/android-unix-socket.yml | 21 + src/amy_unix_socket.c | 447 ++++++++++++++++++++++ src/amy_unix_socket.h | 70 ++++ tests/run_amy_unix_socket_test.sh | 20 + tests/test_amy_unix_socket.c | 183 +++++++++ 5 files changed, 741 insertions(+) create mode 100644 .github/workflows/android-unix-socket.yml create mode 100644 src/amy_unix_socket.c create mode 100644 src/amy_unix_socket.h create mode 100644 tests/run_amy_unix_socket_test.sh create mode 100644 tests/test_amy_unix_socket.c diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/android-unix-socket.yml new file mode 100644 index 00000000..6647320b --- /dev/null +++ b/.github/workflows/android-unix-socket.yml @@ -0,0 +1,21 @@ +name: Android Unix socket transport + +on: + pull_request: + paths: + - 'src/amy_unix_socket.c' + - 'src/amy_unix_socket.h' + - 'tests/test_amy_unix_socket.c' + - 'tests/run_amy_unix_socket_test.sh' + - '.github/workflows/android-unix-socket.yml' + +permissions: + contents: read + +jobs: + linux-socket-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Compile and run private Unix socket transport test + run: bash tests/run_amy_unix_socket_test.sh diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c new file mode 100644 index 00000000..c585bfb6 --- /dev/null +++ b/src/amy_unix_socket.c @@ -0,0 +1,447 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#if defined(__linux__) || defined(__ANDROID__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define AMY_UNIX_SOCKET_POLL_MS 50 + +struct amy_unix_socket_packet { + uint16_t len; + char data[MAX_MESSAGE_LEN]; +}; + +struct amy_unix_socket_server { + int listen_fd; + int client_fd; + pthread_t thread; + pthread_mutex_t client_lock; + bool thread_started; + volatile uint32_t running; + + char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + + struct amy_unix_socket_packet queue[AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + volatile uint32_t write_index; + volatile uint32_t read_index; + + volatile uint32_t queue_overruns; + volatile uint32_t oversize_packets; + volatile uint32_t rejected_peers; +}; + +static uint32_t load_u32(const volatile uint32_t *value) { + return __atomic_load_n(value, __ATOMIC_ACQUIRE); +} + +static void store_u32(volatile uint32_t *value, uint32_t new_value) { + __atomic_store_n(value, new_value, __ATOMIC_RELEASE); +} + +static void increment_u32(volatile uint32_t *value) { + __atomic_add_fetch(value, 1u, __ATOMIC_RELAXED); +} + +static int set_nonblocking_cloexec(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -errno; + + flags = fcntl(fd, F_GETFD, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) return -errno; + return 0; +} + +static int remove_owned_stale_socket(const char *path) { + struct stat st; + if (lstat(path, &st) < 0) { + return errno == ENOENT ? 0 : -errno; + } + + if (!S_ISSOCK(st.st_mode)) return -EEXIST; + if (st.st_uid != geteuid()) return -EPERM; + if (unlink(path) < 0) return -errno; + return 0; +} + +static bool peer_has_same_uid(int fd) { + struct ucred cred; + socklen_t len = sizeof(cred); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) < 0) { + return false; + } + return cred.uid == geteuid(); +} + +static void close_client_locked(amy_unix_socket_server_t *server) { + if (server->client_fd >= 0) { + shutdown(server->client_fd, SHUT_RDWR); + close(server->client_fd); + server->client_fd = -1; + } +} + +static void close_client(amy_unix_socket_server_t *server) { + pthread_mutex_lock(&server->client_lock); + close_client_locked(server); + pthread_mutex_unlock(&server->client_lock); +} + +static void queue_packet(amy_unix_socket_server_t *server, + const char *data, + size_t len) { + if (len == 0) return; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + return; + } + + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + if ((uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY) { + increment_u32(&server->queue_overruns); + return; + } + + struct amy_unix_socket_packet *slot = + &server->queue[write_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + memcpy(slot->data, data, len); + slot->data[len] = '\0'; + slot->len = (uint16_t)len; + + store_u32(&server->write_index, write_index + 1u); +} + +static void receive_client_packets(amy_unix_socket_server_t *server, + int client_fd) { + for (;;) { + char packet[MAX_MESSAGE_LEN]; + ssize_t received = recv(client_fd, + packet, + sizeof(packet), + MSG_DONTWAIT | MSG_TRUNC); + if (received > 0) { + if ((size_t)received > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + } else { + queue_packet(server, packet, (size_t)received); + } + continue; + } + + if (received == 0) { + close_client(server); + return; + } + + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + + close_client(server); + return; + } +} + +static void accept_clients(amy_unix_socket_server_t *server) { + for (;;) { + int fd = accept(server->listen_fd, NULL, NULL); + if (fd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + return; + } + + if (set_nonblocking_cloexec(fd) < 0 || !peer_has_same_uid(fd)) { + increment_u32(&server->rejected_peers); + close(fd); + continue; + } + + pthread_mutex_lock(&server->client_lock); + if (server->client_fd >= 0) { + increment_u32(&server->rejected_peers); + close(fd); + } else { + server->client_fd = fd; + } + pthread_mutex_unlock(&server->client_lock); + } +} + +static int current_client_fd(amy_unix_socket_server_t *server) { + int fd; + pthread_mutex_lock(&server->client_lock); + fd = server->client_fd; + pthread_mutex_unlock(&server->client_lock); + return fd; +} + +static void *socket_thread(void *arg) { + amy_unix_socket_server_t *server = + (amy_unix_socket_server_t *)arg; + + while (load_u32(&server->running)) { + struct pollfd fds[2]; + nfds_t count = 1; + + fds[0].fd = server->listen_fd; + fds[0].events = POLLIN; + fds[0].revents = 0; + + int client_fd = current_client_fd(server); + if (client_fd >= 0) { + fds[1].fd = client_fd; + fds[1].events = POLLIN; + fds[1].revents = 0; + count = 2; + } + + int ready = poll(fds, count, AMY_UNIX_SOCKET_POLL_MS); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + + if (fds[0].revents & POLLIN) accept_clients(server); + + if (count == 2) { + if (fds[1].revents & POLLIN) { + receive_client_packets(server, client_fd); + } + if (fds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) { + close_client(server); + } + } + } + + close_client(server); + return NULL; +} + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + if (out_server == NULL || path == NULL || path[0] == '\0') return -EINVAL; + *out_server = NULL; + + size_t path_len = strlen(path); + if (path_len >= sizeof(((struct sockaddr_un *)0)->sun_path)) { + return -ENAMETOOLONG; + } + + int rc = remove_owned_stale_socket(path); + if (rc < 0) return rc; + + amy_unix_socket_server_t *server = calloc(1, sizeof(*server)); + if (server == NULL) return -ENOMEM; + + server->listen_fd = -1; + server->client_fd = -1; + memcpy(server->path, path, path_len + 1u); + + int mutex_rc = pthread_mutex_init(&server->client_lock, NULL); + if (mutex_rc != 0) { + free(server); + return -mutex_rc; + } + + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (fd < 0) { + rc = -errno; + goto fail; + } + server->listen_fd = fd; + + rc = set_nonblocking_cloexec(fd); + if (rc < 0) goto fail; + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + memcpy(addr.sun_path, path, path_len + 1u); + + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + rc = -errno; + goto fail; + } + + // The Android app-data parent directory is already sandboxed. Mode 0600 + // additionally makes filesystem pathname access same-UID only. + if (chmod(path, S_IRUSR | S_IWUSR) < 0) { + rc = -errno; + goto fail; + } + + if (listen(fd, 1) < 0) { + rc = -errno; + goto fail; + } + + store_u32(&server->running, 1u); + int thread_rc = pthread_create(&server->thread, NULL, + socket_thread, server); + if (thread_rc != 0) { + rc = -thread_rc; + store_u32(&server->running, 0u); + goto fail; + } + server->thread_started = true; + + *out_server = server; + return 0; + +fail: + if (server->listen_fd >= 0) close(server->listen_fd); + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); + return rc; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + if (server == NULL) return; + + store_u32(&server->running, 0u); + if (server->thread_started) { + pthread_join(server->thread, NULL); + } + + if (server->listen_fd >= 0) { + close(server->listen_fd); + server->listen_fd = -1; + } + + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + if (server == NULL || out == NULL) return -EINVAL; + + uint32_t read_index = load_u32(&server->read_index); + uint32_t write_index = load_u32(&server->write_index); + if (read_index == write_index) return 0; + + const struct amy_unix_socket_packet *slot = + &server->queue[read_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + size_t len = slot->len; + if (out_len <= len) return -EMSGSIZE; + + memcpy(out, slot->data, len); + out[len] = '\0'; + store_u32(&server->read_index, read_index + 1u); + return (int)len; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + if (server == NULL || (data == NULL && len != 0)) return -EINVAL; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) return -EMSGSIZE; + + pthread_mutex_lock(&server->client_lock); + int fd = server->client_fd; + if (fd < 0) { + pthread_mutex_unlock(&server->client_lock); + return -ENOTCONN; + } + + ssize_t sent = send(fd, data, len, + MSG_DONTWAIT | MSG_NOSIGNAL); + int saved_errno = errno; + pthread_mutex_unlock(&server->client_lock); + + if (sent < 0) return -saved_errno; + return (int)sent; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->queue_overruns); +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->oversize_packets); +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->rejected_peers); +} + +#else + +#include + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + (void)out_server; + (void)path; + return -ENOTSUP; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + (void)server; +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + (void)server; + (void)out; + (void)out_len; + return -ENOTSUP; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + (void)server; + (void)data; + (void)len; + return -ENOTSUP; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +#endif diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h new file mode 100644 index 00000000..245e3937 --- /dev/null +++ b/src/amy_unix_socket.h @@ -0,0 +1,70 @@ +#ifndef AMY_UNIX_SOCKET_H +#define AMY_UNIX_SOCKET_H + +#include +#include + +#include "amy.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Private pathname AF_UNIX transport for local AMY control. +// +// Intended Android topology: +// Qt/Python process <-> amy.sock <-> native AMY/Oboe process +// +// The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET +// packets into this fixed SPSC queue. The audio/control owner drains packets +// explicitly at a safe point (for example, immediately before rendering the +// next AMY block) and may then pass them to amy_add_message(). +// +// One connected client is supported at a time. On Linux/Android, accepted +// peers must have the same effective UID as the server process. The pathname +// is created mode 0600 and a stale socket is removed only when it is owned by +// the same UID; an existing non-socket path is never removed. + +#define AMY_UNIX_SOCKET_QUEUE_CAPACITY 64u +#define AMY_UNIX_SOCKET_MAX_PACKET ((size_t)MAX_MESSAGE_LEN - 1u) + +typedef struct amy_unix_socket_server amy_unix_socket_server_t; + +// Start a server at path. Returns 0 on success or -errno on failure. +// out_server is set only on success. +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path); + +// Stop the receiver thread, close any client, unlink the socket pathname and +// free the server. Safe to call with NULL. +void amy_unix_socket_stop(amy_unix_socket_server_t *server); + +// Non-blocking dequeue for the AMY/control owner. +// Returns payload length (>0), 0 when no packet is queued, or -errno. +// On success out is NUL-terminated; packet payloads themselves need not carry +// a trailing NUL. If out_len is too small, returns -EMSGSIZE and leaves the +// packet queued. +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len); + +// Send one reply packet to the currently connected client. This is intended +// for non-realtime status/introspection replies, not the audio callback. +// Returns bytes sent or -errno. The accepted client socket is non-blocking. +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len); + +// Diagnostic counters. They are monotonic until the server is stopped. +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server); + +#ifdef __cplusplus +} +#endif + +#endif // AMY_UNIX_SOCKET_H diff --git a/tests/run_amy_unix_socket_test.sh b/tests/run_amy_unix_socket_test.sh new file mode 100644 index 00000000..c3b22a21 --- /dev/null +++ b/tests/run_amy_unix_socket_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +out="${TMPDIR:-/tmp}/test_amy_unix_socket" + +cc \ + -std=c11 \ + -O2 \ + -Wall \ + -Wextra \ + -Werror \ + -pthread \ + -I"$repo_root/src" \ + "$repo_root/src/amy_unix_socket.c" \ + "$repo_root/tests/test_amy_unix_socket.c" \ + -o "$out" + +"$out" +rm -f "$out" diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c new file mode 100644 index 00000000..8310cc32 --- /dev/null +++ b/tests/test_amy_unix_socket.c @@ -0,0 +1,183 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int connect_client(const char *path) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(fd >= 0); + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + assert(strlen(path) < sizeof(addr.sun_path)); + strcpy(addr.sun_path, path); + + assert(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0); + return fd; +} + +static int wait_receive(amy_unix_socket_server_t *server, + char *buffer, + size_t buffer_len) { + for (int i = 0; i < 1000; ++i) { + int rc = amy_unix_socket_receive(server, buffer, buffer_len); + if (rc != 0) return rc; + usleep(1000); + } + return -ETIMEDOUT; +} + +static ssize_t wait_client_receive(int fd, void *buffer, size_t len) { + for (int i = 0; i < 1000; ++i) { + ssize_t rc = recv(fd, buffer, len, MSG_DONTWAIT); + if (rc >= 0) return rc; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + return -1; + } + usleep(1000); + } + errno = ETIMEDOUT; + return -1; +} + +static void test_round_trip_and_permissions(void) { + char dir_template[] = "/tmp/amy-unix-socket-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + assert(server != NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISSOCK(st.st_mode)); + assert((st.st_mode & 0777) == 0600); + assert(st.st_uid == geteuid()); + + int client = connect_client(path); + + const char command[] = "n60l1i2Z"; + assert(send(client, command, strlen(command), 0) == + (ssize_t)strlen(command)); + + char received[MAX_MESSAGE_LEN]; + int rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(command)); + assert(strcmp(received, command) == 0); + + // Too-small destination must not consume the next queued packet. + const char second[] = "K28i2Z"; + assert(send(client, second, strlen(second), 0) == + (ssize_t)strlen(second)); + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_receive(server, received, 4); + if (rc != 0) break; + usleep(1000); + } + assert(rc == -EMSGSIZE); + rc = amy_unix_socket_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(second)); + assert(strcmp(received, second) == 0); + + const char reply[] = "!iv1"; + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_send(server, reply, strlen(reply)); + if (rc != -ENOTCONN) break; + usleep(1000); + } + assert(rc == (int)strlen(reply)); + + char reply_buffer[32]; + ssize_t reply_len = wait_client_receive(client, + reply_buffer, + sizeof(reply_buffer)); + assert(reply_len == (ssize_t)strlen(reply)); + assert(memcmp(reply_buffer, reply, strlen(reply)) == 0); + + close(client); + amy_unix_socket_stop(server); + + assert(lstat(path, &st) < 0); + assert(errno == ENOENT); + assert(rmdir(dir) == 0); +} + +static void test_oversize_packet_is_dropped(void) { + char dir_template[] = "/tmp/amy-unix-oversize-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + char packet[MAX_MESSAGE_LEN]; + memset(packet, 'x', sizeof(packet)); + assert(send(client, packet, sizeof(packet), 0) == (ssize_t)sizeof(packet)); + + for (int i = 0; i < 1000; ++i) { + if (amy_unix_socket_oversize_packets(server) != 0) break; + usleep(1000); + } + assert(amy_unix_socket_oversize_packets(server) == 1); + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + + close(client); + amy_unix_socket_stop(server); + assert(rmdir(dir) == 0); +} + +static void test_existing_regular_file_is_never_removed(void) { + char dir_template[] = "/tmp/amy-unix-stale-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == -EEXIST); + assert(server == NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + + assert(unlink(path) == 0); + assert(rmdir(dir) == 0); +} + +int main(void) { + test_round_trip_and_permissions(); + test_oversize_packet_is_dropped(); + test_existing_regular_file_is_never_removed(); + puts("amy unix socket tests passed"); + return 0; +} From d2755c9fdcd7b4780c29c9bf9d8bedf2521e0a3f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 23:12:04 +0200 Subject: [PATCH 002/112] Add Android Oboe service and hello-world client Add a generic Android AAR service that renders AMY through Oboe/AAudio and accepts ordinary AMY wire packets over the private Unix transport. Include the minimal C-scale hello-world app plus Android build and emulator integration CI. --- .github/workflows/android.yml | 109 ++++++ android/amy-service/build.gradle.kts | 48 +++ .../amy-service/src/main/AndroidManifest.xml | 10 + .../amy-service/src/main/cpp/CMakeLists.txt | 70 ++++ .../amy-service/src/main/cpp/amy_android.cpp | 323 ++++++++++++++++++ .../src/main/cpp/amy_android_daisy_alloc.c | 11 + .../src/main/cpp/amy_android_daisy_alloc.h | 14 + .../src/main/cpp/amy_android_profile.cpp | 10 + .../main/java/org/amy/audio/AmyService.java | 172 ++++++++++ android/build.gradle.kts | 4 + android/hello-world/build.gradle.kts | 38 +++ .../hello-world/src/main/AndroidManifest.xml | 16 + .../hello-world/src/main/cpp/CMakeLists.txt | 8 + .../src/main/cpp/amy_hello_client.cpp | 101 ++++++ .../main/java/org/amy/hello/MainActivity.java | 92 +++++ android/settings.gradle.kts | 19 ++ 16 files changed, 1045 insertions(+) create mode 100644 .github/workflows/android.yml create mode 100644 android/amy-service/build.gradle.kts create mode 100644 android/amy-service/src/main/AndroidManifest.xml create mode 100644 android/amy-service/src/main/cpp/CMakeLists.txt create mode 100644 android/amy-service/src/main/cpp/amy_android.cpp create mode 100644 android/amy-service/src/main/cpp/amy_android_daisy_alloc.c create mode 100644 android/amy-service/src/main/cpp/amy_android_daisy_alloc.h create mode 100644 android/amy-service/src/main/cpp/amy_android_profile.cpp create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyService.java create mode 100644 android/build.gradle.kts create mode 100644 android/hello-world/build.gradle.kts create mode 100644 android/hello-world/src/main/AndroidManifest.xml create mode 100644 android/hello-world/src/main/cpp/CMakeLists.txt create mode 100644 android/hello-world/src/main/cpp/amy_hello_client.cpp create mode 100644 android/hello-world/src/main/java/org/amy/hello/MainActivity.java create mode 100644 android/settings.gradle.kts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..6c5bdeac --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,109 @@ +name: Android AMY + +on: + pull_request: + paths: + - "android/**" + - "src/**" + - "tests/test_amy_unix_socket.c" + - "tests/run_amy_unix_socket_test.sh" + - ".github/workflows/android.yml" + +permissions: + contents: read + +jobs: + socket-transport: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Build and run private Unix socket test + run: bash tests/run_amy_unix_socket_test.sh + + android-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.13" + + - name: Build AMY Android AAR and hello-world APK + working-directory: android + run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace + + - name: Upload AMY Android AAR + uses: actions/upload-artifact@v4 + with: + name: amy-service-debug-aar + path: android/amy-service/build/outputs/aar/amy-service-debug.aar + if-no-files-found: error + + - name: Upload AMY hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-hello-world-debug-apk + path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + if-no-files-found: error + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Emulator end-to-end smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim + script: | + adb uninstall org.amy.hello >/dev/null 2>&1 || true + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-first.log + test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-first.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-first.log + grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + adb uninstall org.amy.hello + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-second.log + test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-second.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-second.log + grep -q 'wire: v0n72l1Z' /tmp/amy-second.log diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts new file mode 100644 index 00000000..c0c0e37e --- /dev/null +++ b/android/amy-service/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "org.amy.audio" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + minSdk = 26 + + // arm64-v8a is the production target. x86_64 is included on this + // hello-world branch so CI can run the same AMY/Oboe service in the + // hardware-accelerated Android emulator. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + arguments += "-DANDROID_STL=c++_shared" + cppFlags += "-std=c++17" + } + } + } + + buildFeatures { + prefab = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("com.google.oboe:oboe:1.10.0") +} diff --git a/android/amy-service/src/main/AndroidManifest.xml b/android/amy-service/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cde4251b --- /dev/null +++ b/android/amy-service/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..25c92db2 --- /dev/null +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_android LANGUAGES C CXX) + +find_package(oboe REQUIRED CONFIG) + +set(AMY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../..") +set(AMY_SRC "${AMY_ROOT}/src") + +set(AMY_SOURCES + ${AMY_SRC}/algorithms.c + ${AMY_SRC}/amy.c + ${AMY_SRC}/amy_unix_socket.c + ${AMY_SRC}/delay.c + ${AMY_SRC}/envelope.c + ${AMY_SRC}/filters.c + ${AMY_SRC}/parse.c + ${AMY_SRC}/sequencer.c + ${AMY_SRC}/transfer.c + ${AMY_SRC}/midi_mappings.c + ${AMY_SRC}/custom.c + ${AMY_SRC}/patches.c + ${AMY_SRC}/oscillators.c + ${AMY_SRC}/interp_partials.c + ${AMY_SRC}/pcm.c + ${AMY_SRC}/log2_exp2.c + ${AMY_SRC}/instrument.c + ${AMY_SRC}/amy_midi.c + ${AMY_SRC}/api.c + ${AMY_SRC}/cv_trigger.c +) + +add_library(amy_android SHARED + amy_android.cpp + amy_android_profile.cpp + ${AMY_SOURCES} +) + +target_include_directories(amy_android PRIVATE + ${AMY_SRC} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# AMY_DAISY selects AMY's existing 48 kHz / 128-frame compile profile. Android +# owns both the audio and MIDI device layers, so no Daisy device implementation +# is linked: AMY_NO_MINIAUDIO leaves Oboe as the sole audio backend and +# AMY_HOST_MIDI leaves run_midi/stop_midi/midi_out to amy_android.cpp. +# delay.c already provides qspi_malloc/qspi_free under AMY_DAISY. pcm.c needs +# declarations for those helpers, so force only the compatibility declarations +# into C translation units; do not link a second allocator implementation. +target_compile_definitions(amy_android PRIVATE + AMY_ANDROID=1 + AMY_DAISY=1 + AMY_HOST_MIDI=1 + AMY_NO_MINIAUDIO=1 + AMY_WAVETABLE=1 +) + +target_compile_options(amy_android PRIVATE + $<$:-include;${CMAKE_CURRENT_SOURCE_DIR}/amy_android_daisy_alloc.h;-O3;-Wall;-Wextra;-Wno-unused-parameter;-Wno-float-conversion> + $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter> +) + +target_compile_features(amy_android PRIVATE c_std_11 cxx_std_17) + +target_link_libraries(amy_android PRIVATE + oboe::oboe + android + log + m +) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp new file mode 100644 index 00000000..bfddd62f --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -0,0 +1,323 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "amy.h" +#include "amy_unix_socket.h" +} + +#define LOG_TAG "AmyAndroid" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* + * AMY's generic API always calls these platform hooks. The Android build does + * not use AMY's miniaudio/I2S platform layer; Oboe owns the output stream and + * calls amy_simple_fill_buffer() directly. + */ +extern "C" void amy_platform_init(void) {} +extern "C" void amy_platform_deinit(void) {} +extern "C" void amy_update_tasks(void) {} +extern "C" int16_t *amy_render_audio(void) { return nullptr; } +extern "C" size_t amy_i2s_write(const uint8_t *, size_t) { return 0; } + +/* + * AMY_HOST_MIDI makes the embedder own the MIDI device layer. This Android + * service is controlled by AMY wire messages rather than a MIDI device, so the + * lifecycle hooks are no-ops. Preserve AMY's optional outgoing MIDI hook even + * though no platform MIDI port is opened here. + */ +extern "C" void run_midi(void) {} +extern "C" void stop_midi(void) {} +extern "C" void midi_out(uint8_t *bytes, uint16_t len) { + if (amy_global.config.amy_external_midi_output_hook != nullptr) { + amy_global.config.amy_external_midi_output_hook(bytes, len); + } +} + +namespace { + +constexpr int kMaxCommandsPerBlock = 64; +constexpr int kAudioReadyTimeoutMs = 2000; +constexpr int kAudioReadyPollMs = 2; + +class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, + public oboe::AudioStreamErrorCallback { +public: + int start(const char *socketPath) { + if (socketPath == nullptr || socketPath[0] == '\0') return -EINVAL; + if (mRunning.load(std::memory_order_acquire)) return -EALREADY; + + amy_config_t config = amy_default_config(); + config.audio = AMY_AUDIO_IS_NONE; + config.features.audio_in = 0; + config.features.default_synths = 0; + config.features.startup_bleep = 0; + /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ + config.platform.multicore = 0; + config.platform.multithread = 0; + /* Physical-string clients can require many simultaneous KS voices. */ + config.ks_oscs = 16; + + amy_start(config); + mAmyStarted = true; + + oboe::AudioStreamBuilder builder; + builder.setDirection(oboe::Direction::Output); + builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); + builder.setSharingMode(oboe::SharingMode::Exclusive); + builder.setFormat(oboe::AudioFormat::I16); + builder.setChannelCount(AMY_NCHANS); + builder.setSampleRate(AMY_SAMPLE_RATE); + builder.setUsage(oboe::Usage::Game); + builder.setContentType(oboe::ContentType::Music); + builder.setDataCallback(this); + builder.setErrorCallback(this); + + oboe::Result result = builder.openStream(mStream); + if (result != oboe::Result::OK || !mStream) { + LOGE("Oboe openStream failed: %s", oboe::convertToText(result)); + stopAmy(); + return static_cast(result); + } + + if (mStream->getFormat() != oboe::AudioFormat::I16 || + mStream->getChannelCount() != AMY_NCHANS || + mStream->getSampleRate() != AMY_SAMPLE_RATE) { + LOGE("Unexpected Oboe format: format=%d channels=%d rate=%d", + static_cast(mStream->getFormat()), + mStream->getChannelCount(), + mStream->getSampleRate()); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ERANGE; + } + + LOGI("Oboe output: deviceId=%d sharing=%d performance=%d usage=%d content=%d framesPerBurst=%d capacity=%d", + mStream->getDeviceId(), + static_cast(mStream->getSharingMode()), + static_cast(mStream->getPerformanceMode()), + static_cast(mStream->getUsage()), + static_cast(mStream->getContentType()), + mStream->getFramesPerBurst(), + mStream->getBufferCapacityInFrames()); + + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + mAudioCallbackSeen.store(false, std::memory_order_release); + mRunning.store(true, std::memory_order_release); + + result = mStream->requestStart(); + if (result != oboe::Result::OK) { + LOGE("Oboe requestStart failed: %s", oboe::convertToText(result)); + mRunning.store(false, std::memory_order_release); + mStream->close(); + mStream.reset(); + stopAmy(); + return static_cast(result); + } + + // Do not publish amy.sock until the realtime audio callback has actually + // executed. This makes successful socket connect a useful readiness + // boundary for generic clients, including the first launch after install. + int waitedMs = 0; + while (!mAudioCallbackSeen.load(std::memory_order_acquire) && + mRunning.load(std::memory_order_acquire) && + waitedMs < kAudioReadyTimeoutMs) { + std::this_thread::sleep_for(std::chrono::milliseconds(kAudioReadyPollMs)); + waitedMs += kAudioReadyPollMs; + } + + if (!mAudioCallbackSeen.load(std::memory_order_acquire)) { + LOGE("Timed out waiting for first Oboe audio callback"); + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ETIMEDOUT; + } + + if (!mRunning.load(std::memory_order_acquire)) { + LOGE("Oboe stream stopped before AMY socket became ready"); + mStream->close(); + mStream.reset(); + stopAmy(); + return -EIO; + } + + amy_unix_socket_server_t *socket = nullptr; + int socketResult = amy_unix_socket_start(&socket, socketPath); + if (socketResult != 0) { + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return socketResult; + } + mSocket.store(socket, std::memory_order_release); + + LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, socket=%s", + AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, socketPath); + return 0; + } + + int32_t outputDeviceId() const { + return mStream ? mStream->getDeviceId() : -1; + } + + void stop() { + mRunning.store(false, std::memory_order_release); + + if (mStream) { + mStream->requestStop(); + mStream->close(); + mStream.reset(); + } + + cleanupSocketAndAmy(); + mAudioCallbackSeen.store(false, std::memory_order_release); + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + } + + oboe::DataCallbackResult onAudioReady( + oboe::AudioStream *, + void *audioData, + int32_t numFrames) override { + int16_t *output = static_cast(audioData); + if (!mRunning.load(std::memory_order_acquire)) { + std::memset(output, 0, + static_cast(numFrames) * AMY_NCHANS * sizeof(int16_t)); + return oboe::DataCallbackResult::Stop; + } + + mAudioCallbackSeen.store(true, std::memory_order_release); + + int32_t outputFrame = 0; + while (outputFrame < numFrames) { + if (mBlock == nullptr || mBlockFrame >= AMY_BLOCK_SIZE) { + drainCommands(); + mBlock = amy_simple_fill_buffer(); + mBlockFrame = 0; + if (mBlock == nullptr) { + std::memset(output + outputFrame * AMY_NCHANS, 0, + static_cast(numFrames - outputFrame) * + AMY_NCHANS * sizeof(int16_t)); + break; + } + } + + const int32_t available = AMY_BLOCK_SIZE - mBlockFrame; + const int32_t frames = std::min(available, numFrames - outputFrame); + std::memcpy( + output + outputFrame * AMY_NCHANS, + mBlock + mBlockFrame * AMY_NCHANS, + static_cast(frames) * AMY_NCHANS * sizeof(int16_t)); + outputFrame += frames; + mBlockFrame += frames; + } + + return oboe::DataCallbackResult::Continue; + } + + void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override { + mRunning.store(false, std::memory_order_release); + LOGE("Oboe stream closed after error: %s", oboe::convertToText(error)); + /* Lifecycle owner may restart the service; no work is done on Oboe's error thread. */ + } + +private: + void drainCommands() { + amy_unix_socket_server_t *socket = mSocket.load(std::memory_order_acquire); + if (socket == nullptr) return; + + char command[MAX_MESSAGE_LEN]; + for (int count = 0; count < kMaxCommandsPerBlock; ++count) { + int length = amy_unix_socket_receive(socket, command, sizeof(command)); + if (length <= 0) break; + amy_add_message(command); + } + } + + void stopAmy() { + if (mAmyStarted) { + amy_stop(); + mAmyStarted = false; + } + } + + void cleanupSocketAndAmy() { + amy_unix_socket_server_t *socket = + mSocket.exchange(nullptr, std::memory_order_acq_rel); + if (socket != nullptr) { + uint32_t overruns = amy_unix_socket_queue_overruns(socket); + uint32_t oversize = amy_unix_socket_oversize_packets(socket); + uint32_t rejected = amy_unix_socket_rejected_peers(socket); + if (overruns || oversize || rejected) { + LOGE("AMY socket diagnostics: overruns=%u oversize=%u rejected=%u", + overruns, oversize, rejected); + } + amy_unix_socket_stop(socket); + } + stopAmy(); + } + + std::atomic mRunning{false}; + std::atomic mAudioCallbackSeen{false}; + bool mAmyStarted = false; + std::atomic mSocket{nullptr}; + std::shared_ptr mStream; + int16_t *mBlock = nullptr; + int32_t mBlockFrame = AMY_BLOCK_SIZE; +}; + +std::mutex gLifecycleMutex; +std::unique_ptr gEngine; + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeStart(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + + std::lock_guard guard(gLifecycleMutex); + if (gEngine) gEngine->stop(); + gEngine = std::make_unique(); + int result = gEngine->start(path); + if (result != 0) gEngine.reset(); + + env->ReleaseStringUTFChars(socketPath, path); + return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeGetOutputDeviceId(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + return gEngine ? gEngine->outputDeviceId() : -1; +} + +extern "C" JNIEXPORT void JNICALL +Java_org_amy_audio_AmyService_nativeStop(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + if (gEngine) { + gEngine->stop(); + gEngine.reset(); + } +} diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c new file mode 100644 index 00000000..a6108f35 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c @@ -0,0 +1,11 @@ +#include "amy_android_daisy_alloc.h" + +#include + +void *qspi_malloc(size_t size) { + return malloc(size); +} + +void qspi_free(void *ptr) { + free(ptr); +} diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h new file mode 100644 index 00000000..7237295e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void *qspi_malloc(size_t size); +void qspi_free(void *ptr); + +#ifdef __cplusplus +} +#endif diff --git a/android/amy-service/src/main/cpp/amy_android_profile.cpp b/android/amy-service/src/main/cpp/amy_android_profile.cpp new file mode 100644 index 00000000..ba37373e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_profile.cpp @@ -0,0 +1,10 @@ +extern "C" { +#include "amy.h" +} + +static_assert(AMY_SAMPLE_RATE == 48000, + "Android AMY service requires a 48 kHz AMY build"); +static_assert(AMY_BLOCK_SIZE == 128, + "Android AMY service requires 128-frame AMY blocks"); +static_assert(AMY_NCHANS == 2, + "Android AMY service expects stereo AMY output"); diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyService.java b/android/amy-service/src/main/java/org/amy/audio/AmyService.java new file mode 100644 index 00000000..98ff90e7 --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -0,0 +1,172 @@ +package org.amy.audio; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.IBinder; +import android.util.Log; + +import java.io.File; +import java.io.IOException; + +/** + * Unexported same-UID service hosting native AMY + Oboe in a separate process. + * + * Musical control never crosses JNI. The host opens the private pathname Unix + * SOCK_SEQPACKET socket and sends one AMY wire message per packet. JNI is only + * used to start/stop the native engine and report its actual Oboe output device. + */ +public final class AmyService extends Service { + private static final String TAG = "AmyService"; + + public static final String EXTRA_SOCKET_PATH = "org.amy.audio.extra.SOCKET_PATH"; + public static final String DEFAULT_SOCKET_NAME = "amy.sock"; + + static { + System.loadLibrary("amy_android"); + } + + private boolean running; + private String runningSocketPath; + + private static native int nativeStart(String socketPath); + private static native int nativeGetOutputDeviceId(); + private static native void nativeStop(); + + /** Start the private AMY process using filesDir/amy.sock. */ + public static void start(Context context) { + File socket = new File(context.getFilesDir(), DEFAULT_SOCKET_NAME); + Intent intent = new Intent(context, AmyService.class); + intent.putExtra(EXTRA_SOCKET_PATH, socket.getAbsolutePath()); + context.startService(intent); + } + + /** Stop the private AMY process. */ + public static void stop(Context context) { + context.stopService(new Intent(context, AmyService.class)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + + String requested = intent.getStringExtra(EXTRA_SOCKET_PATH); + if (requested == null) { + requested = new File(getFilesDir(), DEFAULT_SOCKET_NAME).getAbsolutePath(); + } + + final String socketPath; + try { + socketPath = validatePrivateSocketPath(requested); + } catch (IOException | SecurityException ex) { + Log.e(TAG, "Refusing AMY socket path", ex); + stopSelf(startId); + return START_NOT_STICKY; + } + + // Starting the same service again is normal Android lifecycle behavior. + // Do not tear down an active audio engine and disconnect its socket + // client merely because another equivalent startService() arrived. + if (running && socketPath.equals(runningSocketPath)) { + Log.i(TAG, "AMY already running on private socket " + socketPath); + return START_NOT_STICKY; + } + + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + + int result = nativeStart(socketPath); + if (result != 0) { + Log.e(TAG, "nativeStart failed: " + result); + stopSelf(startId); + return START_NOT_STICKY; + } + + running = true; + runningSocketPath = socketPath; + Log.i(TAG, "AMY listening on private socket " + socketPath); + logOutputRoute(nativeGetOutputDeviceId()); + return START_NOT_STICKY; + } + + private void logOutputRoute(int deviceId) { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + " (AudioManager unavailable)"); + return; + } + + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getId() == deviceId) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=" + audioDeviceTypeName(device.getType()) + + " product=" + String.valueOf(device.getProductName())); + return; + } + } + + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=UNRESOLVED_DEFAULT_OR_DEVICE"); + } + + private static String audioDeviceTypeName(int type) { + switch (type) { + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + return "BUILTIN_EARPIECE"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + return "BUILTIN_SPEAKER"; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + return "WIRED_HEADSET"; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return "WIRED_HEADPHONES"; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + return "BLUETOOTH_SCO"; + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + return "BLUETOOTH_A2DP"; + case AudioDeviceInfo.TYPE_HDMI: + return "HDMI"; + case AudioDeviceInfo.TYPE_USB_DEVICE: + return "USB_DEVICE"; + case AudioDeviceInfo.TYPE_USB_ACCESSORY: + return "USB_ACCESSORY"; + default: + return "TYPE_" + type; + } + } + + private String validatePrivateSocketPath(String requested) throws IOException { + File files = getFilesDir().getCanonicalFile(); + File socket = new File(requested).getCanonicalFile(); + File parent = socket.getParentFile(); + if (parent == null || !parent.equals(files)) { + throw new SecurityException("AMY socket must be directly inside app filesDir"); + } + if (!DEFAULT_SOCKET_NAME.equals(socket.getName())) { + throw new SecurityException("AMY socket filename must be " + DEFAULT_SOCKET_NAME); + } + return socket.getAbsolutePath(); + } + + @Override + public void onDestroy() { + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..a2cc8b72 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("com.android.library") version "8.13.2" apply false +} diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts new file mode 100644 index 00000000..2e77a882 --- /dev/null +++ b/android/hello-world/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "org.amy.hello" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + applicationId = "org.amy.hello" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + cppFlags += "-std=c++17" + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } +} + +dependencies { + implementation(project(":amy-service")) +} diff --git a/android/hello-world/src/main/AndroidManifest.xml b/android/hello-world/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c3f384c --- /dev/null +++ b/android/hello-world/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1915bf81 --- /dev/null +++ b/android/hello-world/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_hello_client LANGUAGES CXX) + +add_library(amy_hello_client SHARED amy_hello_client.cpp) + +target_compile_features(amy_hello_client PRIVATE cxx_std_17) +target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) +target_link_libraries(amy_hello_client PRIVATE log) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp new file mode 100644 index 00000000..df83a9e6 --- /dev/null +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -0,0 +1,101 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#define LOG_TAG "AmyHelloWorld" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +int connect_with_retry(const char *path) { + if (path == nullptr || path[0] == '\0') return -EINVAL; + + sockaddr_un addr{}; + if (std::strlen(path) >= sizeof(addr.sun_path)) return -ENAMETOOLONG; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + for (int attempt = 0; attempt < 100; ++attempt) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); + if (fd < 0) return -errno; + + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + return fd; + } + + int saved = errno; + close(fd); + if (saved != ENOENT && saved != ECONNREFUSED) return -saved; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return -ETIMEDOUT; +} + +int send_wire(int fd, const char *wire) { + size_t len = std::strlen(wire); + ssize_t sent = send(fd, wire, len, MSG_NOSIGNAL); + if (sent < 0) return -errno; + if (static_cast(sent) != len) return -EIO; + LOGI("wire: %s", wire); + return 0; +} + +int play_c_scale(const char *path) { + int fd = connect_with_retry(path); + if (fd < 0) return fd; + + // Raw oscillator 0, sine wave. V is AMY's global output gain, not an + // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. + // Every packet is an ordinary AMY wire command sent through amy.sock. + int rc = send_wire(fd, "v0w0V2.0Z"); + if (rc < 0) { + close(fd); + return rc; + } + + // On a completely fresh AMY instance, commit oscillator setup before the + // first note-on instead of allowing both commands into the same first drain. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + + static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; + char wire[64]; + + for (int note : notes) { + std::snprintf(wire, sizeof(wire), "v0n%dl1Z", note); + rc = send_wire(fd, wire); + if (rc < 0) break; + + std::this_thread::sleep_for(std::chrono::milliseconds(350)); + + rc = send_wire(fd, "v0l0Z"); + if (rc < 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + } + + close(fd); + if (rc == 0) LOGI("C scale complete"); + return rc; +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_hello_MainActivity_nativePlayCScale(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + int rc = play_c_scale(path); + env->ReleaseStringUTFChars(socketPath, path); + if (rc < 0) LOGE("C scale failed: %d", rc); + return rc; +} diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java new file mode 100644 index 00000000..e1b5eb96 --- /dev/null +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -0,0 +1,92 @@ +package org.amy.hello; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.amy.audio.AmyService; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + private TextView status; + private Button playButton; + + static { + System.loadLibrary("amy_hello_client"); + } + + private static native int nativePlayCScale(String socketPath); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(48, 48, 48, 48); + + TextView title = new TextView(this); + title.setText("AMY Hello World"); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + status = new TextView(this); + status.setText("Starting AMY..."); + status.setTextSize(18); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + statusParams.setMargins(0, 40, 0, 40); + root.addView(status, statusParams); + + playButton = new Button(this); + playButton.setText("Play C scale"); + playButton.setOnClickListener(v -> playScale()); + root.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + setContentView(root); + + AmyService.start(this); + if (state == null) { + playScale(); + } else { + status.setText("AMY ready"); + } + } + + private void playScale() { + playButton.setEnabled(false); + status.setText("Playing C major scale..."); + String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + + EXECUTOR.execute(() -> { + int rc = nativePlayCScale(socketPath); + runOnUiThread(() -> { + if (isDestroyed()) return; + if (rc == 0) { + status.setText("C scale complete"); + } else { + status.setText("AMY/socket error: " + rc); + } + playButton.setEnabled(true); + }); + }); + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..d17aab50 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "amy-android" +include(":amy-service") +include(":hello-world") From 9a3e4e1d74a99673ae11ad0ac16614bf03540827 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 23:12:20 +0200 Subject: [PATCH 003/112] Document Android AMY integration and socket contract Document the generic Android AAR, Oboe backend, private SOCK_SEQPACKET client contract, readiness semantics, build requirements, and hello-world example. --- android/README.md | 177 ++++++++++++++++++++++++++++++++++ android/hello-world/README.md | 51 ++++++++++ docs/android_unix_socket.md | 118 +++++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 android/README.md create mode 100644 android/hello-world/README.md create mode 100644 docs/android_unix_socket.md diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..49a30900 --- /dev/null +++ b/android/README.md @@ -0,0 +1,177 @@ +# AMY Android Oboe service + +This directory builds a generic Android AAR that hosts AMY in an unexported +`:amy` service process. The service owns Oboe/AAudio output and receives native +AMY wire messages through the private pathname Unix transport implemented by +`src/amy_unix_socket.[ch]`. + +```text +Android client process + | + | AF_UNIX / SOCK_SEQPACKET + | /amy.sock + | one AMY wire message per packet + v +Android :amy service process + | + +-- amy_unix_socket receiver thread + +-- fixed 64-packet SPSC queue + +-- AMY C engine + +-- Oboe low-latency callback + | + v + AAudio +``` + +The AAR is intended to be embedded by an Android application that wants to use +AMY as its local synth engine. The client can be written with the Android SDK, +Kotlin/Java, native code, Qt, another framework, or any other environment able +to start the service and use an Android Unix-domain `SOCK_SEQPACKET` socket. +AMY itself has no dependency on the client UI framework. + +The service declaration uses `android:exported="false"` and +`android:process=":amy"`. Consequently the service runs in a separate process +from the client while remaining in the same Android application package and +under the same application UID. + +The service only accepts the exact pathname `/amy.sock`. +The native transport creates that node mode `0600` and additionally verifies +accepted peers with `SO_PEERCRED` against the service effective UID. See +`docs/android_unix_socket.md` for the transport/security contract. + +## Audio profile + +The Android native build uses AMY's existing 48 kHz / 128-frame build profile +and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. + +Oboe requests: + +- stereo signed 16-bit output +- 48 kHz +- `PerformanceMode::LowLatency` +- `SharingMode::Exclusive` +- callback-driven output + +The callback size is not assumed to equal 128 frames. The native adapter keeps +only the unconsumed tail of the current AMY block and calls +`amy_simple_fill_buffer()` exactly when another AMY block is required. It does +not add an extra 128-frame output ring. + +Before each new AMY block the callback drains up to 64 already-queued socket +packets and passes them to `amy_add_message()`. The socket thread itself never +calls AMY and never participates in audio rendering. + +AMY is started with its internal platform audio disabled and with AMY rendering +owned by the Oboe callback thread. The current Android build configuration +reserves 16 Karplus-Strong oscillators. + +## JNI boundary + +JNI is lifecycle glue only. `AmyService` calls the native library to start and +stop AMY/Oboe with the validated socket pathname. Notes, patches, sequencer +commands and other musical control do not cross JNI; they use the unchanged AMY +wire protocol through `amy.sock`. + +The client-facing architecture is therefore deliberately transport-oriented: + +```text +client application -> amy.sock -> AMY/Oboe service +``` + +A client does not need AMY-specific JNI bindings. It only needs to start the +service and exchange AMY wire packets over the private socket. + +## Socket client contract + +Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet. +For example the payload of three consecutive packets may be: + +```text +K28i2Z +n60l1i2Z +n60l0i2Z +``` + +Do not add stream framing or depend on newline boundaries. Packet boundaries +are preserved by `SOCK_SEQPACKET`. + +The pathname also serves as the engine readiness boundary. `amy.sock` is not +created until Oboe has started and the realtime audio callback has executed at +least once. A client may therefore retry `connect()` while the service starts; +once `connect()` succeeds it may begin sending AMY wire packets immediately. +No fixed Android-startup sleep is required. + +The socket is bidirectional. The Android engine currently consumes ordinary AMY +wire commands; the existing `amy_unix_socket_send()` path is ready for compact +introspection/status replies when that functionality is integrated. + +## Client integration + +A client application needs to: + +1. package the `amy-service` AAR/module in the Android application; +2. start `org.amy.audio.AmyService` while synthesis is required; +3. obtain the application's actual private files directory rather than + hard-code `/data/user/...`; +4. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` + until the service publishes its ready socket; +5. send one ordinary AMY wire message per packet; +6. optionally receive response packets over the same bidirectional socket; +7. stop and reconnect cleanly across Android application/audio lifecycle + events. + +The transport deliberately does not prescribe a programming language or UI +framework. A minimal example client is provided separately by the Android +hello-world application. + +## Building the AAR + +Requirements used by CI: + +- JDK 17 +- Android SDK platform 36 +- Android NDK 27.0.12077973 +- CMake 3.22.1 +- Gradle 8.13 +- Android Gradle Plugin 8.13.2 +- Oboe 1.10.0 (Prefab dependency) + +From the repository root: + +```bash +cd android +gradle :amy-service:assembleDebug +``` + +The production Android service build targets `arm64-v8a`. Output is below: + +```text +android/amy-service/build/outputs/aar/ +``` + +## Tests + +The private socket regression test is: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, +oversized-packet rejection, cleanup, and protection against deleting an +existing non-socket path. + +`.github/workflows/android.yml` runs that regression plus a complete Android +AAR/NDK/Oboe build. The earlier `.github/workflows/android-unix-socket.yml` +continues to isolate the transport regression itself. + +## Hardware-test items + +The first device tests should measure: + +1. command-to-audio latency; +2. negotiated Oboe callback/device buffer sizes; +3. xruns during patch changes and heavy reverb/delay loads; +4. suspend/resume and audio-device changes; +5. whether executing rare heavy AMY commands at a block boundary needs further + separation from the realtime callback. diff --git a/android/hello-world/README.md b/android/hello-world/README.md new file mode 100644 index 00000000..ee44a05a --- /dev/null +++ b/android/hello-world/README.md @@ -0,0 +1,51 @@ +# AMY Android Hello World + +Minimal Android application proving the generic AMY Android service end to end. + +On launch it: + +1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; +2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V2.0`; +4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; +5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +6. shows `C scale complete` when all packets have been sent. + +The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. + +The generic AMY Android service also logs Oboe's actual output device ID and resolves it through `AudioDeviceInfo`, so device logs identify routes such as `BUILTIN_SPEAKER`, `BUILTIN_EARPIECE`, Bluetooth, wired headphones, or USB where Android exposes a matching device. + +## Wire sequence + +Setup: + +```text +v0w0V2.0Z +``` + +`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. + +Notes use MIDI note numbers and velocity, e.g. middle C: + +```text +v0n60l1Z +v0l0Z +``` + +The complete scale is MIDI notes `60, 62, 64, 65, 67, 69, 71, 72`. + +## Build + +From `android/`: + +```bash +gradle :hello-world:assembleDebug +``` + +APK: + +```text +hello-world/build/outputs/apk/debug/hello-world-debug.apk +``` + +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. diff --git a/docs/android_unix_socket.md b/docs/android_unix_socket.md new file mode 100644 index 00000000..0a0de556 --- /dev/null +++ b/docs/android_unix_socket.md @@ -0,0 +1,118 @@ +# Android private `amy.sock` transport + +`src/amy_unix_socket.c` provides a small Linux/Android pathname `AF_UNIX` +transport intended for a stand-alone AMY + Oboe Android process. + +The Android application should choose a pathname below its private internal +storage directory, for example conceptually: + +``` +/data/user/0//files/amy.sock +``` + +Do not hard-code that example path. Obtain the application's actual internal +files directory from Android and pass the resulting full pathname to the native +AMY process/service. + +## Security properties + +The server: + +- uses `AF_UNIX` + `SOCK_SEQPACKET` rather than TCP/UDP; +- creates the socket pathname mode `0600`; +- on Linux/Android accepts only peers whose `SO_PEERCRED` UID equals the + server's effective UID; +- removes a stale socket only when it is a socket owned by the same UID; +- never removes an existing regular file or foreign-owned socket; +- supports one connected client at a time. + +The Android private app-data parent directory remains the primary sandbox +boundary. Socket mode and peer credentials are defense in depth. + +## Realtime ownership + +The socket receiver thread never calls AMY. Each received `SOCK_SEQPACKET` +message is copied into a fixed 64-entry SPSC queue. There is no allocation in +the dequeue path. + +The AMY/Oboe owner should drain the queue at a safe block boundary: + +```c +#include "amy.h" +#include "amy_unix_socket.h" + +static amy_unix_socket_server_t *amy_socket; + +void process_amy_socket(void) { + char message[MAX_MESSAGE_LEN]; + for (;;) { + int len = amy_unix_socket_receive( + amy_socket, message, sizeof(message)); + if (len <= 0) break; + amy_add_message(message); + } +} +``` + +For an Oboe backend, call `process_amy_socket()` immediately before producing a +new AMY render block, not from the socket thread. + +A packet payload may omit a terminating NUL; the dequeue API adds one. Keep a +single AMY wire command or other logical request in each packet. Maximum packet +payload is `MAX_MESSAGE_LEN - 1` bytes. + +## Bidirectional replies + +`amy_unix_socket_send()` sends one `SOCK_SEQPACKET` reply to the current +client. It is non-blocking and intended for control/status/introspection paths, +not for the realtime audio callback. + +This means the compact introspection protocol can later use the same connection: + +``` +Qt -> AMY ?iv +AMY -> Qt !iv1 +``` + +The socket transport itself intentionally does not depend on the introspection +implementation, so the two branches can be reviewed and merged independently. + +## Starting and stopping + +```c +amy_unix_socket_server_t *server = NULL; +int rc = amy_unix_socket_start(&server, socket_path); +if (rc < 0) { + // rc is -errno +} + +// ... run AMY/Oboe ... + +amy_unix_socket_stop(server); +``` + +Stopping joins the receiver thread and removes the socket pathname. + +## Diagnostics + +These counters can be queried from a non-realtime diagnostics path: + +- `amy_unix_socket_queue_overruns()` +- `amy_unix_socket_oversize_packets()` +- `amy_unix_socket_rejected_peers()` + +A queue overrun means the AMY/control owner is not draining packets quickly +enough. The transport drops the new packet rather than blocking the receiver or +allocating more memory. + +## Host regression test + +On Linux: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +The test verifies round-trip packet transport, socket mode/ownership, +non-consuming `EMSGSIZE` behavior, oversized-packet rejection, pathname cleanup, +and refusal to delete a pre-existing regular file. From bd45e9b5cf4de3449250e11b115ce9afe55f4d94 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:11:16 +0200 Subject: [PATCH 004/112] Validate Android audio level and correct hello-world gain Measure the raw AMY render stream and exact signed-16-bit buffer handed to Oboe during the Android hello-world integration test. Retain both WAVs and level statistics in CI, require byte-for-byte AMY-to-Oboe identity, healthy peak level, and no full-scale clipping. Use AMY V10.0 for the audible hello-world. AMY's V control is a 0..10 bus/master scale and the final mixer applies a 0.1 factor, so the previous V2.0 setting was only 20% linear gain (about -14 dB relative to V10.0). Validated at -2.721 dBFS peak with zero clipping and zero AMY-to-Oboe sample differences in Android AMY run 32630088165. --- .github/workflows/android.yml | 34 ++- .../amy-service/src/main/cpp/CMakeLists.txt | 1 + .../amy-service/src/main/cpp/amy_android.cpp | 21 ++ .../src/main/cpp/amy_android_capture.cpp | 278 ++++++++++++++++++ .../src/main/cpp/amy_android_capture.h | 50 ++++ android/hello-world/README.md | 8 +- .../src/main/cpp/amy_hello_client.cpp | 7 +- .../main/java/org/amy/hello/MainActivity.java | 16 + tests/check_android_audio_capture.py | 113 +++++++ 9 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 android/amy-service/src/main/cpp/amy_android_capture.cpp create mode 100644 android/amy-service/src/main/cpp/amy_android_capture.h create mode 100644 tests/check_android_audio_capture.py diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6c5bdeac..8a758861 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -7,6 +7,7 @@ on: - "src/**" - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" + - "tests/check_android_audio_capture.py" - ".github/workflows/android.yml" permissions: @@ -84,26 +85,51 @@ jobs: adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-first.log test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-first.log - grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-first.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-first.log grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + grep -q 'Audio capture complete:' /tmp/amy-first.log + adb uninstall org.amy.hello adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-second.log test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-second.log - grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-second.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-second.log grep -q 'wire: v0n72l1Z' /tmp/amy-second.log + grep -q 'Audio capture complete:' /tmp/amy-second.log + + mkdir -p android/audio-capture + adb exec-out run-as org.amy.hello cat files/amy-render.wav > android/audio-capture/amy-render.wav + adb exec-out run-as org.amy.hello cat files/amy-oboe.wav > android/audio-capture/amy-oboe.wav + adb exec-out run-as org.amy.hello cat files/amy-audio-levels.txt > android/audio-capture/amy-audio-levels.txt + test -s android/audio-capture/amy-render.wav + test -s android/audio-capture/amy-oboe.wav + test -s android/audio-capture/amy-audio-levels.txt + cat android/audio-capture/amy-audio-levels.txt + + - name: Analyze captured AMY and Oboe audio levels + run: | + python3 tests/check_android_audio_capture.py \ + android/audio-capture/amy-render.wav \ + android/audio-capture/amy-oboe.wav + + - name: Upload Android audio captures + uses: actions/upload-artifact@v4 + with: + name: amy-android-audio-capture + path: android/audio-capture/ + if-no-files-found: error diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 25c92db2..08fbf51c 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -31,6 +31,7 @@ set(AMY_SOURCES add_library(amy_android SHARED amy_android.cpp + amy_android_capture.cpp amy_android_profile.cpp ${AMY_SOURCES} ) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index bfddd62f..250538b8 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -12,6 +12,8 @@ #include #include +#include "amy_android_capture.h" + extern "C" { #include "amy.h" #include "amy_unix_socket.h" @@ -73,6 +75,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, amy_start(config); mAmyStarted = true; + // The helper remains dormant unless the hello-world test has created + // its one-shot private capture marker. It captures the exact samples + // returned by AMY and the exact I16 samples handed to Oboe. + mCapture = std::make_unique( + socketPath, AMY_SAMPLE_RATE, AMY_NCHANS); + oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output); builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); @@ -188,6 +196,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, mStream.reset(); } + // No callback can touch the capture buffers after the stream closes. + if (mCapture) { + mCapture->stop(); + mCapture.reset(); + } + cleanupSocketAndAmy(); mAudioCallbackSeen.store(false, std::memory_order_release); mBlock = nullptr; @@ -206,6 +220,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, } mAudioCallbackSeen.store(true, std::memory_order_release); + if (mCapture && mCapture->enabled()) mCapture->beginCallback(numFrames); int32_t outputFrame = 0; while (outputFrame < numFrames) { @@ -223,6 +238,10 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, const int32_t available = AMY_BLOCK_SIZE - mBlockFrame; const int32_t frames = std::min(available, numFrames - outputFrame); + if (mCapture && mCapture->enabled()) { + mCapture->captureAmyChunk( + mBlock + mBlockFrame * AMY_NCHANS, frames, outputFrame); + } std::memcpy( output + outputFrame * AMY_NCHANS, mBlock + mBlockFrame * AMY_NCHANS, @@ -231,6 +250,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, mBlockFrame += frames; } + if (mCapture && mCapture->enabled()) mCapture->finishCallback(output, numFrames); return oboe::DataCallbackResult::Continue; } @@ -281,6 +301,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, bool mAmyStarted = false; std::atomic mSocket{nullptr}; std::shared_ptr mStream; + std::unique_ptr mCapture; int16_t *mBlock = nullptr; int32_t mBlockFrame = AMY_BLOCK_SIZE; }; diff --git a/android/amy-service/src/main/cpp/amy_android_capture.cpp b/android/amy-service/src/main/cpp/amy_android_capture.cpp new file mode 100644 index 00000000..597b75f5 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.cpp @@ -0,0 +1,278 @@ +#include "amy_android_capture.h" + +#include + +#include +#include +#include +#include + +#include + +#define LOG_TAG "AmyAudioCapture" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +constexpr int32_t kCaptureSeconds = 4; +constexpr const char *kEnableMarker = "amy-audio-capture.enable"; +constexpr const char *kAmyWave = "amy-render.wav"; +constexpr const char *kOboeWave = "amy-oboe.wav"; +constexpr const char *kStatsFile = "amy-audio-levels.txt"; + +std::string joinPath(const std::string &directory, const char *name) { + return directory + "/" + name; +} + +void writeLe16(FILE *file, uint16_t value) { + const uint8_t bytes[2] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +void writeLe32(FILE *file, uint32_t value) { + const uint8_t bytes[4] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + static_cast((value >> 16) & 0xff), + static_cast((value >> 24) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +bool writeWave(const std::string &path, + const std::vector &samples, + int32_t frames, + int32_t sampleRate, + int32_t channels) { + FILE *file = std::fopen(path.c_str(), "wb"); + if (file == nullptr) return false; + + const uint32_t sampleCount = static_cast(frames * channels); + const uint32_t dataBytes = sampleCount * sizeof(int16_t); + const uint32_t byteRate = static_cast(sampleRate * channels * sizeof(int16_t)); + const uint16_t blockAlign = static_cast(channels * sizeof(int16_t)); + + std::fwrite("RIFF", 4, 1, file); + writeLe32(file, 36u + dataBytes); + std::fwrite("WAVE", 4, 1, file); + std::fwrite("fmt ", 4, 1, file); + writeLe32(file, 16); + writeLe16(file, 1); // PCM + writeLe16(file, static_cast(channels)); + writeLe32(file, static_cast(sampleRate)); + writeLe32(file, byteRate); + writeLe16(file, blockAlign); + writeLe16(file, 16); + std::fwrite("data", 4, 1, file); + writeLe32(file, dataBytes); + std::fwrite(samples.data(), sizeof(int16_t), sampleCount, file); + + const bool ok = std::fclose(file) == 0; + return ok; +} + +struct LevelStats { + int32_t peak = 0; + double rms = 0.0; + double peakDbfs = -200.0; + double rmsDbfs = -200.0; +}; + +LevelStats levelStats(const std::vector &samples, int32_t sampleCount) { + LevelStats result; + if (sampleCount <= 0) return result; + + long double sumSquares = 0.0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t value = samples[static_cast(i)]; + const int32_t magnitude = value == -32768 ? 32768 : std::abs(value); + result.peak = std::max(result.peak, magnitude); + const long double sample = static_cast(value); + sumSquares += sample * sample; + } + + result.rms = std::sqrt(static_cast(sumSquares / sampleCount)); + if (result.peak > 0) { + result.peakDbfs = 20.0 * std::log10(static_cast(result.peak) / 32768.0); + } + if (result.rms > 0.0) { + result.rmsDbfs = 20.0 * std::log10(result.rms / 32768.0); + } + return result; +} + +} // namespace + +AmyAndroidAudioCapture::AmyAndroidAudioCapture( + const char *socketPath, int32_t sampleRate, int32_t channels) + : mSampleRate(sampleRate), mChannels(channels) { + if (socketPath == nullptr || sampleRate <= 0 || channels <= 0) return; + + std::string path(socketPath); + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) return; + mDirectory = path.substr(0, slash); + + const std::string marker = joinPath(mDirectory, kEnableMarker); + if (access(marker.c_str(), F_OK) != 0) return; + + // The marker is one-shot. The hello-world app recreates it for each clean + // launch; ordinary users of the AAR never pay the capture cost. + unlink(marker.c_str()); + unlink(joinPath(mDirectory, kAmyWave).c_str()); + unlink(joinPath(mDirectory, kOboeWave).c_str()); + unlink(joinPath(mDirectory, kStatsFile).c_str()); + + mTargetFrames = sampleRate * kCaptureSeconds; + const size_t sampleCount = static_cast(mTargetFrames) * channels; + try { + mAmySamples.resize(sampleCount); + mOboeSamples.resize(sampleCount); + } catch (...) { + LOGE("Unable to allocate Android audio capture buffers"); + mAmySamples.clear(); + mOboeSamples.clear(); + return; + } + + mEnabled = true; + mWriter = std::thread(&AmyAndroidAudioCapture::writerLoop, this); + LOGI("Audio capture armed: %d frames, %d Hz, %d channels", + mTargetFrames, mSampleRate, mChannels); +} + +AmyAndroidAudioCapture::~AmyAndroidAudioCapture() { + stop(); +} + +void AmyAndroidAudioCapture::beginCallback(int32_t numFrames) { + if (!mEnabled || mWriterReady.load(std::memory_order_acquire) || numFrames <= 0) { + mCallbackFrames = 0; + return; + } + + const int32_t remaining = mTargetFrames - mFramesCaptured; + mCallbackStartFrame = mFramesCaptured; + mCallbackFrames = std::min(numFrames, std::max(remaining, 0)); +} + +void AmyAndroidAudioCapture::captureAmyChunk( + const int16_t *samples, int32_t frames, int32_t outputFrame) { + if (!mEnabled || samples == nullptr || frames <= 0 || mCallbackFrames <= 0) return; + if (outputFrame < 0 || outputFrame >= mCallbackFrames) return; + + const int32_t copyFrames = std::min(frames, mCallbackFrames - outputFrame); + const size_t destinationSample = + static_cast(mCallbackStartFrame + outputFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mAmySamples.data() + destinationSample, + samples, + sampleCount * sizeof(int16_t)); +} + +void AmyAndroidAudioCapture::finishCallback( + const int16_t *oboeOutput, int32_t numFrames) { + if (!mEnabled || oboeOutput == nullptr || numFrames <= 0 || mCallbackFrames <= 0) return; + + const int32_t copyFrames = std::min(numFrames, mCallbackFrames); + const size_t destinationSample = static_cast(mCallbackStartFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mOboeSamples.data() + destinationSample, + oboeOutput, + sampleCount * sizeof(int16_t)); + + mFramesCaptured += copyFrames; + mCallbackFrames = 0; + + if (mFramesCaptured >= mTargetFrames) { + mWriterReady.store(true, std::memory_order_release); + mWriterCv.notify_one(); + } +} + +void AmyAndroidAudioCapture::stop() { + if (!mEnabled || mStopped) return; + mStopped = true; + + { + std::lock_guard lock(mWriterMutex); + if (mFramesCaptured > 0) { + mWriterReady.store(true, std::memory_order_release); + } + mWriterStop = true; + } + mWriterCv.notify_one(); + if (mWriter.joinable()) mWriter.join(); +} + +void AmyAndroidAudioCapture::writerLoop() { + std::unique_lock lock(mWriterMutex); + mWriterCv.wait(lock, [this] { + return mWriterReady.load(std::memory_order_acquire) || mWriterStop; + }); + const bool shouldWrite = + mWriterReady.load(std::memory_order_acquire) && mFramesCaptured > 0; + lock.unlock(); + + if (shouldWrite) writeCaptureFiles(); +} + +void AmyAndroidAudioCapture::writeCaptureFiles() { + const int32_t frames = std::min(mFramesCaptured, mTargetFrames); + const int32_t sampleCount = frames * mChannels; + if (frames <= 0 || sampleCount <= 0) return; + + const std::string amyPath = joinPath(mDirectory, kAmyWave); + const std::string oboePath = joinPath(mDirectory, kOboeWave); + const std::string statsPath = joinPath(mDirectory, kStatsFile); + + const bool amyOk = writeWave(amyPath, mAmySamples, frames, mSampleRate, mChannels); + const bool oboeOk = writeWave(oboePath, mOboeSamples, frames, mSampleRate, mChannels); + + const LevelStats amy = levelStats(mAmySamples, sampleCount); + const LevelStats oboe = levelStats(mOboeSamples, sampleCount); + + int32_t maxAbsDiff = 0; + int32_t mismatchSamples = 0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t a = mAmySamples[static_cast(i)]; + const int32_t b = mOboeSamples[static_cast(i)]; + const int32_t difference = std::abs(a - b); + maxAbsDiff = std::max(maxAbsDiff, difference); + if (difference != 0) ++mismatchSamples; + } + + FILE *stats = std::fopen(statsPath.c_str(), "w"); + if (stats != nullptr) { + std::fprintf(stats, "sample_rate=%d\n", mSampleRate); + std::fprintf(stats, "channels=%d\n", mChannels); + std::fprintf(stats, "frames=%d\n", frames); + std::fprintf(stats, "amy_peak=%d\n", amy.peak); + std::fprintf(stats, "amy_peak_dbfs=%.3f\n", amy.peakDbfs); + std::fprintf(stats, "amy_rms=%.3f\n", amy.rms); + std::fprintf(stats, "amy_rms_dbfs=%.3f\n", amy.rmsDbfs); + std::fprintf(stats, "oboe_peak=%d\n", oboe.peak); + std::fprintf(stats, "oboe_peak_dbfs=%.3f\n", oboe.peakDbfs); + std::fprintf(stats, "oboe_rms=%.3f\n", oboe.rms); + std::fprintf(stats, "oboe_rms_dbfs=%.3f\n", oboe.rmsDbfs); + std::fprintf(stats, "max_abs_diff=%d\n", maxAbsDiff); + std::fprintf(stats, "mismatch_samples=%d\n", mismatchSamples); + std::fclose(stats); + } + + if (!amyOk || !oboeOk || stats == nullptr) { + LOGE("Audio capture write failed: amy=%d oboe=%d stats=%d", + amyOk, oboeOk, stats != nullptr); + return; + } + + LOGI("Audio capture complete: frames=%d AMY peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); Oboe peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); mismatches=%d maxdiff=%d", + frames, + amy.peak, amy.peakDbfs, amy.rms, amy.rmsDbfs, + oboe.peak, oboe.peakDbfs, oboe.rms, oboe.rmsDbfs, + mismatchSamples, maxAbsDiff); +} diff --git a/android/amy-service/src/main/cpp/amy_android_capture.h b/android/amy-service/src/main/cpp/amy_android_capture.h new file mode 100644 index 00000000..e77f00c0 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class AmyAndroidAudioCapture { +public: + AmyAndroidAudioCapture(const char *socketPath, int32_t sampleRate, int32_t channels); + ~AmyAndroidAudioCapture(); + + bool enabled() const { return mEnabled; } + + // Called only from Oboe's realtime callback. These methods allocate no + // memory, perform no file I/O, and never take the writer mutex. + void beginCallback(int32_t numFrames); + void captureAmyChunk(const int16_t *samples, int32_t frames, int32_t outputFrame); + void finishCallback(const int16_t *oboeOutput, int32_t numFrames); + + // Called after the Oboe stream has stopped. A partial capture is still + // written, which makes diagnostics useful even on early shutdown/error. + void stop(); + +private: + void writerLoop(); + void writeCaptureFiles(); + + bool mEnabled = false; + bool mStopped = false; + int32_t mSampleRate = 0; + int32_t mChannels = 0; + int32_t mTargetFrames = 0; + int32_t mFramesCaptured = 0; + int32_t mCallbackStartFrame = 0; + int32_t mCallbackFrames = 0; + + std::string mDirectory; + std::vector mAmySamples; + std::vector mOboeSamples; + + std::mutex mWriterMutex; + std::condition_variable mWriterCv; + std::atomic mWriterReady{false}; + bool mWriterStop = false; + std::thread mWriter; +}; diff --git a/android/hello-world/README.md b/android/hello-world/README.md index ee44a05a..491abfe2 100644 --- a/android/hello-world/README.md +++ b/android/hello-world/README.md @@ -6,7 +6,7 @@ On launch it: 1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; 2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; -3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V2.0`; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V10.0`; 4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; 5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; 6. shows `C scale complete` when all packets have been sent. @@ -20,10 +20,10 @@ The generic AMY Android service also logs Oboe's actual output device ID and res Setup: ```text -v0w0V2.0Z +v0w0V10.0Z ``` -`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. +`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. `V2.0`, used by an earlier version of this example, was only 20% linear master gain (about -14 dB relative to `V10.0`). Notes use MIDI note numbers and velocity, e.g. middle C: @@ -48,4 +48,4 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The Android audio-level regression also captures the raw AMY signed-16-bit render stream and the exact signed-16-bit callback buffer handed to Oboe, verifies that they are sample-for-sample identical, and checks their measured peak/RMS level. diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp index df83a9e6..8a4ab011 100644 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -54,10 +54,11 @@ int play_c_scale(const char *path) { int fd = connect_with_retry(path); if (fd < 0) return fd; - // Raw oscillator 0, sine wave. V is AMY's global output gain, not an - // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. + // Raw oscillator 0, sine wave. AMY's V control is a 0..10 bus/master + // volume scale; the final mixer multiplies V by 0.1. Use V10.0 so this + // audible hello-world exercises the full AMY output level. // Every packet is an ordinary AMY wire command sent through amy.sock. - int rc = send_wire(fd, "v0w0V2.0Z"); + int rc = send_wire(fd, "v0w0V10.0Z"); if (rc < 0) { close(fd); return rc; diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java index e1b5eb96..044d530f 100644 --- a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.os.Bundle; +import android.util.Log; import android.view.Gravity; import android.view.ViewGroup; import android.widget.Button; @@ -11,10 +12,13 @@ import org.amy.audio.AmyService; import java.io.File; +import java.io.IOException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public final class MainActivity extends Activity { + private static final String TAG = "AmyHelloWorld"; + private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); private TextView status; @@ -62,6 +66,18 @@ protected void onCreate(Bundle state) { setContentView(root); + // The hello-world app is also the Android integration test client. Arm + // one diagnostic capture before starting the service. The generic AAR + // does not capture anything unless this private marker exists. + try { + File marker = new File(getFilesDir(), AUDIO_CAPTURE_MARKER); + if (!marker.createNewFile() && !marker.isFile()) { + Log.e(TAG, "Unable to arm AMY audio capture: " + marker); + } + } catch (IOException ex) { + Log.e(TAG, "Unable to arm AMY audio capture", ex); + } + AmyService.start(this); if (state == null) { playScale(); diff --git a/tests/check_android_audio_capture.py b/tests/check_android_audio_capture.py new file mode 100644 index 00000000..0e834448 --- /dev/null +++ b/tests/check_android_audio_capture.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +import argparse +import array +import math +import sys +import wave + + +def read_wave(path): + with wave.open(path, "rb") as wav: + channels = wav.getnchannels() + sample_width = wav.getsampwidth() + sample_rate = wav.getframerate() + frames = wav.getnframes() + raw = wav.readframes(frames) + + if sample_width != 2: + raise ValueError(f"{path}: expected 16-bit PCM, got {sample_width * 8} bits") + + samples = array.array("h") + samples.frombytes(raw) + if sys.byteorder != "little": + samples.byteswap() + return channels, sample_rate, frames, samples + + +def levels(samples): + if not samples: + return 0, 0.0, -200.0, -200.0, 0 + + peak = max(abs(int(sample)) for sample in samples) + sum_squares = sum(int(sample) * int(sample) for sample in samples) + rms = math.sqrt(sum_squares / len(samples)) + peak_dbfs = 20.0 * math.log10(peak / 32768.0) if peak else -200.0 + rms_dbfs = 20.0 * math.log10(rms / 32768.0) if rms else -200.0 + clipped = sum(sample in (-32768, 32767) for sample in samples) + return peak, rms, peak_dbfs, rms_dbfs, clipped + + +def main(): + parser = argparse.ArgumentParser( + description="Compare AMY renderer samples with the I16 buffer handed to Oboe" + ) + parser.add_argument("amy_wave") + parser.add_argument("oboe_wave") + parser.add_argument( + "--min-peak-dbfs", + type=float, + default=-6.0, + help="fail when either capture peak is below this value (default: -6 dBFS)", + ) + args = parser.parse_args() + + amy_channels, amy_rate, amy_frames, amy = read_wave(args.amy_wave) + oboe_channels, oboe_rate, oboe_frames, oboe = read_wave(args.oboe_wave) + + expected = (2, 48000) + if (amy_channels, amy_rate) != expected: + raise SystemExit( + f"AMY capture format mismatch: {amy_channels} channels @ {amy_rate} Hz" + ) + if (oboe_channels, oboe_rate) != expected: + raise SystemExit( + f"Oboe capture format mismatch: {oboe_channels} channels @ {oboe_rate} Hz" + ) + if amy_frames != oboe_frames or len(amy) != len(oboe): + raise SystemExit( + f"capture length mismatch: AMY={amy_frames} frames Oboe={oboe_frames} frames" + ) + + mismatch_samples = 0 + max_abs_diff = 0 + for source, output in zip(amy, oboe): + difference = abs(int(source) - int(output)) + if difference: + mismatch_samples += 1 + max_abs_diff = max(max_abs_diff, difference) + + amy_peak, amy_rms, amy_peak_dbfs, amy_rms_dbfs, amy_clipped = levels(amy) + oboe_peak, oboe_rms, oboe_peak_dbfs, oboe_rms_dbfs, oboe_clipped = levels(oboe) + + print(f"frames={amy_frames} channels={amy_channels} sample_rate={amy_rate}") + print( + f"AMY : peak={amy_peak:5d} {amy_peak_dbfs:7.2f} dBFS " + f"RMS={amy_rms:9.2f} {amy_rms_dbfs:7.2f} dBFS clipped={amy_clipped}" + ) + print( + f"Oboe: peak={oboe_peak:5d} {oboe_peak_dbfs:7.2f} dBFS " + f"RMS={oboe_rms:9.2f} {oboe_rms_dbfs:7.2f} dBFS clipped={oboe_clipped}" + ) + print(f"sample mismatches={mismatch_samples} max_abs_diff={max_abs_diff}") + + if mismatch_samples != 0: + raise SystemExit( + "Oboe callback buffer is not byte-for-byte identical to the AMY render stream" + ) + if amy_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"AMY peak {amy_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if oboe_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"Oboe peak {oboe_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if amy_clipped or oboe_clipped: + raise SystemExit( + f"full-scale clipping detected: AMY={amy_clipped} Oboe={oboe_clipped} samples" + ) + + +if __name__ == "__main__": + main() From a12c19bfdd9936dcb10ab8b8e39214041dd56cd9 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 18:18:31 +0200 Subject: [PATCH 005/112] Decouple Android hello-world from AMY service API Make the Java hello-world a transport-only client. MainActivity no longer imports or starts AmyService and no longer loads a JNI/native client library. It uses Android LocalSocket SOCK_SEQPACKET directly and sends only ordinary AMY wire packets to filesDir/amy.sock. Move service startup to an AAR-owned ContentProvider lifecycle hook, remove the hello-world C++/CMake client wrapper, and move test-only audio capture arming into CI so the example remains free of service/test control logic. --- .github/workflows/android.yml | 13 ++ android/README.md | 53 +++++---- .../amy-service/src/main/AndroidManifest.xml | 6 + .../org/amy/audio/AmyAutoStartProvider.java | 37 ++++++ android/hello-world/README.md | 38 ++++-- android/hello-world/build.gradle.kts | 21 +--- .../hello-world/src/main/cpp/CMakeLists.txt | 8 -- .../src/main/cpp/amy_hello_client.cpp | 102 ---------------- .../main/java/org/amy/hello/MainActivity.java | 112 ++++++++++++------ 9 files changed, 190 insertions(+), 200 deletions(-) create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java delete mode 100644 android/hello-world/src/main/cpp/CMakeLists.txt delete mode 100644 android/hello-world/src/main/cpp/amy_hello_client.cpp diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8a758861..9c78dedc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -50,6 +50,13 @@ jobs: working-directory: android run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace + - name: Verify transport-only hello-world packaging + run: | + APK=android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + test -s "$APK" + ! unzip -l "$APK" | grep -q 'libamy_hello_client.so' + test ! -e android/hello-world/src/main/cpp + - name: Upload AMY Android AAR uses: actions/upload-artifact@v4 with: @@ -82,12 +89,15 @@ jobs: script: | adb uninstall org.amy.hello >/dev/null 2>&1 || true adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-first.log + grep -q 'connected to amy.sock' /tmp/amy-first.log test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-first.log grep -q 'wire: v0w0V10.0Z' /tmp/amy-first.log @@ -98,12 +108,15 @@ jobs: adb uninstall org.amy.hello adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-second.log + grep -q 'connected to amy.sock' /tmp/amy-second.log test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-second.log grep -q 'wire: v0w0V10.0Z' /tmp/amy-second.log diff --git a/android/README.md b/android/README.md index 49a30900..17c14f54 100644 --- a/android/README.md +++ b/android/README.md @@ -23,11 +23,13 @@ Android :amy service process AAudio ``` -The AAR is intended to be embedded by an Android application that wants to use -AMY as its local synth engine. The client can be written with the Android SDK, -Kotlin/Java, native code, Qt, another framework, or any other environment able -to start the service and use an Android Unix-domain `SOCK_SEQPACKET` socket. -AMY itself has no dependency on the client UI framework. +The AAR is embedded in an Android application package. Its private +`AmyAutoStartProvider` starts the separate `:amy` service process as part of +Android package initialization; client application code does not start or stop +AMY. A client can therefore be Java/Kotlin, native code, Godot, Qt, another +framework, or any other environment that can package an Android AAR and open an +Android Unix-domain `SOCK_SEQPACKET` socket. No AMY headers, AMY source, JNI +bindings, or language-specific AMY API are required in the client code. The service declaration uses `android:exported="false"` and `android:process=":amy"`. Consequently the service runs in a separate process @@ -36,7 +38,9 @@ under the same application UID. The service only accepts the exact pathname `/amy.sock`. The native transport creates that node mode `0600` and additionally verifies -accepted peers with `SO_PEERCRED` against the service effective UID. See +accepted peers with `SO_PEERCRED` against the service effective UID. The AAR +must therefore be packaged into the same application/UID as the client; this is +intentional and preserves the private-socket security model. See `docs/android_unix_socket.md` for the transport/security contract. ## Audio profile @@ -67,19 +71,20 @@ reserves 16 Karplus-Strong oscillators. ## JNI boundary -JNI is lifecycle glue only. `AmyService` calls the native library to start and -stop AMY/Oboe with the validated socket pathname. Notes, patches, sequencer -commands and other musical control do not cross JNI; they use the unchanged AMY -wire protocol through `amy.sock`. +JNI exists only inside the service implementation. `AmyService` calls the +native library to start and stop AMY/Oboe and to report its actual Oboe output +device. Musical control never crosses JNI: notes, patches, sequencer commands, +and other control are unchanged AMY wire packets sent through `amy.sock`. -The client-facing architecture is therefore deliberately transport-oriented: +The client-facing architecture is deliberately transport-only: ```text client application -> amy.sock -> AMY/Oboe service ``` -A client does not need AMY-specific JNI bindings. It only needs to start the -service and exchange AMY wire packets over the private socket. +The minimal Java hello-world demonstrates this literally with Android's public +`LocalSocket(SOCKET_SEQPACKET)` API. It neither imports `AmyService` nor loads a +native client library. ## Socket client contract @@ -110,19 +115,16 @@ introspection/status replies when that functionality is integrated. A client application needs to: 1. package the `amy-service` AAR/module in the Android application; -2. start `org.amy.audio.AmyService` while synthesis is required; -3. obtain the application's actual private files directory rather than +2. obtain the application's actual private files directory rather than hard-code `/data/user/...`; -4. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` +3. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` until the service publishes its ready socket; -5. send one ordinary AMY wire message per packet; -6. optionally receive response packets over the same bidirectional socket; -7. stop and reconnect cleanly across Android application/audio lifecycle - events. +4. send one ordinary AMY wire message per packet; +5. optionally receive response packets over the same bidirectional socket; +6. reconnect cleanly when its own Android/application lifecycle requires it. -The transport deliberately does not prescribe a programming language or UI -framework. A minimal example client is provided separately by the Android -hello-world application. +Starting AMY is deliberately absent from the client contract. The packaged AAR +owns that Android lifecycle responsibility. ## Building the AAR @@ -162,8 +164,9 @@ oversized-packet rejection, cleanup, and protection against deleting an existing non-socket path. `.github/workflows/android.yml` runs that regression plus a complete Android -AAR/NDK/Oboe build. The earlier `.github/workflows/android-unix-socket.yml` -continues to isolate the transport regression itself. +AAR/NDK/Oboe build and emulator end-to-end test. The emulator arms its own +one-shot audio-capture marker before starting the client; the hello-world +application itself remains transport-only. ## Hardware-test items diff --git a/android/amy-service/src/main/AndroidManifest.xml b/android/amy-service/src/main/AndroidManifest.xml index cde4251b..682656e0 100644 --- a/android/amy-service/src/main/AndroidManifest.xml +++ b/android/amy-service/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + /amy.sock` Unix-domain `SOCK_SEQPACKET` socket and sends ordinary AMY wire messages. -1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; -2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; -3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V10.0`; -4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; -5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; -6. shows `C scale complete` when all packets have been sent. +On launch the client: -The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. +1. retries a pure-Java `android.net.LocalSocket(SOCKET_SEQPACKET)` connection to `/amy.sock` until the independent AMY/Oboe process publishes its ready socket; +2. sends `v0w0V10.0Z` to configure raw oscillator 0 as a sine wave at full AMY master gain; +3. waits 30 ms so setup is committed on a fresh AMY instance before the first note-on; +4. sends wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +5. shows `C scale complete` when all packets have been sent. -The generic AMY Android service also logs Oboe's actual output device ID and resolves it through `AudioDeviceInfo`, so device logs identify routes such as `BUILTIN_SPEAKER`, `BUILTIN_EARPIECE`, Bluetooth, wired headphones, or USB where Android exposes a matching device. +Each Java `OutputStream.write()` is one complete AMY wire request on the `SOCK_SEQPACKET` socket. There is no AMY-specific client API between the application and the wire transport. + +This is the intended framework boundary: + +```text +application/framework code + | + | ordinary AMY wire packets + v +/amy.sock (AF_UNIX / SOCK_SEQPACKET) + | + v +independent Android :amy process -> AMY -> Oboe/AAudio +``` + +The service remains in the same Android application package/UID because the socket is deliberately private (`0600` plus same-UID peer validation). A framework therefore needs only a way to package the Android service AAR and open an Android Unix-domain socket; it does not need AMY headers, AMY source, JNI bindings, or a language-specific AMY API. ## Wire sequence @@ -23,7 +37,7 @@ Setup: v0w0V10.0Z ``` -`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. `V2.0`, used by an earlier version of this example, was only 20% linear master gain (about -14 dB relative to `V10.0`). +`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. Notes use MIDI note numbers and velocity, e.g. middle C: @@ -48,4 +62,4 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The Android audio-level regression also captures the raw AMY signed-16-bit render stream and the exact signed-16-bit callback buffer handed to Oboe, verifies that they are sample-for-sample identical, and checks their measured peak/RMS level. +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. CI, not the example app, arms the test-only audio-capture marker before each launch. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The audio-level regression verifies the raw AMY render stream and the exact signed-16-bit callback buffer handed to Oboe are sample-for-sample identical and checks their measured peak/RMS level. diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts index 2e77a882..5d5c4bd9 100644 --- a/android/hello-world/build.gradle.kts +++ b/android/hello-world/build.gradle.kts @@ -5,7 +5,6 @@ plugins { android { namespace = "org.amy.hello" compileSdk = 36 - ndkVersion = "27.0.12077973" defaultConfig { applicationId = "org.amy.hello" @@ -13,26 +12,12 @@ android { targetSdk = 36 versionCode = 1 versionName = "1.0" - - ndk { - abiFilters += listOf("arm64-v8a", "x86_64") - } - - externalNativeBuild { - cmake { - cppFlags += "-std=c++17" - } - } - } - - externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - version = "3.22.1" - } } } dependencies { + // Package the independent :amy Android service in the APK. MainActivity + // has no Java/JNI dependency on AmyService or on AMY itself; it only uses + // the app-private amy.sock wire transport. implementation(project(":amy-service")) } diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt deleted file mode 100644 index 1915bf81..00000000 --- a/android/hello-world/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,8 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) -project(amy_hello_client LANGUAGES CXX) - -add_library(amy_hello_client SHARED amy_hello_client.cpp) - -target_compile_features(amy_hello_client PRIVATE cxx_std_17) -target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) -target_link_libraries(amy_hello_client PRIVATE log) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp deleted file mode 100644 index 8a4ab011..00000000 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include - -#define LOG_TAG "AmyHelloWorld" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -namespace { - -int connect_with_retry(const char *path) { - if (path == nullptr || path[0] == '\0') return -EINVAL; - - sockaddr_un addr{}; - if (std::strlen(path) >= sizeof(addr.sun_path)) return -ENAMETOOLONG; - addr.sun_family = AF_UNIX; - std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); - - for (int attempt = 0; attempt < 100; ++attempt) { - int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); - if (fd < 0) return -errno; - - if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { - return fd; - } - - int saved = errno; - close(fd); - if (saved != ENOENT && saved != ECONNREFUSED) return -saved; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - return -ETIMEDOUT; -} - -int send_wire(int fd, const char *wire) { - size_t len = std::strlen(wire); - ssize_t sent = send(fd, wire, len, MSG_NOSIGNAL); - if (sent < 0) return -errno; - if (static_cast(sent) != len) return -EIO; - LOGI("wire: %s", wire); - return 0; -} - -int play_c_scale(const char *path) { - int fd = connect_with_retry(path); - if (fd < 0) return fd; - - // Raw oscillator 0, sine wave. AMY's V control is a 0..10 bus/master - // volume scale; the final mixer multiplies V by 0.1. Use V10.0 so this - // audible hello-world exercises the full AMY output level. - // Every packet is an ordinary AMY wire command sent through amy.sock. - int rc = send_wire(fd, "v0w0V10.0Z"); - if (rc < 0) { - close(fd); - return rc; - } - - // On a completely fresh AMY instance, commit oscillator setup before the - // first note-on instead of allowing both commands into the same first drain. - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - - static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; - char wire[64]; - - for (int note : notes) { - std::snprintf(wire, sizeof(wire), "v0n%dl1Z", note); - rc = send_wire(fd, wire); - if (rc < 0) break; - - std::this_thread::sleep_for(std::chrono::milliseconds(350)); - - rc = send_wire(fd, "v0l0Z"); - if (rc < 0) break; - std::this_thread::sleep_for(std::chrono::milliseconds(80)); - } - - close(fd); - if (rc == 0) LOGI("C scale complete"); - return rc; -} - -} // namespace - -extern "C" JNIEXPORT jint JNICALL -Java_org_amy_hello_MainActivity_nativePlayCScale(JNIEnv *env, jclass, jstring socketPath) { - if (socketPath == nullptr) return -EINVAL; - const char *path = env->GetStringUTFChars(socketPath, nullptr); - if (path == nullptr) return -ENOMEM; - int rc = play_c_scale(path); - env->ReleaseStringUTFChars(socketPath, path); - if (rc < 0) LOGE("C scale failed: %d", rc); - return rc; -} diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java index 044d530f..6b979024 100644 --- a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -1,6 +1,8 @@ package org.amy.hello; import android.app.Activity; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; import android.os.Bundle; import android.util.Log; import android.view.Gravity; @@ -9,27 +11,24 @@ import android.widget.LinearLayout; import android.widget.TextView; -import org.amy.audio.AmyService; - import java.io.File; import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public final class MainActivity extends Activity { private static final String TAG = "AmyHelloWorld"; - private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; + private static final String SOCKET_NAME = "amy.sock"; + private static final int CONNECT_ATTEMPTS = 100; + private static final long CONNECT_RETRY_MS = 50; private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private static final int[] NOTES = {60, 62, 64, 65, 67, 69, 71, 72}; private TextView status; private Button playButton; - static { - System.loadLibrary("amy_hello_client"); - } - - private static native int nativePlayCScale(String socketPath); - @Override protected void onCreate(Bundle state) { super.onCreate(state); @@ -48,7 +47,7 @@ protected void onCreate(Bundle state) { ViewGroup.LayoutParams.WRAP_CONTENT)); status = new TextView(this); - status.setText("Starting AMY..."); + status.setText("Connecting to AMY..."); status.setTextSize(18); status.setGravity(Gravity.CENTER); LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( @@ -66,43 +65,86 @@ protected void onCreate(Bundle state) { setContentView(root); - // The hello-world app is also the Android integration test client. Arm - // one diagnostic capture before starting the service. The generic AAR - // does not capture anything unless this private marker exists. - try { - File marker = new File(getFilesDir(), AUDIO_CAPTURE_MARKER); - if (!marker.createNewFile() && !marker.isFile()) { - Log.e(TAG, "Unable to arm AMY audio capture: " + marker); - } - } catch (IOException ex) { - Log.e(TAG, "Unable to arm AMY audio capture", ex); - } - - AmyService.start(this); if (state == null) { playScale(); } else { - status.setText("AMY ready"); + status.setText("AMY socket client ready"); } } private void playScale() { playButton.setEnabled(false); status.setText("Playing C major scale..."); - String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) - .getAbsolutePath(); + String socketPath = new File(getFilesDir(), SOCKET_NAME).getAbsolutePath(); EXECUTOR.execute(() -> { - int rc = nativePlayCScale(socketPath); - runOnUiThread(() -> { - if (isDestroyed()) return; - if (rc == 0) { - status.setText("C scale complete"); - } else { - status.setText("AMY/socket error: " + rc); + try (LocalSocket socket = connectWithRetry(socketPath)) { + OutputStream output = socket.getOutputStream(); + + // Raw oscillator 0, sine wave, full AMY master gain. + // Every write is one complete AMY wire request and therefore + // one SOCK_SEQPACKET packet. The client does not call AMY or + // control the AMY service lifecycle. + sendWire(output, "v0w0V10.0Z"); + Thread.sleep(30); + + for (int note : NOTES) { + sendWire(output, "v0n" + note + "l1Z"); + Thread.sleep(350); + sendWire(output, "v0l0Z"); + Thread.sleep(80); + } + + Log.i(TAG, "C scale complete"); + setResultText("C scale complete"); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + Log.e(TAG, "C scale failed: interrupted", ex); + setResultText("AMY/socket error: interrupted"); + } catch (IOException ex) { + Log.e(TAG, "C scale failed", ex); + setResultText("AMY/socket error: " + ex.getMessage()); + } + }); + } + + private LocalSocket connectWithRetry(String socketPath) + throws IOException, InterruptedException { + IOException lastError = null; + LocalSocketAddress address = new LocalSocketAddress( + socketPath, LocalSocketAddress.Namespace.FILESYSTEM); + + for (int attempt = 0; attempt < CONNECT_ATTEMPTS; ++attempt) { + LocalSocket socket = new LocalSocket(LocalSocket.SOCKET_SEQPACKET); + try { + socket.connect(address); + Log.i(TAG, "connected to amy.sock"); + return socket; + } catch (IOException ex) { + lastError = ex; + try { + socket.close(); + } catch (IOException ignored) { } - playButton.setEnabled(true); - }); + Thread.sleep(CONNECT_RETRY_MS); + } + } + + throw new IOException("timed out connecting to amy.sock", lastError); + } + + private static void sendWire(OutputStream output, String wire) throws IOException { + byte[] payload = wire.getBytes(StandardCharsets.US_ASCII); + output.write(payload); + output.flush(); + Log.i(TAG, "wire: " + wire); + } + + private void setResultText(String text) { + runOnUiThread(() -> { + if (isDestroyed()) return; + status.setText(text); + playButton.setEnabled(true); }); } } From 1bdc0d8e7fadfb6630f92ddec543cb7b2372ac93 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 23:11:48 +0200 Subject: [PATCH 006/112] Add private Unix socket transport for AMY wire protocol Add a fixed-size AF_UNIX/SOCK_SEQPACKET transport for local AMY wire messages, including peer credential checks, bounded queueing, cleanup safeguards, standalone regression tests, and focused CI. --- .github/workflows/android-unix-socket.yml | 21 + src/amy_unix_socket.c | 447 ++++++++++++++++++++++ src/amy_unix_socket.h | 70 ++++ tests/run_amy_unix_socket_test.sh | 20 + tests/test_amy_unix_socket.c | 183 +++++++++ 5 files changed, 741 insertions(+) create mode 100644 .github/workflows/android-unix-socket.yml create mode 100644 src/amy_unix_socket.c create mode 100644 src/amy_unix_socket.h create mode 100644 tests/run_amy_unix_socket_test.sh create mode 100644 tests/test_amy_unix_socket.c diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/android-unix-socket.yml new file mode 100644 index 00000000..6647320b --- /dev/null +++ b/.github/workflows/android-unix-socket.yml @@ -0,0 +1,21 @@ +name: Android Unix socket transport + +on: + pull_request: + paths: + - 'src/amy_unix_socket.c' + - 'src/amy_unix_socket.h' + - 'tests/test_amy_unix_socket.c' + - 'tests/run_amy_unix_socket_test.sh' + - '.github/workflows/android-unix-socket.yml' + +permissions: + contents: read + +jobs: + linux-socket-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Compile and run private Unix socket transport test + run: bash tests/run_amy_unix_socket_test.sh diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c new file mode 100644 index 00000000..c585bfb6 --- /dev/null +++ b/src/amy_unix_socket.c @@ -0,0 +1,447 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#if defined(__linux__) || defined(__ANDROID__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define AMY_UNIX_SOCKET_POLL_MS 50 + +struct amy_unix_socket_packet { + uint16_t len; + char data[MAX_MESSAGE_LEN]; +}; + +struct amy_unix_socket_server { + int listen_fd; + int client_fd; + pthread_t thread; + pthread_mutex_t client_lock; + bool thread_started; + volatile uint32_t running; + + char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + + struct amy_unix_socket_packet queue[AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + volatile uint32_t write_index; + volatile uint32_t read_index; + + volatile uint32_t queue_overruns; + volatile uint32_t oversize_packets; + volatile uint32_t rejected_peers; +}; + +static uint32_t load_u32(const volatile uint32_t *value) { + return __atomic_load_n(value, __ATOMIC_ACQUIRE); +} + +static void store_u32(volatile uint32_t *value, uint32_t new_value) { + __atomic_store_n(value, new_value, __ATOMIC_RELEASE); +} + +static void increment_u32(volatile uint32_t *value) { + __atomic_add_fetch(value, 1u, __ATOMIC_RELAXED); +} + +static int set_nonblocking_cloexec(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -errno; + + flags = fcntl(fd, F_GETFD, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) return -errno; + return 0; +} + +static int remove_owned_stale_socket(const char *path) { + struct stat st; + if (lstat(path, &st) < 0) { + return errno == ENOENT ? 0 : -errno; + } + + if (!S_ISSOCK(st.st_mode)) return -EEXIST; + if (st.st_uid != geteuid()) return -EPERM; + if (unlink(path) < 0) return -errno; + return 0; +} + +static bool peer_has_same_uid(int fd) { + struct ucred cred; + socklen_t len = sizeof(cred); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) < 0) { + return false; + } + return cred.uid == geteuid(); +} + +static void close_client_locked(amy_unix_socket_server_t *server) { + if (server->client_fd >= 0) { + shutdown(server->client_fd, SHUT_RDWR); + close(server->client_fd); + server->client_fd = -1; + } +} + +static void close_client(amy_unix_socket_server_t *server) { + pthread_mutex_lock(&server->client_lock); + close_client_locked(server); + pthread_mutex_unlock(&server->client_lock); +} + +static void queue_packet(amy_unix_socket_server_t *server, + const char *data, + size_t len) { + if (len == 0) return; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + return; + } + + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + if ((uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY) { + increment_u32(&server->queue_overruns); + return; + } + + struct amy_unix_socket_packet *slot = + &server->queue[write_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + memcpy(slot->data, data, len); + slot->data[len] = '\0'; + slot->len = (uint16_t)len; + + store_u32(&server->write_index, write_index + 1u); +} + +static void receive_client_packets(amy_unix_socket_server_t *server, + int client_fd) { + for (;;) { + char packet[MAX_MESSAGE_LEN]; + ssize_t received = recv(client_fd, + packet, + sizeof(packet), + MSG_DONTWAIT | MSG_TRUNC); + if (received > 0) { + if ((size_t)received > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + } else { + queue_packet(server, packet, (size_t)received); + } + continue; + } + + if (received == 0) { + close_client(server); + return; + } + + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + + close_client(server); + return; + } +} + +static void accept_clients(amy_unix_socket_server_t *server) { + for (;;) { + int fd = accept(server->listen_fd, NULL, NULL); + if (fd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + return; + } + + if (set_nonblocking_cloexec(fd) < 0 || !peer_has_same_uid(fd)) { + increment_u32(&server->rejected_peers); + close(fd); + continue; + } + + pthread_mutex_lock(&server->client_lock); + if (server->client_fd >= 0) { + increment_u32(&server->rejected_peers); + close(fd); + } else { + server->client_fd = fd; + } + pthread_mutex_unlock(&server->client_lock); + } +} + +static int current_client_fd(amy_unix_socket_server_t *server) { + int fd; + pthread_mutex_lock(&server->client_lock); + fd = server->client_fd; + pthread_mutex_unlock(&server->client_lock); + return fd; +} + +static void *socket_thread(void *arg) { + amy_unix_socket_server_t *server = + (amy_unix_socket_server_t *)arg; + + while (load_u32(&server->running)) { + struct pollfd fds[2]; + nfds_t count = 1; + + fds[0].fd = server->listen_fd; + fds[0].events = POLLIN; + fds[0].revents = 0; + + int client_fd = current_client_fd(server); + if (client_fd >= 0) { + fds[1].fd = client_fd; + fds[1].events = POLLIN; + fds[1].revents = 0; + count = 2; + } + + int ready = poll(fds, count, AMY_UNIX_SOCKET_POLL_MS); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + + if (fds[0].revents & POLLIN) accept_clients(server); + + if (count == 2) { + if (fds[1].revents & POLLIN) { + receive_client_packets(server, client_fd); + } + if (fds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) { + close_client(server); + } + } + } + + close_client(server); + return NULL; +} + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + if (out_server == NULL || path == NULL || path[0] == '\0') return -EINVAL; + *out_server = NULL; + + size_t path_len = strlen(path); + if (path_len >= sizeof(((struct sockaddr_un *)0)->sun_path)) { + return -ENAMETOOLONG; + } + + int rc = remove_owned_stale_socket(path); + if (rc < 0) return rc; + + amy_unix_socket_server_t *server = calloc(1, sizeof(*server)); + if (server == NULL) return -ENOMEM; + + server->listen_fd = -1; + server->client_fd = -1; + memcpy(server->path, path, path_len + 1u); + + int mutex_rc = pthread_mutex_init(&server->client_lock, NULL); + if (mutex_rc != 0) { + free(server); + return -mutex_rc; + } + + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (fd < 0) { + rc = -errno; + goto fail; + } + server->listen_fd = fd; + + rc = set_nonblocking_cloexec(fd); + if (rc < 0) goto fail; + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + memcpy(addr.sun_path, path, path_len + 1u); + + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + rc = -errno; + goto fail; + } + + // The Android app-data parent directory is already sandboxed. Mode 0600 + // additionally makes filesystem pathname access same-UID only. + if (chmod(path, S_IRUSR | S_IWUSR) < 0) { + rc = -errno; + goto fail; + } + + if (listen(fd, 1) < 0) { + rc = -errno; + goto fail; + } + + store_u32(&server->running, 1u); + int thread_rc = pthread_create(&server->thread, NULL, + socket_thread, server); + if (thread_rc != 0) { + rc = -thread_rc; + store_u32(&server->running, 0u); + goto fail; + } + server->thread_started = true; + + *out_server = server; + return 0; + +fail: + if (server->listen_fd >= 0) close(server->listen_fd); + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); + return rc; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + if (server == NULL) return; + + store_u32(&server->running, 0u); + if (server->thread_started) { + pthread_join(server->thread, NULL); + } + + if (server->listen_fd >= 0) { + close(server->listen_fd); + server->listen_fd = -1; + } + + if (server->path[0] != '\0') unlink(server->path); + pthread_mutex_destroy(&server->client_lock); + free(server); +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + if (server == NULL || out == NULL) return -EINVAL; + + uint32_t read_index = load_u32(&server->read_index); + uint32_t write_index = load_u32(&server->write_index); + if (read_index == write_index) return 0; + + const struct amy_unix_socket_packet *slot = + &server->queue[read_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + size_t len = slot->len; + if (out_len <= len) return -EMSGSIZE; + + memcpy(out, slot->data, len); + out[len] = '\0'; + store_u32(&server->read_index, read_index + 1u); + return (int)len; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + if (server == NULL || (data == NULL && len != 0)) return -EINVAL; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) return -EMSGSIZE; + + pthread_mutex_lock(&server->client_lock); + int fd = server->client_fd; + if (fd < 0) { + pthread_mutex_unlock(&server->client_lock); + return -ENOTCONN; + } + + ssize_t sent = send(fd, data, len, + MSG_DONTWAIT | MSG_NOSIGNAL); + int saved_errno = errno; + pthread_mutex_unlock(&server->client_lock); + + if (sent < 0) return -saved_errno; + return (int)sent; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->queue_overruns); +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->oversize_packets); +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->rejected_peers); +} + +#else + +#include + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + (void)out_server; + (void)path; + return -ENOTSUP; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + (void)server; +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + (void)server; + (void)out; + (void)out_len; + return -ENOTSUP; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + (void)server; + (void)data; + (void)len; + return -ENOTSUP; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +#endif diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h new file mode 100644 index 00000000..245e3937 --- /dev/null +++ b/src/amy_unix_socket.h @@ -0,0 +1,70 @@ +#ifndef AMY_UNIX_SOCKET_H +#define AMY_UNIX_SOCKET_H + +#include +#include + +#include "amy.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Private pathname AF_UNIX transport for local AMY control. +// +// Intended Android topology: +// Qt/Python process <-> amy.sock <-> native AMY/Oboe process +// +// The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET +// packets into this fixed SPSC queue. The audio/control owner drains packets +// explicitly at a safe point (for example, immediately before rendering the +// next AMY block) and may then pass them to amy_add_message(). +// +// One connected client is supported at a time. On Linux/Android, accepted +// peers must have the same effective UID as the server process. The pathname +// is created mode 0600 and a stale socket is removed only when it is owned by +// the same UID; an existing non-socket path is never removed. + +#define AMY_UNIX_SOCKET_QUEUE_CAPACITY 64u +#define AMY_UNIX_SOCKET_MAX_PACKET ((size_t)MAX_MESSAGE_LEN - 1u) + +typedef struct amy_unix_socket_server amy_unix_socket_server_t; + +// Start a server at path. Returns 0 on success or -errno on failure. +// out_server is set only on success. +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path); + +// Stop the receiver thread, close any client, unlink the socket pathname and +// free the server. Safe to call with NULL. +void amy_unix_socket_stop(amy_unix_socket_server_t *server); + +// Non-blocking dequeue for the AMY/control owner. +// Returns payload length (>0), 0 when no packet is queued, or -errno. +// On success out is NUL-terminated; packet payloads themselves need not carry +// a trailing NUL. If out_len is too small, returns -EMSGSIZE and leaves the +// packet queued. +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len); + +// Send one reply packet to the currently connected client. This is intended +// for non-realtime status/introspection replies, not the audio callback. +// Returns bytes sent or -errno. The accepted client socket is non-blocking. +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len); + +// Diagnostic counters. They are monotonic until the server is stopped. +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server); + +#ifdef __cplusplus +} +#endif + +#endif // AMY_UNIX_SOCKET_H diff --git a/tests/run_amy_unix_socket_test.sh b/tests/run_amy_unix_socket_test.sh new file mode 100644 index 00000000..c3b22a21 --- /dev/null +++ b/tests/run_amy_unix_socket_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +out="${TMPDIR:-/tmp}/test_amy_unix_socket" + +cc \ + -std=c11 \ + -O2 \ + -Wall \ + -Wextra \ + -Werror \ + -pthread \ + -I"$repo_root/src" \ + "$repo_root/src/amy_unix_socket.c" \ + "$repo_root/tests/test_amy_unix_socket.c" \ + -o "$out" + +"$out" +rm -f "$out" diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c new file mode 100644 index 00000000..8310cc32 --- /dev/null +++ b/tests/test_amy_unix_socket.c @@ -0,0 +1,183 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int connect_client(const char *path) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(fd >= 0); + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + assert(strlen(path) < sizeof(addr.sun_path)); + strcpy(addr.sun_path, path); + + assert(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0); + return fd; +} + +static int wait_receive(amy_unix_socket_server_t *server, + char *buffer, + size_t buffer_len) { + for (int i = 0; i < 1000; ++i) { + int rc = amy_unix_socket_receive(server, buffer, buffer_len); + if (rc != 0) return rc; + usleep(1000); + } + return -ETIMEDOUT; +} + +static ssize_t wait_client_receive(int fd, void *buffer, size_t len) { + for (int i = 0; i < 1000; ++i) { + ssize_t rc = recv(fd, buffer, len, MSG_DONTWAIT); + if (rc >= 0) return rc; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + return -1; + } + usleep(1000); + } + errno = ETIMEDOUT; + return -1; +} + +static void test_round_trip_and_permissions(void) { + char dir_template[] = "/tmp/amy-unix-socket-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + assert(server != NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISSOCK(st.st_mode)); + assert((st.st_mode & 0777) == 0600); + assert(st.st_uid == geteuid()); + + int client = connect_client(path); + + const char command[] = "n60l1i2Z"; + assert(send(client, command, strlen(command), 0) == + (ssize_t)strlen(command)); + + char received[MAX_MESSAGE_LEN]; + int rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(command)); + assert(strcmp(received, command) == 0); + + // Too-small destination must not consume the next queued packet. + const char second[] = "K28i2Z"; + assert(send(client, second, strlen(second), 0) == + (ssize_t)strlen(second)); + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_receive(server, received, 4); + if (rc != 0) break; + usleep(1000); + } + assert(rc == -EMSGSIZE); + rc = amy_unix_socket_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(second)); + assert(strcmp(received, second) == 0); + + const char reply[] = "!iv1"; + for (int i = 0; i < 1000; ++i) { + rc = amy_unix_socket_send(server, reply, strlen(reply)); + if (rc != -ENOTCONN) break; + usleep(1000); + } + assert(rc == (int)strlen(reply)); + + char reply_buffer[32]; + ssize_t reply_len = wait_client_receive(client, + reply_buffer, + sizeof(reply_buffer)); + assert(reply_len == (ssize_t)strlen(reply)); + assert(memcmp(reply_buffer, reply, strlen(reply)) == 0); + + close(client); + amy_unix_socket_stop(server); + + assert(lstat(path, &st) < 0); + assert(errno == ENOENT); + assert(rmdir(dir) == 0); +} + +static void test_oversize_packet_is_dropped(void) { + char dir_template[] = "/tmp/amy-unix-oversize-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + char packet[MAX_MESSAGE_LEN]; + memset(packet, 'x', sizeof(packet)); + assert(send(client, packet, sizeof(packet), 0) == (ssize_t)sizeof(packet)); + + for (int i = 0; i < 1000; ++i) { + if (amy_unix_socket_oversize_packets(server) != 0) break; + usleep(1000); + } + assert(amy_unix_socket_oversize_packets(server) == 1); + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + + close(client); + amy_unix_socket_stop(server); + assert(rmdir(dir) == 0); +} + +static void test_existing_regular_file_is_never_removed(void) { + char dir_template[] = "/tmp/amy-unix-stale-XXXXXX"; + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + + char path[256]; + snprintf(path, sizeof(path), "%s/amy.sock", dir); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == -EEXIST); + assert(server == NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + + assert(unlink(path) == 0); + assert(rmdir(dir) == 0); +} + +int main(void) { + test_round_trip_and_permissions(); + test_oversize_packet_is_dropped(); + test_existing_regular_file_is_never_removed(); + puts("amy unix socket tests passed"); + return 0; +} From 4104247a0936e69c10ac4d053e1ece84c233c3b8 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 11:24:14 +0200 Subject: [PATCH 007/112] Harden Unix socket transport and tests --- ...ndroid-unix-socket.yml => unix-socket.yml} | 6 +- src/amy_unix_socket.c | 67 +++- src/amy_unix_socket.h | 9 +- tests/run_amy_unix_socket_test.sh | 13 +- tests/test_amy_unix_socket.c | 329 +++++++++++++++--- 5 files changed, 352 insertions(+), 72 deletions(-) rename .github/workflows/{android-unix-socket.yml => unix-socket.yml} (70%) diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/unix-socket.yml similarity index 70% rename from .github/workflows/android-unix-socket.yml rename to .github/workflows/unix-socket.yml index 6647320b..a62761f2 100644 --- a/.github/workflows/android-unix-socket.yml +++ b/.github/workflows/unix-socket.yml @@ -1,4 +1,4 @@ -name: Android Unix socket transport +name: Unix socket transport on: pull_request: @@ -7,7 +7,7 @@ on: - 'src/amy_unix_socket.h' - 'tests/test_amy_unix_socket.c' - 'tests/run_amy_unix_socket_test.sh' - - '.github/workflows/android-unix-socket.yml' + - '.github/workflows/unix-socket.yml' permissions: contents: read @@ -17,5 +17,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - name: Compile and run private Unix socket transport test + - name: Compile and run Unix socket transport test run: bash tests/run_amy_unix_socket_test.sh diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c index c585bfb6..bba2221f 100644 --- a/src/amy_unix_socket.c +++ b/src/amy_unix_socket.c @@ -38,6 +38,9 @@ struct amy_unix_socket_server { volatile uint32_t running; char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + dev_t path_device; + ino_t path_inode; + bool path_bound; struct amy_unix_socket_packet queue[AMY_UNIX_SOCKET_QUEUE_CAPACITY]; volatile uint32_t write_index; @@ -71,6 +74,13 @@ static int set_nonblocking_cloexec(int fd) { return 0; } +static void fill_socket_address(struct sockaddr_un *addr, const char *path) { + size_t path_len = strlen(path); + memset(addr, 0, sizeof(*addr)); + addr->sun_family = AF_UNIX; + memcpy(addr->sun_path, path, path_len + 1u); +} + static int remove_owned_stale_socket(const char *path) { struct stat st; if (lstat(path, &st) < 0) { @@ -79,10 +89,56 @@ static int remove_owned_stale_socket(const char *path) { if (!S_ISSOCK(st.st_mode)) return -EEXIST; if (st.st_uid != geteuid()) return -EPERM; + + // Do not steal the pathname from a live same-UID server. A pathname socket + // left behind after a crash refuses a connection; a listening server + // accepts it. The short-lived probe may be accepted and immediately see + // EOF, but it cannot replace or interrupt an existing client. + int probe_fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (probe_fd < 0) return -errno; + + struct sockaddr_un addr; + fill_socket_address(&addr, path); + int connect_rc = connect(probe_fd, (struct sockaddr *)&addr, sizeof(addr)); + int connect_errno = errno; + close(probe_fd); + + if (connect_rc == 0) return -EADDRINUSE; + if (connect_errno == ENOENT) return 0; + if (connect_errno != ECONNREFUSED) return -connect_errno; + if (unlink(path) < 0) return -errno; return 0; } +static int remember_bound_socket(amy_unix_socket_server_t *server) { + struct stat st; + if (lstat(server->path, &st) < 0) return -errno; + if (!S_ISSOCK(st.st_mode) || st.st_uid != geteuid()) return -EPERM; + + server->path_device = st.st_dev; + server->path_inode = st.st_ino; + server->path_bound = true; + return 0; +} + +static void remove_bound_socket(amy_unix_socket_server_t *server) { + if (!server->path_bound) return; + + // Only unlink the exact filesystem node created by this server. This + // avoids deleting a regular file or a replacement socket if the pathname + // was removed and reused while the server was running. + struct stat st; + if (lstat(server->path, &st) == 0 && + S_ISSOCK(st.st_mode) && + st.st_uid == geteuid() && + st.st_dev == server->path_device && + st.st_ino == server->path_inode) { + unlink(server->path); + } + server->path_bound = false; +} + static bool peer_has_same_uid(int fd) { struct ucred cred; socklen_t len = sizeof(cred); @@ -276,15 +332,16 @@ int amy_unix_socket_start(amy_unix_socket_server_t **out_server, if (rc < 0) goto fail; struct sockaddr_un addr; - memset(&addr, 0, sizeof(addr)); - addr.sun_family = AF_UNIX; - memcpy(addr.sun_path, path, path_len + 1u); + fill_socket_address(&addr, path); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { rc = -errno; goto fail; } + rc = remember_bound_socket(server); + if (rc < 0) goto fail; + // The Android app-data parent directory is already sandboxed. Mode 0600 // additionally makes filesystem pathname access same-UID only. if (chmod(path, S_IRUSR | S_IWUSR) < 0) { @@ -312,7 +369,7 @@ int amy_unix_socket_start(amy_unix_socket_server_t **out_server, fail: if (server->listen_fd >= 0) close(server->listen_fd); - if (server->path[0] != '\0') unlink(server->path); + remove_bound_socket(server); pthread_mutex_destroy(&server->client_lock); free(server); return rc; @@ -331,7 +388,7 @@ void amy_unix_socket_stop(amy_unix_socket_server_t *server) { server->listen_fd = -1; } - if (server->path[0] != '\0') unlink(server->path); + remove_bound_socket(server); pthread_mutex_destroy(&server->client_lock); free(server); } diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h index 245e3937..24a721c4 100644 --- a/src/amy_unix_socket.h +++ b/src/amy_unix_socket.h @@ -12,8 +12,8 @@ extern "C" { // Private pathname AF_UNIX transport for local AMY control. // -// Intended Android topology: -// Qt/Python process <-> amy.sock <-> native AMY/Oboe process +// Typical service topology: +// application process <-> amy.sock <-> native AMY/audio process // // The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET // packets into this fixed SPSC queue. The audio/control owner drains packets @@ -22,8 +22,9 @@ extern "C" { // // One connected client is supported at a time. On Linux/Android, accepted // peers must have the same effective UID as the server process. The pathname -// is created mode 0600 and a stale socket is removed only when it is owned by -// the same UID; an existing non-socket path is never removed. +// is created mode 0600. A stale socket is removed only when it is owned by the +// same UID and refuses a connection; a live listener, an existing non-socket +// path, and any pathname that replaces the running server's node are preserved. #define AMY_UNIX_SOCKET_QUEUE_CAPACITY 64u #define AMY_UNIX_SOCKET_MAX_PACKET ((size_t)MAX_MESSAGE_LEN - 1u) diff --git a/tests/run_amy_unix_socket_test.sh b/tests/run_amy_unix_socket_test.sh index c3b22a21..b5941d03 100644 --- a/tests/run_amy_unix_socket_test.sh +++ b/tests/run_amy_unix_socket_test.sh @@ -2,19 +2,24 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" -out="${TMPDIR:-/tmp}/test_amy_unix_socket" +out="$(mktemp "${TMPDIR:-/tmp}/amy-unix-socket-test.XXXXXX")" +trap 'rm -f "$out"' EXIT cc \ -std=c11 \ - -O2 \ + -O1 \ + -g \ -Wall \ -Wextra \ -Werror \ -pthread \ + -fsanitize=address,undefined \ + -fno-omit-frame-pointer \ -I"$repo_root/src" \ "$repo_root/src/amy_unix_socket.c" \ "$repo_root/tests/test_amy_unix_socket.c" \ -o "$out" -"$out" -rm -f "$out" +ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \ +UBSAN_OPTIONS=halt_on_error=1 \ + "$out" diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c index 8310cc32..8fe36540 100644 --- a/tests/test_amy_unix_socket.c +++ b/tests/test_amy_unix_socket.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,16 +14,28 @@ #include #include +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define WAIT_STEPS 2000 +#define WAIT_US 1000 + +typedef uint32_t (*counter_fn)(const amy_unix_socket_server_t *server); + +static void socket_address(struct sockaddr_un *addr, const char *path) { + memset(addr, 0, sizeof(*addr)); + addr->sun_family = AF_UNIX; + assert(strlen(path) < sizeof(addr->sun_path)); + strcpy(addr->sun_path, path); +} + static int connect_client(const char *path) { int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); assert(fd >= 0); struct sockaddr_un addr; - memset(&addr, 0, sizeof(addr)); - addr.sun_family = AF_UNIX; - assert(strlen(path) < sizeof(addr.sun_path)); - strcpy(addr.sun_path, path); - + socket_address(&addr, path); assert(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0); return fd; } @@ -30,35 +43,104 @@ static int connect_client(const char *path) { static int wait_receive(amy_unix_socket_server_t *server, char *buffer, size_t buffer_len) { - for (int i = 0; i < 1000; ++i) { + for (int i = 0; i < WAIT_STEPS; ++i) { int rc = amy_unix_socket_receive(server, buffer, buffer_len); if (rc != 0) return rc; - usleep(1000); + usleep(WAIT_US); } return -ETIMEDOUT; } static ssize_t wait_client_receive(int fd, void *buffer, size_t len) { - for (int i = 0; i < 1000; ++i) { + for (int i = 0; i < WAIT_STEPS; ++i) { ssize_t rc = recv(fd, buffer, len, MSG_DONTWAIT); if (rc >= 0) return rc; if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { return -1; } - usleep(1000); + usleep(WAIT_US); } errno = ETIMEDOUT; return -1; } -static void test_round_trip_and_permissions(void) { - char dir_template[] = "/tmp/amy-unix-socket-XXXXXX"; +static void wait_counter(counter_fn counter, + amy_unix_socket_server_t *server, + uint32_t expected) { + for (int i = 0; i < WAIT_STEPS; ++i) { + if (counter(server) >= expected) return; + usleep(WAIT_US); + } + assert(counter(server) >= expected); +} + +static void wait_until_disconnected(amy_unix_socket_server_t *server) { + const char probe[] = "x"; + for (int i = 0; i < WAIT_STEPS; ++i) { + int rc = amy_unix_socket_send(server, probe, sizeof(probe) - 1u); + if (rc == -ENOTCONN) return; + assert(rc == (int)(sizeof(probe) - 1u) || rc == -EPIPE || + rc == -ECONNRESET); + usleep(WAIT_US); + } + assert(amy_unix_socket_send(server, probe, sizeof(probe) - 1u) == + -ENOTCONN); +} + +static void make_temp_path(char *dir_template, + char *path, + size_t path_len) { char *dir = mkdtemp(dir_template); assert(dir != NULL); assert(chmod(dir, 0700) == 0); + int written = snprintf(path, path_len, "%s/amy.sock", dir); + assert(written > 0 && (size_t)written < path_len); +} + +static void remove_temp_dir(const char *path) { + char dir[256]; + size_t len = strlen(path); + assert(len < sizeof(dir)); + memcpy(dir, path, len + 1u); + char *slash = strrchr(dir, '/'); + assert(slash != NULL); + *slash = '\0'; + assert(rmdir(dir) == 0); +} + +static void send_packet(int fd, const void *data, size_t len) { + assert(send(fd, data, len, MSG_NOSIGNAL) == (ssize_t)len); +} + +static void test_invalid_arguments(void) { + char output[MAX_MESSAGE_LEN]; + amy_unix_socket_server_t *server = NULL; + + assert(amy_unix_socket_start(NULL, "/tmp/unused.sock") == -EINVAL); + assert(amy_unix_socket_start(&server, NULL) == -EINVAL); + assert(amy_unix_socket_start(&server, "") == -EINVAL); + char long_path[512]; + memset(long_path, 'x', sizeof(long_path)); + long_path[0] = '/'; + long_path[sizeof(long_path) - 1u] = '\0'; + assert(amy_unix_socket_start(&server, long_path) == -ENAMETOOLONG); + assert(server == NULL); + + assert(amy_unix_socket_receive(NULL, output, sizeof(output)) == -EINVAL); + assert(amy_unix_socket_receive(NULL, NULL, 0) == -EINVAL); + assert(amy_unix_socket_send(NULL, "x", 1) == -EINVAL); + assert(amy_unix_socket_send(NULL, NULL, 0) == -EINVAL); + assert(amy_unix_socket_queue_overruns(NULL) == 0); + assert(amy_unix_socket_oversize_packets(NULL) == 0); + assert(amy_unix_socket_rejected_peers(NULL) == 0); + amy_unix_socket_stop(NULL); +} + +static void test_round_trip_limits_and_permissions(void) { + char dir_template[] = "/tmp/amy-unix-roundtrip-XXXXXX"; char path[256]; - snprintf(path, sizeof(path), "%s/amy.sock", dir); + make_temp_path(dir_template, path, sizeof(path)); amy_unix_socket_server_t *server = NULL; assert(amy_unix_socket_start(&server, path) == 0); @@ -69,63 +151,58 @@ static void test_round_trip_and_permissions(void) { assert(S_ISSOCK(st.st_mode)); assert((st.st_mode & 0777) == 0600); assert(st.st_uid == geteuid()); + assert(amy_unix_socket_send(server, "x", 1) == -ENOTCONN); int client = connect_client(path); - const char command[] = "n60l1i2Z"; - assert(send(client, command, strlen(command), 0) == - (ssize_t)strlen(command)); + send_packet(client, command, strlen(command)); char received[MAX_MESSAGE_LEN]; int rc = wait_receive(server, received, sizeof(received)); assert(rc == (int)strlen(command)); assert(strcmp(received, command) == 0); - // Too-small destination must not consume the next queued packet. + // A too-small destination leaves the packet at the head of the queue. const char second[] = "K28i2Z"; - assert(send(client, second, strlen(second), 0) == - (ssize_t)strlen(second)); - for (int i = 0; i < 1000; ++i) { + send_packet(client, second, strlen(second)); + for (int i = 0; i < WAIT_STEPS; ++i) { rc = amy_unix_socket_receive(server, received, 4); if (rc != 0) break; - usleep(1000); + usleep(WAIT_US); } assert(rc == -EMSGSIZE); rc = amy_unix_socket_receive(server, received, sizeof(received)); assert(rc == (int)strlen(second)); assert(strcmp(received, second) == 0); - const char reply[] = "!iv1"; - for (int i = 0; i < 1000; ++i) { - rc = amy_unix_socket_send(server, reply, strlen(reply)); - if (rc != -ENOTCONN) break; - usleep(1000); - } - assert(rc == (int)strlen(reply)); + char maximum[MAX_MESSAGE_LEN]; + memset(maximum, 'm', AMY_UNIX_SOCKET_MAX_PACKET); + send_packet(client, maximum, AMY_UNIX_SOCKET_MAX_PACKET); + rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)AMY_UNIX_SOCKET_MAX_PACKET); + assert(memcmp(received, maximum, AMY_UNIX_SOCKET_MAX_PACKET) == 0); + assert(received[AMY_UNIX_SOCKET_MAX_PACKET] == '\0'); + + assert(amy_unix_socket_send(server, maximum, MAX_MESSAGE_LEN) == + -EMSGSIZE); + rc = amy_unix_socket_send(server, maximum, AMY_UNIX_SOCKET_MAX_PACKET); + assert(rc == (int)AMY_UNIX_SOCKET_MAX_PACKET); - char reply_buffer[32]; - ssize_t reply_len = wait_client_receive(client, - reply_buffer, - sizeof(reply_buffer)); - assert(reply_len == (ssize_t)strlen(reply)); - assert(memcmp(reply_buffer, reply, strlen(reply)) == 0); + char reply[MAX_MESSAGE_LEN]; + ssize_t reply_len = wait_client_receive(client, reply, sizeof(reply)); + assert(reply_len == (ssize_t)AMY_UNIX_SOCKET_MAX_PACKET); + assert(memcmp(reply, maximum, AMY_UNIX_SOCKET_MAX_PACKET) == 0); close(client); amy_unix_socket_stop(server); - - assert(lstat(path, &st) < 0); - assert(errno == ENOENT); - assert(rmdir(dir) == 0); + assert(lstat(path, &st) < 0 && errno == ENOENT); + remove_temp_dir(path); } static void test_oversize_packet_is_dropped(void) { char dir_template[] = "/tmp/amy-unix-oversize-XXXXXX"; - char *dir = mkdtemp(dir_template); - assert(dir != NULL); - assert(chmod(dir, 0700) == 0); - char path[256]; - snprintf(path, sizeof(path), "%s/amy.sock", dir); + make_temp_path(dir_template, path, sizeof(path)); amy_unix_socket_server_t *server = NULL; assert(amy_unix_socket_start(&server, path) == 0); @@ -133,12 +210,8 @@ static void test_oversize_packet_is_dropped(void) { char packet[MAX_MESSAGE_LEN]; memset(packet, 'x', sizeof(packet)); - assert(send(client, packet, sizeof(packet), 0) == (ssize_t)sizeof(packet)); - - for (int i = 0; i < 1000; ++i) { - if (amy_unix_socket_oversize_packets(server) != 0) break; - usleep(1000); - } + send_packet(client, packet, sizeof(packet)); + wait_counter(amy_unix_socket_oversize_packets, server, 1); assert(amy_unix_socket_oversize_packets(server) == 1); char received[MAX_MESSAGE_LEN]; @@ -146,17 +219,134 @@ static void test_oversize_packet_is_dropped(void) { close(client); amy_unix_socket_stop(server); - assert(rmdir(dir) == 0); + remove_temp_dir(path); } -static void test_existing_regular_file_is_never_removed(void) { +static void test_queue_is_bounded_and_ordered(void) { + char dir_template[] = "/tmp/amy-unix-queue-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + const uint32_t extra = 8; + for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY + extra; ++i) { + char packet[32]; + int len = snprintf(packet, sizeof(packet), "packet-%03u", i); + assert(len > 0 && (size_t)len < sizeof(packet)); + send_packet(client, packet, (size_t)len); + } + + wait_counter(amy_unix_socket_queue_overruns, server, extra); + assert(amy_unix_socket_queue_overruns(server) == extra); + + for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY; ++i) { + char expected[32]; + int expected_len = snprintf(expected, sizeof(expected), + "packet-%03u", i); + char received[MAX_MESSAGE_LEN]; + int rc = amy_unix_socket_receive(server, received, sizeof(received)); + assert(rc == expected_len); + assert(strcmp(received, expected) == 0); + } + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + close(client); + amy_unix_socket_stop(server); + remove_temp_dir(path); +} + +static void test_only_one_client_and_reconnect(void) { + char dir_template[] = "/tmp/amy-unix-clients-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int first = connect_client(path); + + char received[MAX_MESSAGE_LEN]; + send_packet(first, "first", 5); + assert(wait_receive(server, received, sizeof(received)) == 5); + assert(strcmp(received, "first") == 0); + + int rejected = connect_client(path); + wait_counter(amy_unix_socket_rejected_peers, server, 1); + assert(amy_unix_socket_rejected_peers(server) == 1); + + // Rejecting a second connection must not disturb the established client. + send_packet(first, "still-first", 11); + assert(wait_receive(server, received, sizeof(received)) == 11); + assert(strcmp(received, "still-first") == 0); + close(rejected); + + close(first); + wait_until_disconnected(server); + + int second = connect_client(path); + send_packet(second, "second", 6); + assert(wait_receive(server, received, sizeof(received)) == 6); + assert(strcmp(received, "second") == 0); + + close(second); + amy_unix_socket_stop(server); + remove_temp_dir(path); +} + +static void test_live_socket_is_not_stolen(void) { + char dir_template[] = "/tmp/amy-unix-live-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *first_server = NULL; + assert(amy_unix_socket_start(&first_server, path) == 0); + int client = connect_client(path); + send_packet(client, "before", 6); + + char received[MAX_MESSAGE_LEN]; + assert(wait_receive(first_server, received, sizeof(received)) == 6); + + amy_unix_socket_server_t *second_server = NULL; + assert(amy_unix_socket_start(&second_server, path) == -EADDRINUSE); + assert(second_server == NULL); + + send_packet(client, "after", 5); + assert(wait_receive(first_server, received, sizeof(received)) == 5); + assert(strcmp(received, "after") == 0); + + close(client); + amy_unix_socket_stop(first_server); + remove_temp_dir(path); +} + +static void test_owned_stale_socket_is_replaced(void) { char dir_template[] = "/tmp/amy-unix-stale-XXXXXX"; - char *dir = mkdtemp(dir_template); - assert(dir != NULL); - assert(chmod(dir, 0700) == 0); + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + int stale = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(stale >= 0); + struct sockaddr_un addr; + socket_address(&addr, path); + assert(bind(stale, (struct sockaddr *)&addr, sizeof(addr)) == 0); + close(stale); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + amy_unix_socket_stop(server); + + struct stat st; + assert(lstat(path, &st) < 0 && errno == ENOENT); + remove_temp_dir(path); +} + +static void test_existing_regular_file_is_never_removed(void) { + char dir_template[] = "/tmp/amy-unix-file-XXXXXX"; char path[256]; - snprintf(path, sizeof(path), "%s/amy.sock", dir); + make_temp_path(dir_template, path, sizeof(path)); int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); assert(fd >= 0); @@ -169,15 +359,42 @@ static void test_existing_regular_file_is_never_removed(void) { struct stat st; assert(lstat(path, &st) == 0); assert(S_ISREG(st.st_mode)); + assert(unlink(path) == 0); + remove_temp_dir(path); +} +static void test_stop_preserves_replacement_path(void) { + char dir_template[] = "/tmp/amy-unix-replaced-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); assert(unlink(path) == 0); - assert(rmdir(dir) == 0); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_stop(server); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + assert(unlink(path) == 0); + remove_temp_dir(path); } int main(void) { - test_round_trip_and_permissions(); + test_invalid_arguments(); + test_round_trip_limits_and_permissions(); test_oversize_packet_is_dropped(); + test_queue_is_bounded_and_ordered(); + test_only_one_client_and_reconnect(); + test_live_socket_is_not_stolen(); + test_owned_stale_socket_is_replaced(); test_existing_regular_file_is_never_removed(); + test_stop_preserves_replacement_path(); puts("amy unix socket tests passed"); return 0; } From 237ff60b8c4404379d8fa0d977ba239e60619ce7 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 11:24:23 +0200 Subject: [PATCH 008/112] Expose Godot backend lifecycle signals --- .github/workflows/c-cpp.yml | 3 ++ godot/amy.gd | 15 +++++++- tests/test_godot_backend_signals.py | 59 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/test_godot_backend_signals.py diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..1e707fb1 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -87,6 +87,9 @@ jobs: - name: Validate GDScript parses run: gdparse godot/amy.gd + - name: Test Godot backend lifecycle signals + run: python tests/test_godot_backend_signals.py + - name: Check godot/amy.gd is in sync with amy/__init__.py run: | if ! git diff --quiet -- godot/amy.gd; then diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..98574ad8 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -14,6 +14,11 @@ extends Node ## Or use wire protocol directly: ## amy.send_raw("v0w0f440l1") +## Emitted after the selected native or web backend is ready for messages. +signal backend_ready +## Emitted when the selected backend cannot initialize. +signal backend_error(message: String) + # ============================================================ # Wave types # ============================================================ @@ -103,7 +108,9 @@ func _init_native() -> void: _synth = ClassDB.instantiate(&"AmySynth") add_child(_synth) else: - push_warning("AmySynth GDExtension not loaded — audio disabled") + var message := "AmySynth GDExtension not loaded — audio disabled" + push_warning(message) + backend_error.emit(message) return # Apply config before starting @@ -132,6 +139,7 @@ func _init_native() -> void: _stream_player.play() _playback = _stream_player.get_stream_playback() as AudioStreamGeneratorPlayback _started = true + backend_ready.emit() func _init_web() -> void: # Pass config to JS bridge before AMY starts @@ -144,9 +152,12 @@ func _init_web() -> void: if ready: _started = true print("AMY web synth ready") + backend_ready.emit() return await get_tree().create_timer(0.1).timeout - push_warning("AMY web module failed to load after 10 s") + var message := "AMY web module failed to load after 10 s" + push_warning(message) + backend_error.emit(message) func _process(_delta: float) -> void: if _started and not _is_web: diff --git a/tests/test_godot_backend_signals.py b/tests/test_godot_backend_signals.py new file mode 100644 index 00000000..1b2b5489 --- /dev/null +++ b/tests/test_godot_backend_signals.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Static regression for Amy.gd's platform-independent backend lifecycle.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +AMY_GD = ROOT / "godot" / "amy.gd" + + +def function_body(source: str, name: str) -> str: + lines = source.splitlines() + signature = f"func {name}(" + start = next( + index for index, line in enumerate(lines) if line.startswith(signature) + ) + body: list[str] = [] + for line in lines[start + 1 :]: + if line and not line.startswith(("\t", " ")): + break + body.append(line) + return "\n".join(body) + + +class GodotBackendSignalContract(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = AMY_GD.read_text(encoding="utf-8") + + def test_public_signal_signatures_are_stable(self) -> None: + self.assertEqual(self.source.count("signal backend_ready\n"), 1) + self.assertEqual( + self.source.count("signal backend_error(message: String)\n"), 1 + ) + + def test_native_backend_reports_success_and_failure(self) -> None: + body = function_body(self.source, "_init_native") + self.assertIn('var message := "AmySynth GDExtension not loaded', body) + self.assertIn("backend_error.emit(message)", body) + self.assertIn("_started = true", body) + self.assertIn("backend_ready.emit()", body) + self.assertLess( + body.index("_started = true"), body.index("backend_ready.emit()") + ) + + def test_web_backend_reports_success_and_timeout(self) -> None: + body = function_body(self.source, "_init_web") + self.assertIn("_started = true", body) + self.assertIn("backend_ready.emit()", body) + self.assertIn('var message := "AMY web module failed to load', body) + self.assertIn("backend_error.emit(message)", body) + self.assertLess( + body.index("_started = true"), body.index("backend_ready.emit()") + ) + + +if __name__ == "__main__": + unittest.main() From d90917c2aa165a01a2dbfd9ef6482692d4d5b546 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 11:24:31 +0200 Subject: [PATCH 009/112] Document portable service integrations --- README.md | 2 + docs/godot.md | 29 ++++++++- docs/porting.md | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 docs/porting.md diff --git a/README.md b/README.md index 77d9e83e..95b9eb74 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) + * [**Porting AMY and local-service transports**](docs/porting.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) AMY supports @@ -175,6 +176,7 @@ It's good to understand what wire messages are but you don't need to construct t * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) * [**AMY in Godot**](docs/godot.md) + * [**Porting AMY and local-service transports**](docs/porting.md) * [**AMY on Windows**](windows/README.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) diff --git a/docs/godot.md b/docs/godot.md index 3918c4f7..76949876 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -52,8 +52,12 @@ var amy: Amy func _ready(): amy = Amy.new() + amy.backend_ready.connect(_on_amy_ready) + amy.backend_error.connect(_on_amy_error) add_child(amy) - await get_tree().process_frame # let AMY initialize + +func _on_amy_ready(): + # The selected backend can now accept messages. # Play a 440 Hz sine wave amy.send({"osc": 0, "wave": Amy.SINE, "freq": 440, "vel": 1.0}) @@ -69,8 +73,16 @@ func _ready(): # Or use wire protocol directly amy.send_raw("v3w0f880l0.5") + +func _on_amy_error(message: String): + push_error(message) ``` +Connect the signals before `add_child(amy)`: the native backend can become +ready synchronously during `_ready()`. `backend_ready` is emitted once the +native or web backend accepts messages. `backend_error(message)` reports a +missing native extension or a web-backend startup timeout. + ### 4. Configure AMY (optional) Set [config properties](api.md) on the `Amy` node **before** adding it to the tree: @@ -136,6 +148,16 @@ Or run locally: `python3 -m http.server` from your `dist` folder and go to `loc - **Web:** AMY runs as its own WASM module with Web Audio API AudioWorklets. The `Amy` GDScript class detects `OS.get_name() == "Web"` and sends wire messages via `JavaScriptBridge` instead of the native extension. +### Android reference implementation + +Android is not built or maintained in this repository. A complete external +[Godot Android service integration](https://github.com/linuxificator/amy/tree/upstream/godot-android) +demonstrates the same `Amy` Dictionary-to-wire API with AMY running in a +separate Oboe service process. The lower-level +[Android Oboe reference](https://github.com/linuxificator/amy/tree/upstream/android-oboe) +contains the service and private Unix-socket transport. See +[porting notes](porting.md) for the reusable boundary and verified build flags. + ## API Reference @@ -180,6 +202,11 @@ Send a raw AMY wire-protocol message (e.g. `"v0w0f440l1"`). Stop all sound immediately. +### Signals + +- `backend_ready`: the selected backend is ready to accept AMY messages. +- `backend_error(message)`: backend initialization failed. + ### Constants **Wave types:** `Amy.SINE`, `Amy.PULSE`, `Amy.SAW_DOWN`, `Amy.SAW_UP`, `Amy.TRIANGLE`, `Amy.NOISE`, `Amy.KS`, `Amy.PCM`, `Amy.ALGO`, `Amy.PARTIAL`, `Amy.WAVETABLE`, `Amy.CUSTOM`, `Amy.WAVE_OFF` diff --git a/docs/porting.md b/docs/porting.md new file mode 100644 index 00000000..f1a9a004 --- /dev/null +++ b/docs/porting.md @@ -0,0 +1,170 @@ +# Porting AMY and local-service transports + +AMY's C engine can run inside an application or in a separate local process. +The second form is useful when a framework or language should remain a +wire-protocol client and a small native service should own AMY and the audio +device. + +This page records portable pieces and verified porting results. The complete +Android, Godot Android, and Windows applications linked below are external +reference implementations; their platform-specific build trees are not part +of the core AMY repository. + +## Embedding boundary + +A native host normally: + +1. creates an `amy_config_t` with `amy_default_config()`; +2. selects the host's audio and MIDI ownership before calling `amy_start()`; +3. delivers complete AMY wire messages through `amy_add_message()` at a safe + control or render boundary; +4. obtains audio with `amy_simple_fill_buffer()` when the host owns rendering; +5. calls `amy_stop()` during shutdown. + +Keep blocking IPC away from the realtime audio callback. If a receiver thread +accepts commands, move them through a bounded queue and let the AMY/audio owner +drain that queue between render blocks. + +## Linux/Android packet transport + +`src/amy_unix_socket.[ch]` implements a local pathname `AF_UNIX` / +`SOCK_SEQPACKET` server for Linux and Android. It is transport-only: its thread +does not call AMY. + +The server provides: + +- one logical request per packet, up to `MAX_MESSAGE_LEN - 1` bytes; +- a fixed 64-packet single-producer/single-consumer queue; +- one connected client at a time; +- pathname mode `0600` and same-effective-UID peer checks with `SO_PEERCRED`; +- refusal to replace a live listener or remove a non-socket/reused pathname; +- non-blocking dequeue and reply calls; +- counters for queue overruns, oversized packets, and rejected clients. + +The render owner can drain commands immediately before a new AMY block: + +```c +char message[MAX_MESSAGE_LEN]; +for (;;) { + int length = amy_unix_socket_receive(server, message, sizeof(message)); + if (length <= 0) break; + amy_add_message(message); +} +``` + +`amy_unix_socket_send()` supports replies from a non-realtime control/status +path. It must not be called from the audio callback. + +Run the AddressSanitizer/UndefinedBehaviorSanitizer host regression on Linux: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +The test covers maximum and oversized packets, non-consuming `EMSGSIZE`, queue +ordering/overrun behavior, connection replacement/rejection, reconnects, +permissions, active/stale paths, and safe shutdown cleanup. + +On unsupported platforms these functions return `-ENOTSUP`. A stream or +platform-native IPC adapter can preserve the same higher-level rule: one +complete AMY wire request is delivered to the AMY owner at a safe boundary. + +## Verified Android NDK recipe + +The external [Android Oboe service reference][android-oboe] demonstrates that +the AMY core compiles for Android NDK without changes to its synthesis sources. +That build uses: + +```text +AMY_DAISY=1 +AMY_HOST_MIDI=1 +AMY_NO_MINIAUDIO=1 +AMY_WAVETABLE=1 +``` + +`AMY_DAISY` selects AMY's existing 48 kHz / 128-frame profile. +`AMY_NO_MINIAUDIO` lets Oboe own audio, and `AMY_HOST_MIDI` lets the service +supply the MIDI lifecycle hooks. The Oboe callback calls +`amy_simple_fill_buffer()` only when it needs another AMY block and drains the +socket queue before that block. + +One declaration-only compatibility header is force-included in the C +translation units so `pcm.c` sees allocators already supplied by `delay.c` +under `AMY_DAISY`: + +```c +#include + +void *qspi_malloc(size_t size); +void qspi_free(void *ptr); +``` + +Do not link a second allocator implementation. + +The Android audio-level regression also caught an important gain detail: +AMY's `V` bus/master control is a `0..10` scale, and final mixdown multiplies +it by `0.1`. Therefore `V2.0` is 20% linear gain, while `V10.0` is full master +gain. This differs from oscillator velocity/amplitude and per-synth `iV`. + +The reference branch includes the Gradle AAR, private `:amy` service, Oboe +adapter, transport-only Java client, emulator tests, and captured AMY-to-Oboe +audio comparison. Those framework-specific files remain outside the core AMY +tree. + +## Godot lifecycle and Android reference + +The shared `godot/amy.gd` wrapper exposes two platform-independent signals: + +- `backend_ready`, emitted after the selected native or web backend can accept + messages; +- `backend_error(message)`, emitted if backend initialization fails. + +Connect them before adding the `Amy` node to the scene tree, because a native +backend may become ready synchronously: + +```gdscript +var amy := Amy.new() +amy.backend_ready.connect(func(): amy.send({"osc": 0, "note": 60, "vel": 1})) +amy.backend_error.connect(func(message: String): push_error(message)) +add_child(amy) +``` + +The external [Godot Android reference][godot-android] uses the same signals and +Dictionary-to-wire encoder while keeping AMY in the separate Android service. +It contains the AAR packaging example and Android emulator validation. The +Android backend itself is not part of the core Godot addon. + +## Verified Windows named-pipe adapter + +Windows local IPC did not require changes to AMY. The native +[LB Omnichord Windows service][windows-service] compiles the normal AMY C +sources and implements the transport entirely in its host wrapper: + +- `CreateNamedPipeA()` creates one private byte-mode pipe instance with + `PIPE_REJECT_REMOTE_CLIENTS`; +- the [launcher][windows-launcher] supplies a unique per-run pipe name and + publishes readiness only after the pipe and AMY exist; +- the [Qt client][windows-client] uses `QLocalSocket` and writes LF-framed + records because a Windows named pipe is a byte stream rather than a + `SOCK_SEQPACKET` endpoint; +- the service buffers partial/multiple `ReadFile()` results, requires each + completed request to end in `Z`, then calls `amy_add_message()`; +- AMY remains in a separate native service process and owns miniaudio output. + +The [Windows build target][windows-cmake], [packaging regression][windows-test], +and [Windows Server 2025 release test][windows-ci] compile the service, run an +offline `amy_simple_fill_buffer()` self-test, and exercise the packaged +Qt-to-pipe-to-AMY boundary. These hosted tests prove compilation, command +delivery, non-silent offline rendering, and process cleanup; they do not prove +physical audio, MIDI, latency, or dropout behavior. See the [full Windows +design and validation notes][windows-doc] for those limits. + +[android-oboe]: https://github.com/linuxificator/amy/tree/upstream/android-oboe +[godot-android]: https://github.com/linuxificator/amy/tree/upstream/godot-android +[windows-service]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/amy_service.c +[windows-launcher]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/run_windows.ps1 +[windows-client]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/code/amy_transport.py +[windows-cmake]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/CMakeLists.txt +[windows-test]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/tests/test_packaging.py +[windows-ci]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/.github/workflows/desktop-release.yml +[windows-doc]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/docs/WINDOWS_NATIVE.md From 8738ae2dd5d351323f3b17c5e8e914e13262d7ce Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 11:55:36 +0200 Subject: [PATCH 010/112] Define the Android integration service profile --- .github/workflows/android.yml | 10 ++++ android/README.md | 8 +++- .../amy-service/src/main/cpp/amy_android.cpp | 15 +++++- tests/test_android_service_contract.py | 46 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/test_android_service_contract.py diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 9c78dedc..6a4316aa 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,6 +1,9 @@ name: Android AMY on: + push: + branches: + - integration/amy_android pull_request: paths: - "android/**" @@ -8,7 +11,9 @@ on: - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" - "tests/check_android_audio_capture.py" + - "tests/test_android_service_contract.py" - ".github/workflows/android.yml" + workflow_dispatch: permissions: contents: read @@ -18,6 +23,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - name: Verify Android service contract + run: python3 tests/test_android_service_contract.py - name: Build and run private Unix socket test run: bash tests/run_amy_unix_socket_test.sh @@ -26,6 +33,9 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Verify Android service contract + run: python3 tests/test_android_service_contract.py + - uses: actions/setup-java@v4 with: distribution: temurin diff --git a/android/README.md b/android/README.md index 17c14f54..907f3c2a 100644 --- a/android/README.md +++ b/android/README.md @@ -67,7 +67,9 @@ calls AMY and never participates in audio rendering. AMY is started with its internal platform audio disabled and with AMY rendering owned by the Oboe callback thread. The current Android build configuration -reserves 16 Karplus-Strong oscillators. +reserves 336 addressable oscillators, 11 runtime buses, and 16 Karplus-Strong +oscillators. These are service-host capacities, not wire-protocol extensions: +clients continue to send ordinary AMY messages and may use any smaller layout. ## JNI boundary @@ -161,7 +163,9 @@ bash tests/run_amy_unix_socket_test.sh It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, oversized-packet rejection, cleanup, and protection against deleting an -existing non-socket path. +existing non-socket path. `tests/test_android_service_contract.py` additionally +guards the AAR's private-process manifest, socket-only client boundary, and the +336-oscillator/11-bus integration profile without requiring an Android SDK. `.github/workflows/android.yml` runs that regression plus a complete Android AAR/NDK/Oboe build and emulator end-to-end test. The emulator arms its own diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 250538b8..1648098f 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -53,6 +53,8 @@ namespace { constexpr int kMaxCommandsPerBlock = 64; constexpr int kAudioReadyTimeoutMs = 2000; constexpr int kAudioReadyPollMs = 2; +constexpr uint16_t kIntegrationMaxOscillators = 336; +constexpr uint16_t kIntegrationMaxBuses = 11; class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback { @@ -66,6 +68,13 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, config.features.audio_in = 0; config.features.default_synths = 0; config.features.startup_bleep = 0; + /* + * The integration AAR must accommodate clients with large, explicitly + * addressed oscillator and bus layouts. Keep this runtime profile in + * sync with the documented Android service contract. + */ + config.max_oscs = kIntegrationMaxOscillators; + config.max_buses = kIntegrationMaxBuses; /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ config.platform.multicore = 0; config.platform.multithread = 0; @@ -178,8 +187,10 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, } mSocket.store(socket, std::memory_order_release); - LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, socket=%s", - AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, socketPath); + LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, %u oscs, %u buses, socket=%s", + AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, + static_cast(config.max_oscs), + static_cast(config.max_buses), socketPath); return 0; } diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py new file mode 100644 index 00000000..90b8c43f --- /dev/null +++ b/tests/test_android_service_contract.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Source-level guard for the Android AAR's public integration contract.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] + + +def require(pattern: str, text: str, label: str) -> None: + if re.search(pattern, text, flags=re.MULTILINE) is None: + raise AssertionError(f"Android service contract is missing {label}") + + +def main() -> None: + engine = (ROOT / "android/amy-service/src/main/cpp/amy_android.cpp").read_text() + manifest = (ROOT / "android/amy-service/src/main/AndroidManifest.xml").read_text() + hello = (ROOT / "android/hello-world/src/main/java/org/amy/hello/MainActivity.java").read_text() + + require(r"kIntegrationMaxOscillators\s*=\s*336\s*;", engine, + "the 336-oscillator host capacity") + require(r"kIntegrationMaxBuses\s*=\s*11\s*;", engine, + "the 11-bus host capacity") + require(r"config\.max_oscs\s*=\s*kIntegrationMaxOscillators\s*;", engine, + "runtime oscillator configuration") + require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, + "runtime bus configuration") + require(r"android:process=\":amy\"", manifest, "the separate :amy process") + require(r"android:exported=\"false\"", manifest, "a private Android component") + require(r"\$\{applicationId\}\.amy-autostart", manifest, + "an application-scoped provider authority") + + forbidden_client_symbols = ("AmyService", "System.loadLibrary", "native ") + for symbol in forbidden_client_symbols: + if symbol in hello: + raise AssertionError( + f"transport-only hello-world unexpectedly contains {symbol!r}" + ) + + print("Android service contract OK: private :amy process, socket-only client, " + "336 oscillators, 11 buses") + + +if __name__ == "__main__": + main() From 20f714a6ed309077a2a4fcca1f998e552cc7510a Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 12:01:26 +0200 Subject: [PATCH 011/112] Align Android service with PySide NDK r27c --- .github/workflows/android.yml | 2 +- android/README.md | 2 +- android/amy-service/build.gradle.kts | 2 +- tests/test_android_service_contract.py | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6a4316aa..2e24fd93 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -49,7 +49,7 @@ jobs: sdkmanager \ "platforms;android-36" \ "build-tools;35.0.0" \ - "ndk;27.0.12077973" \ + "ndk;27.2.12479018" \ "cmake;3.22.1" - uses: gradle/actions/setup-gradle@v4 diff --git a/android/README.md b/android/README.md index 907f3c2a..97ff937e 100644 --- a/android/README.md +++ b/android/README.md @@ -134,7 +134,7 @@ Requirements used by CI: - JDK 17 - Android SDK platform 36 -- Android NDK 27.0.12077973 +- Android NDK 27.2.12479018 (r27c) - CMake 3.22.1 - Gradle 8.13 - Android Gradle Plugin 8.13.2 diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts index c0c0e37e..f95cd743 100644 --- a/android/amy-service/build.gradle.kts +++ b/android/amy-service/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "org.amy.audio" compileSdk = 36 - ndkVersion = "27.0.12077973" + ndkVersion = "27.2.12479018" defaultConfig { minSdk = 26 diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 90b8c43f..7756606b 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -15,6 +15,7 @@ def require(pattern: str, text: str, label: str) -> None: def main() -> None: engine = (ROOT / "android/amy-service/src/main/cpp/amy_android.cpp").read_text() + gradle = (ROOT / "android/amy-service/build.gradle.kts").read_text() manifest = (ROOT / "android/amy-service/src/main/AndroidManifest.xml").read_text() hello = (ROOT / "android/hello-world/src/main/java/org/amy/hello/MainActivity.java").read_text() @@ -26,6 +27,8 @@ def main() -> None: "runtime oscillator configuration") require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, "runtime bus configuration") + require(r'ndkVersion\s*=\s*"27\.2\.12479018"', gradle, + "the PySide-compatible Android NDK r27c") require(r"android:process=\":amy\"", manifest, "the separate :amy process") require(r"android:exported=\"false\"", manifest, "a private Android component") require(r"\$\{applicationId\}\.amy-autostart", manifest, From 8b7cd100e168b0dbe673b99ef297f8b7fc6265ee Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 15:47:58 +0200 Subject: [PATCH 012/112] Extend Android framework audio capture --- android/README.md | 5 +++++ android/amy-service/src/main/cpp/amy_android_capture.cpp | 5 ++++- tests/test_android_service_contract.py | 7 ++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/android/README.md b/android/README.md index 97ff937e..e1ef05be 100644 --- a/android/README.md +++ b/android/README.md @@ -48,6 +48,11 @@ intentional and preserves the private-socket security model. See The Android native build uses AMY's existing 48 kHz / 128-frame build profile and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. +The marker-gated CI capture records eight seconds from both AMY's rendered +samples and the exact buffer handed to Oboe. This leaves a packaged framework +runtime enough startup time before UI-driven notes while remaining a one-shot, +test-only path; ordinary applications never allocate the capture buffers. + Oboe requests: - stereo signed 16-bit output diff --git a/android/amy-service/src/main/cpp/amy_android_capture.cpp b/android/amy-service/src/main/cpp/amy_android_capture.cpp index 597b75f5..7a94dce3 100644 --- a/android/amy-service/src/main/cpp/amy_android_capture.cpp +++ b/android/amy-service/src/main/cpp/amy_android_capture.cpp @@ -15,7 +15,10 @@ namespace { -constexpr int32_t kCaptureSeconds = 4; +// Leave enough room for a packaged framework client to extract/start its +// runtime and still exercise real UI-driven notes. Four seconds was enough +// for the Java hello-world, but could end during a Qt/Python synth attack. +constexpr int32_t kCaptureSeconds = 8; constexpr const char *kEnableMarker = "amy-audio-capture.enable"; constexpr const char *kAmyWave = "amy-render.wav"; constexpr const char *kOboeWave = "amy-oboe.wav"; diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 7756606b..b4a71d8d 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -15,6 +15,9 @@ def require(pattern: str, text: str, label: str) -> None: def main() -> None: engine = (ROOT / "android/amy-service/src/main/cpp/amy_android.cpp").read_text() + capture = ( + ROOT / "android/amy-service/src/main/cpp/amy_android_capture.cpp" + ).read_text() gradle = (ROOT / "android/amy-service/build.gradle.kts").read_text() manifest = (ROOT / "android/amy-service/src/main/AndroidManifest.xml").read_text() hello = (ROOT / "android/hello-world/src/main/java/org/amy/hello/MainActivity.java").read_text() @@ -27,6 +30,8 @@ def main() -> None: "runtime oscillator configuration") require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, "runtime bus configuration") + require(r"kCaptureSeconds\s*=\s*8\s*;", capture, + "the framework-safe eight-second audio capture window") require(r'ndkVersion\s*=\s*"27\.2\.12479018"', gradle, "the PySide-compatible Android NDK r27c") require(r"android:process=\":amy\"", manifest, "the separate :amy process") @@ -42,7 +47,7 @@ def main() -> None: ) print("Android service contract OK: private :amy process, socket-only client, " - "336 oscillators, 11 buses") + "336 oscillators, 11 buses, 8-second test capture") if __name__ == "__main__": From 74bed0483367dd4bf374f1c9b0abf5f45f997f76 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:04:08 +0200 Subject: [PATCH 013/112] Add reusable sequencer groups --- Makefile | 1 + amy/__init__.py | 1 + amy/constants.py | 1 + src/amy.c | 6 +- src/amy.h | 7 +- src/amy_api.generated.js | 44 ++-- src/api.c | 6 +- src/parse.c | 32 ++- src/patches.c | 6 +- src/pyamy.c | 27 +++ src/sequencer.c | 457 ++++++++++++++++++++++++++++++++++- src/sequencer.h | 20 +- tests/test_sequence_groups.c | 198 +++++++++++++++ 13 files changed, 770 insertions(+), 36 deletions(-) create mode 100644 tests/test_sequence_groups.c diff --git a/Makefile b/Makefile index f849e7dc..2e465c0f 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,7 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ + tests/test_sequence_groups \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..7cb08362 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -254,6 +254,7 @@ def str_of_int(arg): ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), + ('sequence_control', 'zQL'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), ('synth_level', 'iVF'), diff --git a/amy/constants.py b/amy/constants.py index ef33569a..ecf4bb28 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,6 +124,7 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +TICKS_GROUP=3 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/src/amy.c b/src/amy.c index 6919023c..d20187da 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1298,7 +1298,10 @@ int8_t oscs_init() { algo_init(); patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); - sequencer_init(amy_global.config.max_sequencer_tags); + sequencer_init(amy_global.config.max_sequencer_tags, + amy_global.config.max_sequence_groups, + amy_global.config.max_sequence_group_tags, + amy_global.config.max_sequence_group_executions); if(pcm_samples) pcm_init(); if(AMY_HAS_CUSTOM) custom_init(); // synth and msynth are now pointers to arrays of pointers to dynamically-allocated synth structures. @@ -2476,6 +2479,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_group_reset_timebase(); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; diff --git a/src/amy.h b/src/amy.h index 3022cade..11deb495 100644 --- a/src/amy.h +++ b/src/amy.h @@ -363,6 +363,7 @@ enum coefs{ #define TICKS_TICK 0 #define TICKS_PERIOD 1 #define TICKS_TAG 2 +#define TICKS_GROUP 3 // Reset masks #define RESET_SEQUENCER 4096 @@ -667,7 +668,7 @@ typedef struct amy_event { uint16_t num_voices; uint8_t oscs_per_voice; // Used when initializing a synth without a patch. // - uint32_t ticks[3]; // tick, period, tag + uint32_t ticks[4]; // tick, period, tag, optional group tag // uint8_t note_source_channel; // .. to mark the channel of events that come from MIDI so we don't send them back out again. uint32_t reset_osc; @@ -887,6 +888,10 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; + // Group tag zero is reserved for the existing root sequencer. + uint32_t max_sequence_groups; + uint32_t max_sequence_group_tags; + uint32_t max_sequence_group_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..4f92c050 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -55,6 +55,7 @@ var AMY_KW_MAP = { portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, sequencer_run: {wire: "zY", type: "I"}, + sequence_control: {wire: "zQ", type: "L"}, external_midi_sync: {wire: "zC", type: "I"}, synth: {wire: "i", type: "I"}, pedal: {wire: "ip", type: "I"}, @@ -130,27 +131,28 @@ var AMY_KW_PRIORITY = { portamento: 48, tempo: 49, sequencer_run: 50, - external_midi_sync: 51, - synth: 52, - pedal: 53, - synth_flags: 54, - num_voices: 55, - oscs_per_voice: 56, - synth_level: 57, - to_synth: 58, - grab_midi_notes: 59, - note_source_channel: 60, - synth_delay: 61, - preset: 62, - num_partials: 63, - start_sample: 64, - stop_sample: 65, - bus: 66, - mode: 67, - midi_cc: 68, - midi_note_cmd: 69, - cv_trigger: 70, - patch_string: 71 + sequence_control: 51, + external_midi_sync: 52, + synth: 53, + pedal: 54, + synth_flags: 55, + num_voices: 56, + oscs_per_voice: 57, + synth_level: 58, + to_synth: 59, + grab_midi_notes: 60, + note_source_channel: 61, + synth_delay: 62, + preset: 63, + num_partials: 64, + start_sample: 65, + stop_sample: 66, + bus: 67, + mode: 68, + midi_cc: 69, + midi_note_cmd: 70, + cv_trigger: 71, + patch_string: 72 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; diff --git a/src/api.c b/src/api.c index fd70fbef..8b0371cd 100644 --- a/src/api.c +++ b/src/api.c @@ -48,6 +48,9 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; + c.max_sequence_groups = 32; + c.max_sequence_group_tags = 32; + c.max_sequence_group_executions = 16; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -187,6 +190,7 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->ticks[TICKS_TICK]); AMY_UNSET(e->ticks[TICKS_PERIOD]); AMY_UNSET(e->ticks[TICKS_TAG]); + AMY_UNSET(e->ticks[TICKS_GROUP]); AMY_UNSET(e->eq_l); AMY_UNSET(e->eq_m); AMY_UNSET(e->eq_h); @@ -320,7 +324,7 @@ void amy_send_wire_from_sysex(char *message) { void amy_add_event(amy_event *e) { peek_stack("add_event"); // was amy_process_event - if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG])) { + if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG]) || AMY_IS_SET(e->ticks[TICKS_GROUP])) { // C-API ticks event: serialize it to a wire message and hand it to // the sequencer, so scheduled events have a single storage format. char *buf = (char *)malloc_caps(MAX_MESSAGE_LEN, amy_global.config.ram_caps_events); diff --git a/src/parse.c b/src/parse.c index 436a4549..25c0cef5 100644 --- a/src/parse.c +++ b/src/parse.c @@ -659,6 +659,18 @@ uint16_t amy_parse_transfer_layer_message(char *message) { return total; } } + else if (cmd == 'Q') { + // zQgroup,action,value,quantize[,execution_tag] + uint32_t values[5] = {0, 0, 0, 0, 0}; + int count = parse_list_uint32_t(message, values, 5, 0); + if (count < 2) { + fprintf(stderr, "sequence_control needs at least group and action\n"); + } else { + sequencer_group_control(values[0], values[1], values[2], values[3], + values[4], count >= 5); + } + return 1; + } else if (cmd == 'Y') { // zY: sequencer transport. zY1 starts the sequencer, zY0 stops it. Lets a // host drive playback without MIDI clock sync (see external_midi_sync). @@ -710,8 +722,8 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { // is only ever honored as the first command of a message. void handle_ticks_message(char *message) { assert(message[0] == 'H'); - uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); + uint32_t ticks[4] = {0, 0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, ticks, 4, 0); uint16_t schedule_len = 1 + _next_alpha(message + 1); char *payload = message + schedule_len; uint16_t payload_len = (uint16_t)strlen(payload); @@ -720,10 +732,17 @@ void handle_ticks_message(char *message) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - // A tag is only "given" if all 3 values were present; fewer - // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); + if (num_vals >= 4 && ticks[TICKS_GROUP] != 0) { + // The fourth ticks value selects persistent group-local storage. + // Group zero deliberately follows the legacy root path below. + sequencer_group_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], + ticks[TICKS_TAG], ticks[TICKS_GROUP], stripped); + } else { + // A root tag is only "given" if all 3 values were present; fewer + // than that (a 1- or 2-value ticks=) stores anonymously. + sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped); + } } } @@ -906,4 +925,3 @@ int amy_parse_message(char * message, amy_event *e) { // Return exactly how many characters we used. return pos; } - diff --git a/src/patches.c b/src/patches.c index 63c07b5d..9aa7e50a 100644 --- a/src/patches.c +++ b/src/patches.c @@ -330,12 +330,12 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { snprintf(s, len - (size_t)(s - s_entry), "amy_event(time=%" PRIu32 ", osc=%u, addr_osc=%d adr_syn=%d adr_bus=%d): ", e->time, (unsigned)e->osc, event_addresses_oscs(e), event_addresses_synth(e), event_addresses_bus(e)); s += strlen(s); - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group } else { // e->time has no wire representation anymore (there's no 't' command); // it's only ever meaningful as this event's own near-term playback time. // ticks ("H") must always be the first entry in wire code if used. - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group _EPRINT_I(osc, "osc", "v"); } _EPRINT_I(wave, "wave", "w"); @@ -540,7 +540,7 @@ bool event_addresses_oscs(amy_event *e) { _RET_TRUE_IF_SET(eg_type[0]); _RET_TRUE_IF_SET(eg_type[1]); // We don't know - _RET_TRUE_IF_SET_SEQ(ticks, 3); // tick, period, tag + _RET_TRUE_IF_SET_SEQ(ticks, 4); // tick, period, tag, optional group // //_RET_TRUE_IF_SET(status, "status"); _RET_TRUE_IF_SET(reset_osc); diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..cee64e20 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,6 +97,33 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_sequencer_tags = (uint32_t)llv; return 0; + } else if (strcmp(key, "max_sequence_groups") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_groups must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_groups = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_tags") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_group_tags must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_tags = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_executions") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_group_executions must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_executions = (uint32_t)llv; + return 0; } else if (strcmp(key, "max_voices") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; diff --git a/src/sequencer.c b/src/sequencer.c index 243bafd1..1aa02cc1 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,7 +47,164 @@ static volatile bool sequencer_external_clock = false; // flag makes those nested calls no-ops so a tick is never processed twice. static volatile bool wire_firing = false; -void sequencer_init(int max_sequencer_tags) { +// A group definition is immutable once published. Edits are accumulated in a +// private copy and become visible together through SEQUENCE_CONTROL_PUBLISH. +// Active executions retain the published revision they started with. +typedef struct sequence_group_event_t { + char *wire; + uint32_t tick; + uint32_t period; +} sequence_group_event_t; + +typedef struct sequence_group_definition_t { + sequence_group_event_t *events; + uint32_t length_ticks; + uint32_t refs; +} sequence_group_definition_t; + +typedef struct sequence_group_slot_t { + sequence_group_definition_t *published; + sequence_group_definition_t *staging; +} sequence_group_slot_t; + +typedef struct sequence_group_execution_t { + sequence_group_definition_t *definition; + uint32_t group; + uint32_t start_tick; + uint32_t repeats; + uint32_t execution_tag; + uint32_t stop_tick; + uint32_t gate_change_tick; + uint32_t gate_duration; + uint32_t gate_end_tick; + bool occupied; + bool has_execution_tag; + bool stop_pending; + bool gate_change_pending; + bool gated; +} sequence_group_execution_t; + +static sequence_group_slot_t *sequence_groups = NULL; +static sequence_group_execution_t *group_executions = NULL; +static uint32_t max_sequence_groups = 0; +static uint32_t max_sequence_group_tags = 0; +static uint32_t max_sequence_group_executions = 0; +static volatile bool group_wire_firing = false; + +static void group_definition_release(sequence_group_definition_t *definition) { + if (definition == NULL || definition->refs == 0) return; + definition->refs--; + if (definition->refs != 0) return; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) + if (definition->events[i].wire != NULL) free(definition->events[i].wire); + free(definition->events); + free(definition); +} + +static sequence_group_definition_t *group_definition_new(void) { + sequence_group_definition_t *definition = + (sequence_group_definition_t *)malloc_caps(sizeof(sequence_group_definition_t), + amy_global.config.ram_caps_synth); + if (definition == NULL) return NULL; + definition->events = (sequence_group_event_t *)malloc_caps( + sizeof(sequence_group_event_t) * max_sequence_group_tags, + amy_global.config.ram_caps_synth); + if (definition->events == NULL) { + free(definition); + return NULL; + } + memset(definition->events, 0, + sizeof(sequence_group_event_t) * max_sequence_group_tags); + definition->length_ticks = 0; + definition->refs = 1; + return definition; +} + +static char *group_wire_copy(const char *wire) { + size_t len = strlen(wire); + char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + if (copy != NULL) memcpy(copy, wire, len + 1); + return copy; +} + +static sequence_group_definition_t *group_definition_clone( + const sequence_group_definition_t *source) { + sequence_group_definition_t *copy = group_definition_new(); + if (copy == NULL) return NULL; + if (source == NULL) return copy; + copy->length_ticks = source->length_ticks; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + const sequence_group_event_t *from = &source->events[i]; + if (from->wire == NULL) continue; + copy->events[i].wire = group_wire_copy(from->wire); + if (copy->events[i].wire == NULL) { + group_definition_release(copy); + return NULL; + } + copy->events[i].tick = from->tick; + copy->events[i].period = from->period; + } + return copy; +} + +static void group_execution_release(sequence_group_execution_t *execution) { + if (!execution->occupied) return; + sequence_group_definition_t *definition = execution->definition; + memset(execution, 0, sizeof(*execution)); + group_definition_release(definition); +} + +static void group_executions_reset(void) { + if (group_executions == NULL) return; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) + group_execution_release(&group_executions[i]); +} + +static void sequence_groups_deinit(void) { + group_executions_reset(); + if (sequence_groups != NULL) { + for (uint32_t i = 0; i < max_sequence_groups; ++i) { + group_definition_release(sequence_groups[i].published); + group_definition_release(sequence_groups[i].staging); + } + free(sequence_groups); + sequence_groups = NULL; + } + if (group_executions != NULL) { + free(group_executions); + group_executions = NULL; + } + max_sequence_groups = 0; + max_sequence_group_tags = 0; + max_sequence_group_executions = 0; +} + +static void sequence_groups_init(uint32_t groups, uint32_t tags, + uint32_t executions) { + max_sequence_groups = groups; + max_sequence_group_tags = tags; + max_sequence_group_executions = executions; + group_wire_firing = false; + if (groups == 0 || tags == 0 || executions == 0) return; + sequence_groups = (sequence_group_slot_t *)malloc_caps( + sizeof(sequence_group_slot_t) * groups, amy_global.config.ram_caps_synth); + if (sequence_groups != NULL) + memset(sequence_groups, 0, sizeof(sequence_group_slot_t) * groups); + group_executions = (sequence_group_execution_t *)malloc_caps( + sizeof(sequence_group_execution_t) * executions, + amy_global.config.ram_caps_synth); + if (group_executions != NULL) + memset(group_executions, 0, + sizeof(sequence_group_execution_t) * executions); + if (sequence_groups == NULL || group_executions == NULL) { + amy_oom("sequencer groups"); + sequence_groups_deinit(); + return; + } +} + +void sequencer_init(int max_sequencer_tags, uint32_t groups, + uint32_t group_tags, uint32_t group_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). sequencer_running = true; @@ -65,6 +222,7 @@ void sequencer_init(int max_sequencer_tags) { sequences[i].next_active = -1; } first_active = -1; + sequence_groups_init(groups, group_tags, group_execution_count); // We are read to go. sequencer_recompute(); } @@ -82,6 +240,9 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + // Definitions are preloadable state and deliberately survive a transport + // reset; only their active or quantized executions are discarded. + group_executions_reset(); } void sequencer_deinit() { @@ -91,6 +252,13 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + sequence_groups_deinit(); +} + +void sequencer_group_reset_timebase() { + // Absolute activation/control ticks cannot be meaningfully rebased across + // a timebase reset. Persistent definitions remain available for relaunch. + group_executions_reset(); } void sequencer_debug() { @@ -240,6 +408,289 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } +static sequence_group_slot_t *group_slot(uint32_t group) { + if (sequence_groups == NULL || group == 0 || group > max_sequence_groups) + return NULL; + return &sequence_groups[group - 1]; +} + +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL || tag >= max_sequence_group_tags) { + fprintf(stderr, "sequencer group/event tag out of range: group %" PRIu32 + ", tag %" PRIu32 "\n", group, tag); + free(wire); + return 0; + } + if (wire[0] == 'H') { + fprintf(stderr, "a grouped ticks event cannot contain another ticks event\n"); + free(wire); + return 0; + } + + amy_grab_lock(); + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_release_lock(); + amy_oom("sequencer group edit"); + free(wire); + return 0; + } + } + sequence_group_event_t *event = &slot->staging->events[tag]; + if (event->wire != NULL) free(event->wire); + event->wire = NULL; + event->tick = 0; + event->period = 0; + if (tick != 0 || period != 0) { + event->wire = wire; + event->tick = tick; + event->period = period; + wire = NULL; + } + amy_release_lock(); + if (wire != NULL) free(wire); + return 1; +} + +static uint32_t group_control_tick(uint32_t quantize) { + // A control fired by the root sequencer participates in this tick. A + // control arriving between ticks begins no earlier than the next tick. + uint32_t tick = wire_firing ? amy_global.sequencer_tick_count + : amy_global.sequencer_tick_count + 1; + if (quantize != 0) { + uint32_t remainder = tick % quantize; + if (remainder != 0) tick += quantize - remainder; + } + return tick; +} + +static bool group_execution_matches(const sequence_group_execution_t *execution, + uint32_t group, uint32_t execution_tag, + bool has_execution_tag) { + if (!execution->occupied || execution->group != group) return false; + return !has_execution_tag + || (execution->has_execution_tag + && execution->execution_tag == execution_tag); +} + +static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { + if (length == 0) { + fprintf(stderr, "a sequencer group must have a nonzero length\n"); + return 0; + } + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_oom("sequencer group publish"); + return 0; + } + } + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + sequence_group_event_t *event = &slot->staging->events[i]; + if (event->wire == NULL) continue; + if (event->tick >= length + || (event->period != 0 && event->tick >= event->period)) { + fprintf(stderr, "sequencer group event %" PRIu32 + " has tick %" PRIu32 " outside its period/group length\n", + i, event->tick); + return 0; + } + } + slot->staging->length_ticks = length; + sequence_group_definition_t *previous = slot->published; + slot->published = slot->staging; + slot->staging = NULL; + group_definition_release(previous); + return 1; +} + +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL) { + fprintf(stderr, "sequencer group %" PRIu32 " is out of range\n", group); + return 0; + } + if (group_wire_firing + && (action == SEQUENCE_CONTROL_START + || action == SEQUENCE_CONTROL_PUBLISH + || action == SEQUENCE_CONTROL_CLEAR)) { + fprintf(stderr, "a sequencer group cannot launch or edit a group\n"); + return 0; + } + + uint8_t result = 0; + amy_grab_lock(); + if (action == SEQUENCE_CONTROL_PUBLISH) { + result = group_publish(slot, value); + } else if (action == SEQUENCE_CONTROL_CLEAR) { + group_definition_release(slot->published); + group_definition_release(slot->staging); + slot->published = NULL; + slot->staging = NULL; + result = 1; + } else if (action == SEQUENCE_CONTROL_START) { + if (slot->published == NULL || slot->published->length_ticks == 0) { + fprintf(stderr, "sequencer group %" PRIu32 " has no published definition\n", + group); + } else { + uint32_t start_tick = group_control_tick(quantize); + sequence_group_execution_t *available = NULL; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied && available == NULL) available = execution; + } + if (available == NULL) { + fprintf(stderr, "sequencer group execution pool is full\n"); + } else { + if (has_execution_tag) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (group_execution_matches(execution, group, execution_tag, true)) { + execution->stop_tick = start_tick; + execution->stop_pending = true; + } + } + } + memset(available, 0, sizeof(*available)); + available->definition = slot->published; + available->definition->refs++; + available->group = group; + available->start_tick = start_tick; + available->repeats = value; + available->execution_tag = execution_tag; + available->has_execution_tag = has_execution_tag; + available->occupied = true; + result = 1; + } + } + } else if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_GATE) { + uint32_t control_tick = group_control_tick(quantize); + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!group_execution_matches(execution, group, execution_tag, + has_execution_tag)) + continue; + if (action == SEQUENCE_CONTROL_STOP) { + execution->stop_tick = control_tick; + execution->stop_pending = true; + } else { + execution->gate_change_tick = control_tick; + execution->gate_duration = value; + execution->gate_change_pending = true; + } + result = 1; + } + } else { + fprintf(stderr, "unknown sequencer group action %" PRIu32 "\n", action); + } + amy_release_lock(); + return result; +} + +static bool group_event_hits(const sequence_group_event_t *event, + uint32_t local_tick) { + if (event->wire == NULL) return false; + return event->period != 0 ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool group_event_is_control(const sequence_group_event_t *event) { + return event->wire != NULL && strncmp(event->wire, "zQ", 2) == 0; +} + +static void group_play_wire(const char *wire) { + bool previous = group_wire_firing; + group_wire_firing = true; + amy_play_message((char *)wire); + group_wire_firing = previous; +} + +static void group_process_control_events(uint32_t tick) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + amy_grab_lock(); + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + continue; + } + uint32_t elapsed = tick - execution->start_tick; + sequence_group_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (execution->repeats != 0 + && elapsed / definition->length_ticks >= execution->repeats)) { + group_execution_release(execution); + amy_release_lock(); + continue; + } + definition->refs++; + uint32_t local_tick = elapsed % definition->length_ticks; + amy_release_lock(); + + for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { + sequence_group_event_t *event = &definition->events[tag]; + if (group_event_is_control(event) && group_event_hits(event, local_tick)) + group_play_wire(event->wire); + } + + amy_grab_lock(); + group_definition_release(definition); + amy_release_lock(); + } +} + +static void group_process_events(uint32_t tick) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + amy_grab_lock(); + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + continue; + } + uint32_t elapsed = tick - execution->start_tick; + sequence_group_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (execution->repeats != 0 + && elapsed / definition->length_ticks >= execution->repeats)) { + group_execution_release(execution); + amy_release_lock(); + continue; + } + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; + } + if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) + execution->gated = false; + bool gated = execution->gated; + definition->refs++; + uint32_t local_tick = elapsed % definition->length_ticks; + amy_release_lock(); + + if (!gated) { + for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { + sequence_group_event_t *event = &definition->events[tag]; + if (!group_event_is_control(event) + && group_event_hits(event, local_tick)) + group_play_wire(event->wire); + } + } + + amy_grab_lock(); + group_definition_release(definition); + amy_release_lock(); + } +} + static void sequencer_process_tick(void) { amy_global.sequencer_tick_count++; midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode @@ -300,6 +751,10 @@ static void sequencer_process_tick(void) { } tag = next; } + // Controls embedded in a group are leaf operations (stop/gate only) and + // take effect before any ordinary group event on the same tick. + group_process_control_events(amy_global.sequencer_tick_count); + group_process_events(amy_global.sequencer_tick_count); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); diff --git a/src/sequencer.h b/src/sequencer.h index d073e642..2bb038da 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,7 +5,8 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences); +void sequencer_init(int max_num_sequences, uint32_t max_groups, + uint32_t max_group_tags, uint32_t max_group_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); @@ -22,6 +23,23 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) and can't be addressed // or cancelled by any tag. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +// Store one ordinary ticks event in a group's unpublished revision. Takes +// ownership of wire. Group zero is reserved for sequencer_add_wire(). +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire); + +// sequence_control actions. The wire/API representation is always +// [group, action, value, quantize, optional execution_tag]. +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 +#define SEQUENCE_CONTROL_PUBLISH 3 +#define SEQUENCE_CONTROL_CLEAR 4 +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag); +void sequencer_group_reset_timebase(); void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c new file mode 100644 index 00000000..c0422907 --- /dev/null +++ b/tests/test_sequence_groups.c @@ -0,0 +1,198 @@ +// Regression and behavior tests for reusable sequencer groups. + +#include +#include +#include +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[24]; + uint32_t tick; +} mark_t; + +static mark_t marks[128]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + memset(marks, 0, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static uint32_t next_boundary(uint32_t now, uint32_t quantum) { + uint32_t remainder = now % quantum; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +static int mark_at(const char *name, uint32_t tick) { + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; + return 0; +} + +static int marks_named(const char *name) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name)) count++; + return count; +} + +static void clear_group(uint32_t group) { + char wire[32]; + snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); + amy_add_message(wire); +} + +static void test_legacy_ticks_are_unchanged(void) { + printf("legacy root ticks behavior remains unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H0,4,0zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), "root period event fires at global modulo"); + CHECK(mark_at("root", first + 4), "root period event keeps looping"); + amy_add_message("H0,0,0Z"); + + clear_marks(); + uint32_t target = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); + amy_add_message(wire); + clock_to(target); + CHECK(!marks_named("old") && mark_at("new", target), + "legacy root tags still replace by tag"); +} + +static void test_one_n_and_infinite_repeats(void) { + printf("groups support one, N and infinite repeats\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPzeroZ"); + amy_add_message("H2,4,1,1zPtwoZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t one = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,1,4Z"); + clock_to(one + 6); + CHECK(mark_at("zero", one) && mark_at("two", one + 2), + "one-shot uses local ticks from its activation"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "one-shot does not wrap"); + + clear_marks(); + uint32_t twice = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,2,4Z"); + clock_to(twice + 10); + CHECK(mark_at("zero", twice) && mark_at("zero", twice + 4), + "repeat count two runs exactly two phrases"); + CHECK(marks_named("zero") == 2, "N-shot finishes after N phrases"); + + clear_marks(); + uint32_t loop = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,0,4,77Z"); + clock_to(loop + 8); + CHECK(mark_at("zero", loop) && mark_at("zero", loop + 8), + "repeat count zero loops indefinitely"); + amy_add_message("zQ1,0,0,0,77Z"); + clock_to(loop + 12); + CHECK(!mark_at("zero", loop + 12), "tagged stop ends the loop"); +} + +static void test_atomic_revision_lifetime(void) { + printf("published revisions are atomic and immutable while active\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,8,0,2zPold-zeroZ"); + amy_add_message("H6,8,1,2zPold-tailZ"); + amy_add_message("zQ2,3,8Z"); + + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + amy_add_message("H0,8,0,2zPnew-zeroZ"); + amy_add_message("H0,0,1,2Z"); + + uint32_t still_old = old_start + 8; + char root[80]; + snprintf(root, sizeof(root), "H%" PRIu32 ",0,31zQ2,1,1,0Z", still_old); + amy_add_message(root); + clock_to(old_start + 6); + CHECK(mark_at("old-zero", old_start) && mark_at("old-tail", old_start + 6), + "an active execution finishes its original revision"); + + clock_to(still_old); + CHECK(mark_at("old-zero", still_old), + "staged edits are invisible before publication"); + amy_add_message("zQ2,3,8Z"); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + clock_to(new_start + 6); + CHECK(mark_at("new-zero", new_start), "future execution uses published edit"); + CHECK(!mark_at("old-tail", new_start + 6), "published local-tag clear took effect"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("a root event can launch group local tick zero on the same tick\n"); + sequencer_reset(); + clear_group(3); + clear_marks(); + amy_add_message("H0,4,0,3zPchildZ"); + amy_add_message("zQ3,3,4Z"); + + uint32_t start = sequencer_ticks() + 4; + char wire[80]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,22zQ3,1,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child", start), "root launch and group local zero coincide"); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequence_groups = 8; + config.max_sequence_group_tags = 8; + config.max_sequence_group_executions = 8; + amy_start(config); + + test_legacy_ticks_are_unchanged(); + test_one_n_and_infinite_repeats(); + test_atomic_revision_lifetime(); + test_root_launches_local_zero_on_same_tick(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequencer group checks passed\n"); + return 0; +} From 066bc1f28371c279591bb8002c21ebe99c358b00 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:06:41 +0200 Subject: [PATCH 014/112] Expand sequencer group behavior coverage --- tests/test_sequence_groups.c | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index c0422907..05c19392 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -170,6 +170,154 @@ static void test_root_launches_local_zero_on_same_tick(void) { CHECK(mark_at("child", start), "root launch and group local zero coincide"); } +static void test_c_event_uses_fourth_ticks_field(void) { + printf("the C event API defines grouped events through ticks[3]\n"); + sequencer_reset(); + clear_group(6); + amy_event event = amy_default_event(); + event.osc = 0; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 0; + event.ticks[TICKS_PERIOD] = 4; + event.ticks[TICKS_TAG] = 0; + event.ticks[TICKS_GROUP] = 6; + amy_add_event(&event); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "C-authored grouped event publishes"); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "C-authored group starts"); + clock_to(sequencer_ticks() + 2); + amy_execute_deltas(); + CHECK(synth[0] != NULL && synth[0]->wave == TRIANGLE, + "C-authored grouped event reaches normal playback"); +} + +static void test_quantized_gate_preserves_phase(void) { + printf("finite event gating preserves local phase\n"); + sequencer_reset(); + clear_group(4); + clear_group(5); + clear_marks(); + amy_add_message("H0,2,0,4zPbackgroundZ"); + amy_add_message("zQ4,3,4Z"); + amy_add_message("H0,4,0,5zQ4,2,4,0,81Z"); + amy_add_message("H0,4,1,5zPforegroundZ"); + amy_add_message("zQ5,3,4Z"); + + uint32_t background = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ4,1,0,4,81Z"); + clock_to(background + 2); + CHECK(mark_at("background", background) + && mark_at("background", background + 2), + "background loop initially emits on phase"); + + uint32_t foreground = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ5,1,1,4Z"); + clock_to(foreground + 4); + CHECK(mark_at("foreground", foreground), "foreground group starts normally"); + CHECK(!mark_at("background", foreground) + && !mark_at("background", foreground + 2), + "gate suppresses events for its exact duration"); + CHECK(mark_at("background", foreground + 4), + "background resumes on its unchanged phase"); + amy_add_message("zQ4,0,0,0,81Z"); + clock_to(foreground + 6); +} + +static void test_quantized_stop_precedes_boundary_event(void) { + printf("quantized stop takes effect before an event at its boundary\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPstoppedZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,0,4,91Z"); + clock_to(start); + CHECK(mark_at("stopped", start), "loop starts on its boundary"); + + uint32_t stop = next_boundary(sequencer_ticks(), 8); + amy_add_message("zQ6,0,0,8,91Z"); + clock_to(stop); + CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); +} + +static void test_group_control_cannot_recurse(void) { + printf("a group cannot launch a third sequencer level\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPgrandchildZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,7zQ8,1,1,0Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start + 4); + CHECK(!marks_named("grandchild"), "nested group launch is rejected"); +} + +static void test_resets_keep_definitions_only(void) { + printf("sequencer and timebase resets stop executions but keep definitions\n"); + sequencer_reset(); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPsurvivorZ"); + amy_add_message("zQ8,3,4Z"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,0,4Z"); + clock_to(first); + CHECK(mark_at("survivor", first), "definition runs before reset"); + + clear_marks(); + sequencer_reset(); + clock_to(first + 4); + CHECK(!marks_named("survivor"), "RESET_SEQUENCER stops active executions"); + uint32_t second = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,1,4Z"); + clock_to(second); + CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); + + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + clock_to(sequencer_ticks() + 2); + sequencer_group_reset_timebase(); + clear_marks(); + uint32_t after_reset = sequencer_ticks() + 4; + clock_to(after_reset); + CHECK(!marks_named("survivor"), "RESET_TIMEBASE stops active executions"); + amy_add_message("zQ8,1,1,0Z"); + clock_to(sequencer_ticks() + 2); + CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); +} + +static void test_configured_bounds(void) { + printf("configured group, local-tag and execution bounds are enforced\n"); + sequencer_reset(); + clear_group(8); + char *valid = strdup("zPlastZ"); + char *bad_group = strdup("zPbad-groupZ"); + char *bad_tag = strdup("zPbad-tagZ"); + CHECK(sequencer_group_add_wire(0, 4, 7, 8, valid), + "last configured group and local tag are valid"); + CHECK(!sequencer_group_add_wire(0, 4, 0, 9, bad_group), + "first group past the configured range is rejected"); + CHECK(!sequencer_group_add_wire(0, 4, 8, 8, bad_tag), + "first local tag past the configured range is rejected"); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "last group publishes"); + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + i, true), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + 8, true), + "one execution beyond the configured pool is rejected"); + sequencer_reset(); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -187,6 +335,12 @@ int main(void) { test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); + test_c_event_uses_fourth_ticks_field(); + test_quantized_gate_preserves_phase(); + test_quantized_stop_precedes_boundary_event(); + test_group_control_cannot_recurse(); + test_resets_keep_definitions_only(); + test_configured_bounds(); amy_stop(); if (failures) { From 83ae7f1d0adfeea2b2ef565bf11ca15133d28494 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:10:28 +0200 Subject: [PATCH 015/112] Document sequencer group API --- amy/constants.py | 5 ++ docs/api.md | 6 +- docs/sequencer-groups.md | 132 +++++++++++++++++++++++++++++++++++++++ docs/synth.md | 13 +++- godot/amy.gd | 44 ++++++------- src/amy.h | 6 ++ src/amy_api.generated.js | 6 ++ src/api.c | 4 +- src/sequencer.h | 5 -- 9 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 docs/sequencer-groups.md diff --git a/amy/constants.py b/amy/constants.py index ecf4bb28..4820ffbe 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -125,6 +125,11 @@ TICKS_PERIOD=1 TICKS_TAG=2 TICKS_GROUP=3 +SEQUENCE_CONTROL_STOP=0 +SEQUENCE_CONTROL_START=1 +SEQUENCE_CONTROL_GATE=2 +SEQUENCE_CONTROL_PUBLISH=3 +SEQUENCE_CONTROL_CLEAR=4 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/docs/api.md b/docs/api.md index 0a19e45f..16f07b90 100644 --- a/docs/api.md +++ b/docs/api.md @@ -204,6 +204,9 @@ amy_start(amy_config); | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | | `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | +| `max_sequence_groups` | Int | 32 | Number of persistent sequencer groups; group tags are 1 through this value | +| `max_sequence_group_tags` | Int | 64 | Addressable local event tags in each allocated group definition | +| `max_sequence_group_executions` | Int | 32 | Maximum active or quantized-pending group executions | | `max_voices` | Int | 64 | How many voices | | `max_synths` | Int | 64 | How many synths | | `max_memory_patches` | Int | 32 | How many in memory patches to supprot | @@ -503,8 +506,9 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `H` | `ticks[4]` | `ticks` | int[,int[,tag[,group]]] | Tick, period and tag for root sequencing. A nonzero fourth value instead addresses a persistent [sequencer group](sequencer-groups.md), with the third value as its local event tag. `tag` omitted at root: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | +| `zQ` | — | `sequence_control` | group,action,value,quantize[,execution_tag] | Publish, start, stop, gate or clear a [sequencer group](sequencer-groups.md). | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | | `N` | `latency_ms`| `latency_ms` | uint | Sets latency in ms. default 0 (see LATENCY) | diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md new file mode 100644 index 00000000..a5303238 --- /dev/null +++ b/docs/sequencer-groups.md @@ -0,0 +1,132 @@ +# Sequencer groups + +Sequencer groups are reusable collections of ordinary AMY sequencer events. +They add one bounded level below the existing root sequencer: a root event may +start a group, but a group cannot start another group. + +This is useful when a musical controller needs to trigger a complete phrase +as one operation. Examples include a drum fill, a short arpeggio with its own +note-on and note-off, or a repeating percussion layer. The controller can +preload these phrases and later send one small, quantized control message. It +does not need to reproduce AMY's clock or resend every event at performance +time. + +## Defining and publishing a group + +The normal `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +`group_tag` values start at 1. An absent or zero group tag uses the existing +root sequencer without changing any of its semantics. + +This wire sequence stages a four-beat phrase in group 1 and then publishes it +atomically with a length of 192 ticks: + +```text +H0,192,0,1i2n60l1Z +H24,192,1,1i2n60l0Z +H48,192,2,1i2n64l1Z +H72,192,3,1i2n64l0Z +zQ1,3,192Z +``` + +The equivalent Python calls are: + +```python +amy.send(ticks="0,192,0,1", synth=2, note=60, vel=1) +amy.send(ticks="24,192,1,1", synth=2, note=60, vel=0) +amy.send(ticks="48,192,2,1", synth=2, note=64, vel=1) +amy.send(ticks="72,192,3,1", synth=2, note=64, vel=0) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_PUBLISH, 192]) +``` + +Grouped `ticks` commands update a private staging revision. Publishing is one +action in the generic control family rather than a separate begin/add/commit +API. It makes all staged local-tag replacements visible together, so a launch +can never observe a half-updated phrase. As at the root, `tick=0,period=0` +clears the specified event tag. Use a nonzero period for an event at local tick +zero. + +The published length is explicit and bounded; AMY does not derive it using an +LCM of event periods. Within each phrase, a nonzero event period repeats by +local modulo and a zero period fires once at its local tick. + +## Controlling executions + +The control layout is fixed: + +```text +group,action,value,quantize[,execution_tag] +``` + +| Action | Number | Meaning of `value` | +|---|---:|---| +| stop | 0 | reserved; use 0 | +| start | 1 | repeat count: 1 once, N exactly N times, 0 indefinitely | +| gate | 2 | suppress group-event firings for this many ticks; 0 releases a gate | +| publish | 3 | explicit group length in ticks | +| clear | 4 | reserved; use 0 | + +`quantize=0` means the next sequencer tick for a direct command. Otherwise the +control takes effect at the next multiple of that many ticks. When a root +sequencer event issues the control on the boundary itself, it takes effect on +that same tick, including the group's local tick-zero events. + +For example, start group 1 indefinitely at the next 192-tick boundary, assign +execution tag 100, and later stop that execution at a boundary: + +```text +zQ1,1,0,192,100Z +zQ1,0,0,192,100Z +``` + +```python +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_START, 0, 192, 100]) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_STOP, 0, 192, 100]) +``` + +Omit `execution_tag` to address every active execution of the group for stop +or gate operations. Supplying a tag to start makes a later start with the same +group and execution tag replace it on the requested boundary. Untagged starts +may overlap, which is useful for one-shot note phrases whose releases must be +allowed to finish independently. + +A finite gate advances the execution's local clock but suppresses its event +firings. Audio already sounding is not stopped, and the first event after the +gate occurs at its original phase. A gate can itself be placed in another +group as a leaf control; start, publish and clear are rejected while a group +payload is firing, preventing recursive nesting. + +## Scheduling a launch at the root + +Because `sequence_control` is an ordinary wire command, it can be the payload +of a normal root `ticks` event. This starts group 1 once at absolute tick 960: + +```text +H960,0,40zQ1,1,1,0Z +``` + +A repeating root entry can launch the same group sparsely without copying its +events. Clear that future launch with the unchanged root operation +`H0,0,40Z`; an execution already started from it keeps running. + +## Lifetime and memory guarantees + +An active execution retains the immutable published revision it started with. +Editing, publishing or clearing the group affects future starts only. This is +important for phrases containing releases: an old note-off cannot disappear +because a new definition was loaded while it was sounding. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and quantized-pending +executions but preserve published group definitions. Full AMY shutdown frees +them. + +Storage and work are bounded by `max_sequence_groups`, +`max_sequence_group_tags` and `max_sequence_group_executions` in +`amy_config_t`. Group event arrays and wire payloads are allocated only for +definitions that are authored. The tick path scans only the fixed active +execution pool; inactive stored groups are not visited, and starting an +execution does not allocate memory. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..0b22eb26 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -241,6 +241,18 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](docs/api.md) to any function. This will be called at every tick with the current tick number as an argument. +### Reusable sequencer groups + +A fourth `ticks` value stores an event in a reusable group instead of the root +sequencer: `tick,period,event_tag,group_tag`. Group tag zero is reserved for +the root sequencer, so existing one-, two- and three-value `ticks` messages +retain their original behavior. Groups are controlled through the single +`sequence_control` parameter; they can run once, a fixed number of times, or +continuously, and start/stop can be quantized to AMY's tick clock. + +See [Sequencer groups](sequencer-groups.md) for the wire format, lifecycle, +examples and implementation guarantees. + ## Core oscillators We support bandlimited saw, pulse/square and triangle waves, alongside sine and noise. Use the wave parameter: 0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE. Each oscillator can have a frequency (or set by midi note), amplitude and phase (set in 0-1.). You can also set `duty` for the pulse type. We also have a karplus-strong type (KS=6), plus `WAVETABLE` when compiled with `AMY_WAVETABLE` that plays back 16,384 sample long wavetable packs, such as those hosted on [waveeditonline.com](http://waveeditonline.com). @@ -478,4 +490,3 @@ amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) - diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..435e3693 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -343,6 +343,7 @@ var _KW_MAP: Dictionary = { "portamento": ["m", "I"], "tempo": ["j", "F"], "sequencer_run": ["zY", "I"], + "sequence_control": ["zQ", "L"], "external_midi_sync": ["zC", "I"], "synth": ["i", "I"], "pedal": ["ip", "I"], @@ -418,27 +419,28 @@ var _KW_PRIORITY: Dictionary = { "portamento": 48, "tempo": 49, "sequencer_run": 50, - "external_midi_sync": 51, - "synth": 52, - "pedal": 53, - "synth_flags": 54, - "num_voices": 55, - "oscs_per_voice": 56, - "synth_level": 57, - "to_synth": 58, - "grab_midi_notes": 59, - "note_source_channel": 60, - "synth_delay": 61, - "preset": 62, - "num_partials": 63, - "start_sample": 64, - "stop_sample": 65, - "bus": 66, - "mode": 67, - "midi_cc": 68, - "midi_note_cmd": 69, - "cv_trigger": 70, - "patch_string": 71, + "sequence_control": 51, + "external_midi_sync": 52, + "synth": 53, + "pedal": 54, + "synth_flags": 55, + "num_voices": 56, + "oscs_per_voice": 57, + "synth_level": 58, + "to_synth": 59, + "grab_midi_notes": 60, + "note_source_channel": 61, + "synth_delay": 62, + "preset": 63, + "num_partials": 64, + "start_sample": 65, + "stop_sample": 66, + "bus": 67, + "mode": 68, + "midi_cc": 69, + "midi_note_cmd": 70, + "cv_trigger": 71, + "patch_string": 72, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/src/amy.h b/src/amy.h index 11deb495..37a71d03 100644 --- a/src/amy.h +++ b/src/amy.h @@ -365,6 +365,12 @@ enum coefs{ #define TICKS_TAG 2 #define TICKS_GROUP 3 +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 +#define SEQUENCE_CONTROL_PUBLISH 3 +#define SEQUENCE_CONTROL_CLEAR 4 + // Reset masks #define RESET_SEQUENCER 4096 #define RESET_ALL_OSCS 8192 diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 4f92c050..230f8876 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -408,6 +408,12 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + TICKS_GROUP: 3, + SEQUENCE_CONTROL_STOP: 0, + SEQUENCE_CONTROL_START: 1, + SEQUENCE_CONTROL_GATE: 2, + SEQUENCE_CONTROL_PUBLISH: 3, + SEQUENCE_CONTROL_CLEAR: 4, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, diff --git a/src/api.c b/src/api.c index 8b0371cd..faad006c 100644 --- a/src/api.c +++ b/src/api.c @@ -49,8 +49,8 @@ amy_config_t amy_default_config() { c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; c.max_sequence_groups = 32; - c.max_sequence_group_tags = 32; - c.max_sequence_group_executions = 16; + c.max_sequence_group_tags = 64; + c.max_sequence_group_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; diff --git a/src/sequencer.h b/src/sequencer.h index 2bb038da..82eda5ba 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -30,11 +30,6 @@ uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, // sequence_control actions. The wire/API representation is always // [group, action, value, quantize, optional execution_tag]. -#define SEQUENCE_CONTROL_STOP 0 -#define SEQUENCE_CONTROL_START 1 -#define SEQUENCE_CONTROL_GATE 2 -#define SEQUENCE_CONTROL_PUBLISH 3 -#define SEQUENCE_CONTROL_CLEAR 4 uint8_t sequencer_group_control(uint32_t group, uint32_t action, uint32_t value, uint32_t quantize, uint32_t execution_tag, From 109852803bd1385100448e49965dff949d3ba5dd Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:01:54 +0200 Subject: [PATCH 016/112] Cover sequencer group identity and rollover --- tests/test_sequence_groups.c | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 05c19392..13e9a3d5 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -83,6 +83,35 @@ static void test_legacy_ticks_are_unchanged(void) { clock_to(target); CHECK(!marks_named("old") && mark_at("new", target), "legacy root tags still replace by tag"); + + clear_marks(); + uint32_t group_zero = next_boundary(sequencer_ticks(), 4); + amy_add_message("H0,4,5,0zPgroup-zero-rootZ"); + clock_to(group_zero); + CHECK(mark_at("group-zero-root", group_zero), + "an explicit group tag zero follows the legacy root path"); + amy_add_message("H0,0,5Z"); +} + +static void test_group_local_tags_are_independent(void) { + printf("event tags are local to each sequencer group\n"); + sequencer_reset(); + clear_group(6); + clear_group(7); + clear_marks(); + amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); + amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); + amy_add_message("zQ6,3,4Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,1,4Z"); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start); + CHECK(mark_at("group-six-tag-zero", start), + "group 6 owns its event tag zero"); + CHECK(mark_at("group-seven-tag-zero", start), + "group 7 independently owns event tag zero"); } static void test_one_n_and_infinite_repeats(void) { @@ -293,6 +322,24 @@ static void test_resets_keep_definitions_only(void) { CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); } +static void test_group_start_crosses_clock_rollover(void) { + printf("group phase remains correct across the 32-bit tick rollover\n"); + sequencer_reset(); + clear_group(5); + clear_marks(); + amy_add_message("H0,4,0,5zPwrap-zeroZ"); + amy_add_message("H1,0,1,5zPwrap-oneZ"); + amy_add_message("zQ5,3,4Z"); + + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("zQ5,1,1,4Z"); + clock_to(1); + CHECK(mark_at("wrap-zero", 0), + "quantized local tick zero fired after rollover"); + CHECK(mark_at("wrap-one", 1), + "local elapsed time advanced across rollover"); +} + static void test_configured_bounds(void) { printf("configured group, local-tag and execution bounds are enforced\n"); sequencer_reset(); @@ -332,6 +379,7 @@ int main(void) { amy_start(config); test_legacy_ticks_are_unchanged(); + test_group_local_tags_are_independent(); test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); @@ -340,6 +388,7 @@ int main(void) { test_quantized_stop_precedes_boundary_event(); test_group_control_cannot_recurse(); test_resets_keep_definitions_only(); + test_group_start_crosses_clock_rollover(); test_configured_bounds(); amy_stop(); From 45fc871f949d93d62fa10eb2a926a78751042a3c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:40:22 +0200 Subject: [PATCH 017/112] Add sequencer group usage guides --- docs/sequencer-groups-abstractions.md | 150 ++++++++++++ docs/sequencer-groups-howto.md | 258 +++++++++++++++++++++ docs/sequencer-groups-musical-use-cases.md | 100 ++++++++ docs/sequencer-groups.md | 6 + 4 files changed, 514 insertions(+) create mode 100644 docs/sequencer-groups-abstractions.md create mode 100644 docs/sequencer-groups-howto.md create mode 100644 docs/sequencer-groups-musical-use-cases.md diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md new file mode 100644 index 00000000..264fafce --- /dev/null +++ b/docs/sequencer-groups-abstractions.md @@ -0,0 +1,150 @@ +# Sequencer-group abstractions and implementation + +AMY's root sequencer stores ordinary events on one global musical timeline. +Sequencer groups add one reusable, bounded phrase level below that timeline: a +root event can start a finite or repeating group of ordinary AMY events. They +do not add a drum machine, arpeggiator, song model, or recursive scheduler. + +For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). +For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). +The concise argument reference is in [Sequencer groups](sequencer-groups.md). + +## The model + +The model separates stored content, scheduled starts, and active playback: + +| Object | Purpose | Lifetime | +| --- | --- | --- | +| Root sequencer event | Decides when a group starts | Existing `H` tick/period/tag semantics | +| Group tag | Selects one reusable definition slot | From 1 through the configured group capacity | +| Staging revision | Receives local event edits privately | Until published or cleared | +| Published revision | Supplies immutable content to future starts | Until replaced or cleared | +| Execution | Plays one captured revision | Until its repeat count completes or it is stopped | +| Execution tag | Optionally addresses live or pending executions | Supplied by the start operation | +| Local event tag | Replaces or clears one event in one group's staging revision | Scoped to that group only | + +Root tags, group tags, execution tags, and local event tags are separate +identities. For example, replacing a tagged root event changes which phrase +will start in the future. It does not edit the phrase definition or shorten an +execution that has already started. + +## Authoring and publication + +The existing `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +With a nonzero `group_tag`, the `H` message edits that group's private staging +revision instead of the root sequencer. The first edit after publication clones +the current published revision, so a host can replace only the local tags that +changed. A local tag is cleared with `tick=0,period=0`, exactly like a tagged +root event. + +Because that pair means clear, an event at local tick zero must use a nonzero +period. Using the group length as its period is usually the clearest choice; a +finite execution still fires it only once per repetition. + +Publication uses action 3 of the `sequence_control` family: + +```text +zQ,3,Z +``` + +The length is explicit. AMY validates every staged event against it, then +publishes the complete revision atomically. Playback therefore never observes +a partly rewritten phrase. AMY does not infer a potentially expensive least +common multiple from event periods. + +## Execution lifetime + +A start captures the currently published revision. Its repeat value is: + +- `1` for one performance; +- `N` for exactly N performances; +- `0` for indefinite repetition. + +Editing, publishing, or clearing the group afterward affects future starts +only. Every active execution retains a reference to the revision it captured +and can deliver the note-offs or other closing events already stored in that +revision. This is the key guarantee for glitch-free live phrase changes. + +Starts and stops can be quantized to the next multiple of a sequencer tick +interval. A zero quantization value means the next sequencer tick for a direct +command. When a root event starts a group, local tick zero is processed on that +same root tick. + +An optional execution tag gives live playback a stable control identity. A new +start with the same group and execution tag replaces the matching execution at +the requested boundary. Untagged starts may overlap. Stop and gate operations +can address one execution tag or, when the tag is omitted, all executions of a +group. + +## Finite event gates + +Gate action 2 suppresses event dispatch for a duration while the execution's +local clock continues advancing. It does not stop already-sounding audio. When +the gate ends, the next event occurs at its original phase rather than at a +restarted phase. A zero duration releases a current gate. + +A group may contain a gate control as a leaf event. This lets one finite phrase +temporarily suppress events from another tagged repeating layer. AMY assigns no +musical meaning to either layer; the controller owns that policy. + +## Bounded scheduling + +The root sequencer may start a group. A group may contain ordinary AMY events +and finite gate controls, but it cannot start, publish, or clear a group. This +provides the two useful musical levels—global arrangement and reusable +phrase—without cycles or unbounded recursive work. + +The configured limits independently bound: + +- persistent group slots; +- local event tags in each allocated definition; +- active or quantized-pending executions. + +The portable defaults are 32 groups, 64 local tags per group, and 32 active or +pending executions. Definition storage is allocated only when a group is +authored. The audio-time tick path scans only the fixed execution pool, not all +stored groups, so an application can choose a larger definition catalogue +without making every inactive definition part of per-tick work. + +## Implementation outline + +The implementation in [`src/sequencer.c`](../src/sequencer.c) deliberately +reuses the normal event path: + +- grouped `H` messages store the same wire payloads AMY already parses; +- staged and published definitions use fixed-capacity local-tag tables; +- published revisions are reference-counted and remain alive while captured by + an execution; +- an independently bounded execution pool owns start phase, repeat count, + execution identity, pending stop, and gate state; +- root events are processed before group events, which makes a root launch and + its local tick-zero payload sample-clock coherent; +- recursive group lifecycle operations are rejected while a grouped payload is + firing. + +The public configuration fields and constants are declared in +[`src/amy.h`](../src/amy.h). The group engine entry points are in +[`src/sequencer.h`](../src/sequencer.h), and Python uses the existing +`amy.send(ticks=...)` and `amy.send(sequence_control=...)` interface. + +## Compatibility contract + +An absent or zero fourth `ticks` value follows the existing root-sequencer path. +Existing three-field `H` messages, anonymous root events, tag replacement and +clear behavior, modulo periods, and `amy_add_event()` scheduling are unchanged. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and pending executions +but preserve published group definitions. Full AMY shutdown releases the +definitions. + +The native group regression test exercises legacy root behavior and group +behavior in the same process. It covers root compatibility, local tag +namespaces, one/N/infinite repetition, quantization, atomic publication, +immutable active revisions, same-tick root launches, finite phase-preserving +gates, recursion rejection, resets, 32-bit clock rollover, and configured +bounds. diff --git a/docs/sequencer-groups-howto.md b/docs/sequencer-groups-howto.md new file mode 100644 index 00000000..9c747a52 --- /dev/null +++ b/docs/sequencer-groups-howto.md @@ -0,0 +1,258 @@ +# Sequencer-group how-to: switchable arpeggios and a percussion gate + +This example sends complete AMY wire messages, including the final `Z`. AMY's +sequencer uses 48 ticks per quarter note, so the arpeggios use 24 ticks per +eighth note and a 96-tick phrase length. + +The examples use `amy.send()` as the Python API. Each expandable section emits +the same wire message shown directly above it. + +## 1. Configure a simple sound + +Use oscillator 0 with a sine wave so the example does not depend on a stored +patch bank: + +```text +v0w0Z +``` + +
+Python API equivalent + +```python +import amy + +amy.send(osc=0, wave=amy.SINE) +``` + +
+ +## 2. Preload an ascending arpeggio + +Group 10 plays C4, E4, G4, and C5. Each note begins 24 ticks after the previous +one and has an 18-tick gate: + +```text +H0,96,0,10v0n60l1Z +H18,96,1,10v0l0Z +H24,96,2,10v0n64l1Z +H42,96,3,10v0l0Z +H48,96,4,10v0n67l1Z +H66,96,5,10v0l0Z +H72,96,6,10v0n72l1Z +H90,96,7,10v0l0Z +zQ10,3,96Z +``` + +The fourth `H` value selects group 10. The third value is a local event tag, +not a root tag. These messages update private staging storage; publish action 3 +makes the complete 96-tick revision visible atomically. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 10], osc=0, note=60, vel=1) +amy.send(ticks=[18, 96, 1, 10], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 10], osc=0, note=64, vel=1) +amy.send(ticks=[42, 96, 3, 10], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 10], osc=0, note=67, vel=1) +amy.send(ticks=[66, 96, 5, 10], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 10], osc=0, note=72, vel=1) +amy.send(ticks=[90, 96, 7, 10], osc=0, vel=0) +amy.send(sequence_control=[10, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 3. Preload a descending arpeggio + +Group 11 uses the same timing and reverses the pitches: + +```text +H0,96,0,11v0n72l1Z +H18,96,1,11v0l0Z +H24,96,2,11v0n67l1Z +H42,96,3,11v0l0Z +H48,96,4,11v0n64l1Z +H66,96,5,11v0l0Z +H72,96,6,11v0n60l1Z +H90,96,7,11v0l0Z +zQ11,3,96Z +``` + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 11], osc=0, note=72, vel=1) +amy.send(ticks=[18, 96, 1, 11], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 11], osc=0, note=67, vel=1) +amy.send(ticks=[42, 96, 3, 11], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 11], osc=0, note=64, vel=1) +amy.send(ticks=[66, 96, 5, 11], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 11], osc=0, note=60, vel=1) +amy.send(ticks=[90, 96, 7, 11], osc=0, vel=0) +amy.send(sequence_control=[11, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 4. Turn on the ascending arpeggio + +Install a normal repeating root event. Every 96 ticks it starts group 10 once. +Root tag 200 gives that future schedule a replaceable identity: + +```text +H0,96,200zQ10,1,1,0Z +zY1Z +``` + +The embedded control arguments are: + +```text +zQ group,action,repeats,quantize Z + 10 1 1 0 +``` + +Action 1 means start, and repeat value 1 makes each execution finite. The root +event supplies the repetition. Quantization is zero because the root event +already fires on the exact musical boundary; the group's local tick-zero event +is delivered on that same tick. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 0], +) +amy.send(sequencer_run=1) +``` + +
+ +## 5. Switch to the descending arpeggio + +Replace root tag 200 with a start for group 11: + +```text +H0,96,200zQ11,1,1,0Z +``` + +The next matching root boundary starts the descending revision. An ascending +execution that already began keeps its captured revision and reaches every +original note-off normally. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[11, amy.SEQUENCE_CONTROL_START, 1, 0], +) +``` + +
+ +## 6. Turn the arpeggio off and on + +Clear root tag 200 with the unchanged root-sequencer operation: + +```text +H0,0,200Z +``` + +This prevents future starts. It does not stop an execution that has already +begun, so the current phrase finishes with its normal note gates. Re-send the +root message from step 4 or 5 to turn the selected arpeggio on again. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 0, 200]) +``` + +
+ +To play group 10 only once instead of installing a root schedule, start one +execution at the next 96-tick boundary: + +```text +zQ10,1,1,96Z +``` + +
+Python API equivalent + +```python +amy.send( + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 96] +) +``` + +
+ +## 7. Gate one percussion instrument from a controller + +An independently controllable percussion role needs its own group execution. +Assume synth 10 is already configured as a percussion instrument and MIDI note +42 produces the desired closed hi-hat. Group 20 triggers that hit every 24 +ticks, and execution tag 300 is its live control address: + +```text +H0,24,0,20i10n42l1Z +zQ20,3,24Z +zQ20,1,0,24,300Z +``` + +The start repeat value is zero, so the execution repeats indefinitely. Other +percussion roles should use separate groups and execution tags when they need +independent control. + +Suppose a MIDI foot controller, switch, or other input has already been mapped +by the sending application. On press, it can apply a long finite event gate: + +```text +zQ20,2,2147483647,0,300Z +``` + +On release, duration zero removes the gate immediately: + +```text +zQ20,2,0,0,300Z +``` + +The gate suppresses future events from execution 300. It does not cut off a +sample that is already sounding, and the execution's clock continues. When the +gate is released, the hi-hat resumes on its original 24-tick phase. Reading the +controller and mapping it to these messages remain outside AMY. + +
+Python API equivalent + +```python +# Define and start the independently controllable hi-hat layer. +amy.send(ticks=[0, 24, 0, 20], synth=10, note=42, vel=1) +amy.send(sequence_control=[20, amy.SEQUENCE_CONTROL_PUBLISH, 24]) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_START, 0, 24, 300] +) + +# Controller press, then controller release. +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 2147483647, 0, 300] +) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 0, 0, 300] +) +``` + +
+ +When the silence has a known musical duration, send that duration directly. +For example, `zQ20,2,192,0,300Z` suppresses four quarter notes at 48 PPQ and +then releases automatically without another controller message. diff --git a/docs/sequencer-groups-musical-use-cases.md b/docs/sequencer-groups-musical-use-cases.md new file mode 100644 index 00000000..1db6b7a8 --- /dev/null +++ b/docs/sequencer-groups-musical-use-cases.md @@ -0,0 +1,100 @@ +# Musical use cases for sequencer groups + +Sequencer groups are useful when a musical phrase must remain a coherent unit +while a controller changes what will play next. Two representative applications +are an interactive rhythm engine with selectable drum fills and an arpeggiator +whose timing, direction, or notes can change during playback. Both are expressed +as ordinary AMY events on a local timeline; AMY contains no policy specific to +either application. + +## Dynamic drum fills + +Consider a rhythm engine that combines repeating percussion layers with a +selectable fill and a fill density. It may offer hundreds of short fills, let a +player change the active selection while transport continues, and temporarily +silence some background layers during a fill while allowing others to continue. + +A flat root sequence can represent one final arrangement. Live editing is more +complicated: the host must expand every chosen fill into root events, identify +which future events are safe to replace, coordinate the background boundaries, +avoid truncating a fill already in progress, and resend a large schedule whenever +selection or density changes. Combining fills, densities, and independently +controlled background layers multiplies that state even though every individual +phrase is small. + +Sequencer groups preserve the useful phrase boundary: + +1. The controller preloads each fill once as a finite group. +2. A small tagged root event starts the selected group at a musical boundary. +3. Independently controllable background roles run as tagged repeating group + executions. +4. A fill can contain finite gate events for background executions that should + not dispatch events during that fill. +5. Replacing or clearing the root event changes future fills only. A fill that + already started retains its immutable revision and finishes normally. + +The controller still owns every musical choice: fill selection, density, +instrument roles, and which roles continue. AMY only provides reusable phrase +storage, coherent execution, and generic event gating. Live control therefore +changes a small reference instead of rewriting the expanded leaf-event schedule. + +Stored definitions and active executions have independent limits. A rhythm +engine can configure enough group slots for a large fill catalogue without +creating hundreds of live players or scanning every stored fill on each tick. + +## Arpeggios with clean live changes + +An arpeggio can also be expanded into the root sequencer. The difficult part is +changing rate, direction, pitch, or voicing while notes are already in flight. +Deleting old root entries can remove a future note-off and leave a note hanging. +Sending an immediate all-off prevents the hang but shortens a valid note. A +host-side timer can defer the edit, but then the host must mirror AMY's musical +clock and track the lifetimes of overlapping phrases. + +Instead, one group revision stores the complete arpeggio phrase, including every +note-on and its matching note-off. Tagged root events determine when that phrase +starts. When a player changes the arpeggio: + +- the controller stages and atomically publishes the complete replacement; +- future starts capture the new published revision; +- an execution already sounding retains its previous immutable revision; +- every release in that execution therefore occurs at its original gate; +- quantized root starts preserve the musical boundary; +- untagged executions may overlap when a new phrase starts before an older one + has finished. + +The result avoids both abrupt releases and delayed hanging notes. AMY does not +know that the event collection is an arpeggio; the same lifetime guarantee +applies to any finite musical gesture. + +## Independently controlled repeating layers + +A drum voice, ostinato, control phrase, or other repeating part can run as an +independently tagged group execution. A controller can stop it at a quantized +boundary or gate future event dispatch without stopping the sequencer, changing +the phase, or affecting unrelated layers. + +For example, a foot controller can gate the event stream that triggers one +percussion instrument. Pedal-down suppresses future hits for that tagged +execution, while a sample already sounding ends naturally. Pedal-up releases the +gate and the next hit occurs on the layer's original phase. Reading the pedal and +choosing the execution tag remain responsibilities of the controller application. + +## The common abstraction + +All three applications share the same structure: + +```text +root timeline: decide when a stored phrase starts +group definition: store a coherent local event sequence +group execution: play one immutable revision with a bounded lifetime +execution control: start, stop, or temporarily gate that playback +``` + +A flat sequence can ultimately represent the same notes. The group boundary is +valuable because it makes live changes atomic, compact, and independent of host +timing. It moves phrase completion and release ownership into AMY without moving +application-specific musical policy into the synthesizer. + +See the [step-by-step arpeggio and percussion-gate example](sequencer-groups-howto.md) +for the corresponding wire commands and Python calls. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index a5303238..ee6e8698 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -11,6 +11,12 @@ preload these phrases and later send one small, quantized control message. It does not need to reproduce AMY's clock or resend every event at performance time. +Related guides: + +- [Abstractions and implementation](sequencer-groups-abstractions.md) +- [Musical use cases](sequencer-groups-musical-use-cases.md) +- [Step-by-step wire and Python how-to](sequencer-groups-howto.md) + ## Defining and publishing a group The normal `ticks` tuple accepts an optional fourth value: From b1e995fba8430a4210102a877df23143ff237761 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:23:49 +0200 Subject: [PATCH 018/112] Align sequencer group terminology and links --- README.md | 2 ++ docs/sequencer-groups-abstractions.md | 10 +++++----- docs/sequencer-groups.md | 2 +- docs/synth.md | 10 ++++++---- tests/test_sequence_groups.c | 10 +++++----- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 77d9e83e..eea0daf0 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Sequencer Groups**](docs/sequencer-groups.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) @@ -171,6 +172,7 @@ It's good to understand what wire messages are but you don't need to construct t * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Sequencer Groups**](docs/sequencer-groups.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md index 264fafce..96ad6d4b 100644 --- a/docs/sequencer-groups-abstractions.md +++ b/docs/sequencer-groups-abstractions.md @@ -3,7 +3,7 @@ AMY's root sequencer stores ordinary events on one global musical timeline. Sequencer groups add one reusable, bounded phrase level below that timeline: a root event can start a finite or repeating group of ordinary AMY events. They -do not add a drum machine, arpeggiator, song model, or recursive scheduler. +do not add a drum machine, arpeggiator, song model, or scheduler hierarchy. For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). @@ -97,7 +97,7 @@ musical meaning to either layer; the controller owns that policy. The root sequencer may start a group. A group may contain ordinary AMY events and finite gate controls, but it cannot start, publish, or clear a group. This provides the two useful musical levels—global arrangement and reusable -phrase—without cycles or unbounded recursive work. +phrase—without cycles or variable scheduling depth. The configured limits independently bound: @@ -124,7 +124,7 @@ reuses the normal event path: execution identity, pending stop, and gate state; - root events are processed before group events, which makes a root launch and its local tick-zero payload sample-clock coherent; -- recursive group lifecycle operations are rejected while a grouped payload is +- group-to-group lifecycle operations are rejected while a grouped payload is firing. The public configuration fields and constants are declared in @@ -146,5 +146,5 @@ The native group regression test exercises legacy root behavior and group behavior in the same process. It covers root compatibility, local tag namespaces, one/N/infinite repetition, quantization, atomic publication, immutable active revisions, same-tick root launches, finite phase-preserving -gates, recursion rejection, resets, 32-bit clock rollover, and configured -bounds. +gates, group-to-group lifecycle rejection, resets, 32-bit clock rollover, and +configured bounds. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index ee6e8698..ba79613e 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -104,7 +104,7 @@ A finite gate advances the execution's local clock but suppresses its event firings. Audio already sounding is not stopped, and the first event after the gate occurs at its original phase. A gate can itself be placed in another group as a leaf control; start, publish and clear are rejected while a group -payload is firing, preventing recursive nesting. +payload is firing. A group therefore never launches or edits another group. ## Scheduling a launch at the root diff --git a/docs/synth.md b/docs/synth.md index 0b22eb26..d3159403 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -239,7 +239,7 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon `tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. -If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](docs/api.md) to any function. This will be called at every tick with the current tick number as an argument. +If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. ### Reusable sequencer groups @@ -250,8 +250,11 @@ retain their original behavior. Groups are controlled through the single `sequence_control` parameter; they can run once, a fixed number of times, or continuously, and start/stop can be quantized to AMY's tick clock. -See [Sequencer groups](sequencer-groups.md) for the wire format, lifecycle, -examples and implementation guarantees. +See [Sequencer groups](sequencer-groups.md) for the concise wire format and +lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-groups-abstractions.md), +[musical use cases](sequencer-groups-musical-use-cases.md), and a +[step-by-step wire and Python example](sequencer-groups-howto.md). ## Core oscillators @@ -489,4 +492,3 @@ amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 13e9a3d5..8b89201e 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -271,13 +271,13 @@ static void test_quantized_stop_precedes_boundary_event(void) { CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); } -static void test_group_control_cannot_recurse(void) { - printf("a group cannot launch a third sequencer level\n"); +static void test_group_to_group_control_is_rejected(void) { + printf("a group payload cannot launch another group\n"); sequencer_reset(); clear_group(7); clear_group(8); clear_marks(); - amy_add_message("H0,4,0,8zPgrandchildZ"); + amy_add_message("H0,4,0,8zPforbiddenZ"); amy_add_message("zQ8,3,4Z"); amy_add_message("H0,4,0,7zQ8,1,1,0Z"); amy_add_message("zQ7,3,4Z"); @@ -285,7 +285,7 @@ static void test_group_control_cannot_recurse(void) { uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("zQ7,1,1,4Z"); clock_to(start + 4); - CHECK(!marks_named("grandchild"), "nested group launch is rejected"); + CHECK(!marks_named("forbidden"), "group-to-group launch is rejected"); } static void test_resets_keep_definitions_only(void) { @@ -386,7 +386,7 @@ int main(void) { test_c_event_uses_fourth_ticks_field(); test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); - test_group_control_cannot_recurse(); + test_group_to_group_control_is_rejected(); test_resets_keep_definitions_only(); test_group_start_crosses_clock_rollover(); test_configured_bounds(); From c3ebcaefbc4bea5a6631e568dca307e5c483e70d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:38:57 +0200 Subject: [PATCH 019/112] Harden sequencer group validation and diagnostics --- src/parse.c | 4 +- src/sequencer.c | 118 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/parse.c b/src/parse.c index 25c0cef5..8c8b94f8 100644 --- a/src/parse.c +++ b/src/parse.c @@ -664,7 +664,9 @@ uint16_t amy_parse_transfer_layer_message(char *message) { uint32_t values[5] = {0, 0, 0, 0, 0}; int count = parse_list_uint32_t(message, values, 5, 0); if (count < 2) { - fprintf(stderr, "sequence_control needs at least group and action\n"); + fprintf(stderr, + "invalid sequence_control: expected " + "zQgroup,action[,value,quantize,execution_tag]\n"); } else { sequencer_group_control(values[0], values[1], values[2], values[3], values[4], count >= 5); diff --git a/src/sequencer.c b/src/sequencer.c index 1aa02cc1..da7c4863 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -89,8 +89,16 @@ static sequence_group_execution_t *group_executions = NULL; static uint32_t max_sequence_groups = 0; static uint32_t max_sequence_group_tags = 0; static uint32_t max_sequence_group_executions = 0; +static size_t sequence_group_event_bytes = 0; static volatile bool group_wire_firing = false; +static bool checked_array_size(uint32_t count, size_t element_size, + size_t *bytes) { + if (count > SIZE_MAX / element_size) return false; + *bytes = (size_t)count * element_size; + return true; +} + static void group_definition_release(sequence_group_definition_t *definition) { if (definition == NULL || definition->refs == 0) return; definition->refs--; @@ -107,14 +115,12 @@ static sequence_group_definition_t *group_definition_new(void) { amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; definition->events = (sequence_group_event_t *)malloc_caps( - sizeof(sequence_group_event_t) * max_sequence_group_tags, - amy_global.config.ram_caps_synth); + sequence_group_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); return NULL; } - memset(definition->events, 0, - sizeof(sequence_group_event_t) * max_sequence_group_tags); + memset(definition->events, 0, sequence_group_event_bytes); definition->length_ticks = 0; definition->refs = 1; return definition; @@ -177,6 +183,7 @@ static void sequence_groups_deinit(void) { max_sequence_groups = 0; max_sequence_group_tags = 0; max_sequence_group_executions = 0; + sequence_group_event_bytes = 0; } static void sequence_groups_init(uint32_t groups, uint32_t tags, @@ -186,16 +193,31 @@ static void sequence_groups_init(uint32_t groups, uint32_t tags, max_sequence_group_executions = executions; group_wire_firing = false; if (groups == 0 || tags == 0 || executions == 0) return; + + size_t group_bytes = 0; + size_t execution_bytes = 0; + if (!checked_array_size(groups, sizeof(sequence_group_slot_t), &group_bytes) + || !checked_array_size(tags, sizeof(sequence_group_event_t), + &sequence_group_event_bytes) + || !checked_array_size(executions, + sizeof(sequence_group_execution_t), + &execution_bytes)) { + fprintf(stderr, + "sequencer group configuration exceeds addressable memory: " + "groups=%" PRIu32 ", event_tags=%" PRIu32 + ", executions=%" PRIu32 "\n", + groups, tags, executions); + sequence_groups_deinit(); + return; + } sequence_groups = (sequence_group_slot_t *)malloc_caps( - sizeof(sequence_group_slot_t) * groups, amy_global.config.ram_caps_synth); + group_bytes, amy_global.config.ram_caps_synth); if (sequence_groups != NULL) - memset(sequence_groups, 0, sizeof(sequence_group_slot_t) * groups); + memset(sequence_groups, 0, group_bytes); group_executions = (sequence_group_execution_t *)malloc_caps( - sizeof(sequence_group_execution_t) * executions, - amy_global.config.ram_caps_synth); + execution_bytes, amy_global.config.ram_caps_synth); if (group_executions != NULL) - memset(group_executions, 0, - sizeof(sequence_group_execution_t) * executions); + memset(group_executions, 0, execution_bytes); if (sequence_groups == NULL || group_executions == NULL) { amy_oom("sequencer groups"); sequence_groups_deinit(); @@ -417,14 +439,36 @@ static sequence_group_slot_t *group_slot(uint32_t group) { uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, uint32_t tag, uint32_t group, char *wire) { sequence_group_slot_t *slot = group_slot(group); - if (slot == NULL || tag >= max_sequence_group_tags) { - fprintf(stderr, "sequencer group/event tag out of range: group %" PRIu32 - ", tag %" PRIu32 "\n", group, tag); + if (slot == NULL) { + if (sequence_groups == NULL) + fprintf(stderr, "cannot add event to sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot add event: sequencer group %" PRIu32 + " is outside the configured range [1, %" PRIu32 "]\n", + group, max_sequence_groups); free(wire); return 0; } + if (tag >= max_sequence_group_tags) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": valid event tags are [0, %" PRIu32 "]\n", + tag, group, max_sequence_group_tags - 1); + free(wire); + return 0; + } + if (wire == NULL) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 ": wire is NULL\n", + tag, group); + return 0; + } if (wire[0] == 'H') { - fprintf(stderr, "a grouped ticks event cannot contain another ticks event\n"); + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": a grouped event cannot contain another ticks command\n", + tag, group); free(wire); return 0; } @@ -476,9 +520,11 @@ static bool group_execution_matches(const sequence_group_execution_t *execution, && execution->execution_tag == execution_tag); } -static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { +static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, + uint32_t length) { if (length == 0) { - fprintf(stderr, "a sequencer group must have a nonzero length\n"); + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": length must be greater than zero\n", group); return 0; } if (slot->staging == NULL) { @@ -491,11 +537,18 @@ static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { sequence_group_event_t *event = &slot->staging->events[i]; if (event->wire == NULL) continue; - if (event->tick >= length - || (event->period != 0 && event->tick >= event->period)) { - fprintf(stderr, "sequencer group event %" PRIu32 - " has tick %" PRIu32 " outside its period/group length\n", - i, event->tick); + if (event->tick >= length) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below group length %" PRIu32 "\n", + group, i, event->tick, length); + return 0; + } + if (event->period != 0 && event->tick >= event->period) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below its period %" PRIu32 "\n", + group, i, event->tick, event->period); return 0; } } @@ -513,21 +566,30 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, bool has_execution_tag) { sequence_group_slot_t *slot = group_slot(group); if (slot == NULL) { - fprintf(stderr, "sequencer group %" PRIu32 " is out of range\n", group); + if (sequence_groups == NULL) + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": valid groups are [1, %" PRIu32 "]\n", + group, max_sequence_groups); return 0; } if (group_wire_firing && (action == SEQUENCE_CONTROL_START || action == SEQUENCE_CONTROL_PUBLISH || action == SEQUENCE_CONTROL_CLEAR)) { - fprintf(stderr, "a sequencer group cannot launch or edit a group\n"); + fprintf(stderr, "sequencer group %" PRIu32 + " cannot perform lifecycle action %" PRIu32 + ": grouped events may only stop or gate executions\n", + group, action); return 0; } uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_PUBLISH) { - result = group_publish(slot, value); + result = group_publish(slot, group, value); } else if (action == SEQUENCE_CONTROL_CLEAR) { group_definition_release(slot->published); group_definition_release(slot->staging); @@ -546,7 +608,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, if (!execution->occupied && available == NULL) available = execution; } if (available == NULL) { - fprintf(stderr, "sequencer group execution pool is full\n"); + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": all %" PRIu32 " execution slots are occupied\n", + group, max_sequence_group_executions); } else { if (has_execution_tag) { for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { @@ -588,7 +652,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } } else { - fprintf(stderr, "unknown sequencer group action %" PRIu32 "\n", action); + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": action %" PRIu32 " is unknown; valid actions are [0, 4]\n", + group, action); } amy_release_lock(); return result; From ac8ea86a1d097567e7a779c62e5e70eb56704b8f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:44:25 +0200 Subject: [PATCH 020/112] Expand sequencer group edge-case coverage --- tests/test_sequence_groups.c | 256 ++++++++++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 6 deletions(-) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 8b89201e..fce89b19 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -55,6 +55,13 @@ static int marks_named(const char *name) { return count; } +static int marks_named_at(const char *name, uint32_t tick) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) count++; + return count; +} + static void clear_group(uint32_t group) { char wire[32]; snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); @@ -93,6 +100,27 @@ static void test_legacy_ticks_are_unchanged(void) { amy_add_message("H0,0,5Z"); } +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events keep their three-value ticks wire format\n"); + amy_event event = amy_default_event(); + event.osc = 2; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 3; + event.ticks[TICKS_PERIOD] = 8; + event.ticks[TICKS_TAG] = 7; + + char wire[MAX_MESSAGE_LEN]; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7", 6) == 0 + && strncmp(wire, "H3,8,7,", 7) != 0, + "an unset group field adds no fourth ticks value: %s", wire); + + event.ticks[TICKS_GROUP] = 2; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7,2", 8) == 0, + "a grouped C event adds exactly one ticks value: %s", wire); +} + static void test_group_local_tags_are_independent(void) { printf("event tags are local to each sequencer group\n"); sequencer_reset(); @@ -199,6 +227,49 @@ static void test_root_launches_local_zero_on_same_tick(void) { CHECK(mark_at("child", start), "root launch and group local zero coincide"); } +static void test_direct_start_begins_on_next_tick(void) { + printf("an unquantized direct start begins on the next tick\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPnext-tickZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "unquantized direct start is accepted"); + CHECK(!marks_named("next-tick"), "start does not fire synchronously"); + sequencer_midi_clock_tick(); + CHECK(mark_at("next-tick", start), "local tick zero fires on the next tick"); +} + +static void test_tagged_start_replaces_at_activation(void) { + printf("a tagged start replaces its predecessor at the activation boundary\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,2,0,2zPold-executionZ"); + amy_add_message("zQ2,3,2Z"); + uint32_t predecessor_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 41, true), + "the predecessor starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("old-execution", predecessor_start), + "the predecessor is running before replacement"); + + amy_add_message("H0,2,0,2zPnew-executionZ"); + amy_add_message("zQ2,3,2Z"); + clear_marks(); + uint32_t replacement = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 1, 4, 41, true), + "the tagged replacement is accepted"); + clock_to(replacement); + CHECK(!mark_at("old-execution", replacement), + "the predecessor does not fire at the replacement boundary"); + CHECK(marks_named_at("new-execution", replacement) == 1, + "exactly one replacement fires at the boundary"); +} + static void test_c_event_uses_fourth_ticks_field(void) { printf("the C event API defines grouped events through ticks[3]\n"); sequencer_reset(); @@ -271,21 +342,157 @@ static void test_quantized_stop_precedes_boundary_event(void) { CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); } -static void test_group_to_group_control_is_rejected(void) { - printf("a group payload cannot launch another group\n"); +static void test_tagged_gate_and_stop_are_selective(void) { + printf("execution tags make gate and stop selective\n"); + sequencer_reset(); + clear_group(3); + clear_group(4); + clear_marks(); + amy_add_message("H0,1,0,3zPsharedZ"); + amy_add_message("zQ3,3,8Z"); + amy_add_message("H0,1,0,4zPother-groupZ"); + amy_add_message("zQ4,3,8Z"); + amy_add_message("zQ3,1,0,0,101Z"); + amy_add_message("zQ3,1,0,0,102Z"); + amy_add_message("zQ4,1,0,0,101Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named_at("shared", sequencer_ticks()) == 2, + "two tagged executions of one group can overlap"); + CHECK(marks_named_at("other-group", sequencer_ticks()) == 1, + "the same execution tag is independent in another group"); + + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 2, 0, 101, true), + "a matching tagged gate is accepted"); + uint32_t gate_tick = sequencer_ticks() + 1; + clock_to(gate_tick + 2); + CHECK(marks_named_at("shared", gate_tick) == 1 + && marks_named_at("shared", gate_tick + 1) == 1, + "only the selected execution is gated"); + CHECK(marks_named_at("shared", gate_tick + 2) == 2, + "the selected execution resumes after the exact duration"); + CHECK(marks_named_at("other-group", gate_tick) == 1, + "a tagged gate does not cross group boundaries"); + + clear_marks(); + uint32_t tagged_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), + "a matching tagged stop is accepted"); + clock_to(tagged_stop_tick); + CHECK(marks_named_at("shared", tagged_stop_tick) == 1, + "only the selected execution stops"); + CHECK(!sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 999, true), + "a nonmatching execution tag reports no affected execution"); + uint32_t all_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop selects every remaining execution in the group"); + clock_to(all_stop_tick); + int remaining = marks_named_at("shared", all_stop_tick); + CHECK(remaining == 0, + "the untagged stop removed the remaining execution (got %d events)", + remaining); + CHECK(mark_at("other-group", all_stop_tick), + "the untagged stop remains scoped to its group"); + amy_add_message("zQ4,0Z"); + sequencer_midi_clock_tick(); +} + +static void test_group_lifecycle_control_is_not_recursive(void) { + printf("a group payload cannot start, publish or clear a group\n"); sequencer_reset(); clear_group(7); clear_group(8); clear_marks(); - amy_add_message("H0,4,0,8zPforbiddenZ"); + amy_add_message("H0,4,0,8zPpublished-revisionZ"); amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,8zPstaged-revisionZ"); amy_add_message("H0,4,0,7zQ8,1,1,0Z"); + amy_add_message("H0,4,1,7zQ8,3,4Z"); + amy_add_message("H0,4,2,7zQ8,4Z"); amy_add_message("zQ7,3,4Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("zQ7,1,1,4Z"); - clock_to(start + 4); - CHECK(!marks_named("forbidden"), "group-to-group launch is rejected"); + clock_to(start); + CHECK(!marks_named("published-revision") && !marks_named("staged-revision"), + "group-to-group start is rejected"); + + uint32_t old_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the target group can still be started directly"); + sequencer_midi_clock_tick(); + CHECK(mark_at("published-revision", old_revision_start), + "nested clear was rejected and the published revision remains"); + CHECK(!mark_at("staged-revision", old_revision_start), + "nested publish was rejected and staged edits remain private"); + + amy_add_message("zQ8,3,4Z"); + uint32_t new_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the newly published target group starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("staged-revision", new_revision_start), + "the rejected nested publish did not discard staged edits"); +} + +static void test_invalid_edits_are_repairable(void) { + printf("invalid definitions fail without losing staged edits\n"); + sequencer_reset(); + clear_group(5); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 0, 0, 0, false), + "zero-length publication is rejected"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, NULL), + "a NULL wire is rejected safely"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, strdup("H0zPnestedZ")), + "a second ticks command is rejected"); + + CHECK(sequencer_group_add_wire(3, 2, 0, 5, strdup("zPbad-periodZ")), + "an invalid-period edit can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= period"); + CHECK(sequencer_group_add_wire(1, 2, 0, 5, strdup("zPrepairedZ")), + "the invalid staged event can be replaced"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "the repaired definition publishes"); + + CHECK(sequencer_group_add_wire(4, 0, 1, 5, strdup("zPtoo-lateZ")), + "an out-of-length event can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= group length"); + CHECK(sequencer_group_add_wire(0, 0, 1, 5, strdup("")), + "the invalid local tag can be cleared"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication succeeds after clearing the invalid tag"); + + CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), + "an unknown lifecycle action is rejected"); + CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "reserved group zero is rejected by group control"); + CHECK(!sequencer_group_control(9, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "a group beyond the configured range is rejected"); + clear_group(6); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "start without a published definition is rejected"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_GATE, 1, 0, 0, false), + "gate with no active execution reports no affected execution"); +} + +static void test_clear_preserves_active_revision(void) { + printf("clearing storage does not invalidate an active revision\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPactive-after-clearZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the execution starts before storage is cleared"); + clear_group(6); + sequencer_midi_clock_tick(); + CHECK(mark_at("active-after-clear", start), + "an active execution retains its published revision"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "clear prevents future starts until another publication"); } static void test_resets_keep_definitions_only(void) { @@ -309,6 +516,20 @@ static void test_resets_keep_definitions_only(void) { clock_to(second); CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + sequencer_midi_clock_tick(); + amy_add_message("S4096Z"); + amy_execute_deltas(); + clear_marks(); + clock_to(sequencer_ticks() + 4); + CHECK(!marks_named("survivor"), + "the public RESET_SEQUENCER wire stops group executions"); + amy_add_message("zQ8,1,1,0Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named("survivor") == 1, + "the public RESET_SEQUENCER wire preserves definitions"); + clear_marks(); amy_add_message("zQ8,1,0,0Z"); clock_to(sequencer_ticks() + 2); @@ -365,6 +586,22 @@ static void test_configured_bounds(void) { sequencer_reset(); } +static void test_disabled_configuration(void) { + printf("zero capacities disable sequencer groups safely\n"); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequence_groups = 0; + config.max_sequence_group_tags = 0; + config.max_sequence_group_executions = 0; + amy_start(config); + CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), + "group storage rejects events while disabled"); + CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "group control rejects operations while disabled"); + amy_stop(); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -379,19 +616,26 @@ int main(void) { amy_start(config); test_legacy_ticks_are_unchanged(); + test_legacy_c_event_wire_is_unchanged(); test_group_local_tags_are_independent(); test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); + test_direct_start_begins_on_next_tick(); + test_tagged_start_replaces_at_activation(); test_c_event_uses_fourth_ticks_field(); test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); - test_group_to_group_control_is_rejected(); + test_tagged_gate_and_stop_are_selective(); + test_group_lifecycle_control_is_not_recursive(); + test_invalid_edits_are_repairable(); + test_clear_preserves_active_revision(); test_resets_keep_definitions_only(); test_group_start_crosses_clock_rollover(); test_configured_bounds(); amy_stop(); + test_disabled_configuration(); if (failures) { printf("\n%d check(s) FAILED\n", failures); return 1; From 6c9829c632dfb76e56ac7d73e5d31ab5f5a9e0e4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:44:25 +0200 Subject: [PATCH 021/112] Unify sequencer group tick processing --- src/sequencer.c | 63 +++++++++++++------------------------------------ 1 file changed, 16 insertions(+), 47 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index da7c4863..d53a7c95 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -678,7 +678,7 @@ static void group_play_wire(const char *wire) { group_wire_firing = previous; } -static void group_process_control_events(uint32_t tick) { +static void group_process_pass(uint32_t tick, bool controls) { for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { amy_grab_lock(); sequence_group_execution_t *execution = &group_executions[i]; @@ -695,57 +695,26 @@ static void group_process_control_events(uint32_t tick) { amy_release_lock(); continue; } - definition->refs++; - uint32_t local_tick = elapsed % definition->length_ticks; - amy_release_lock(); - - for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { - sequence_group_event_t *event = &definition->events[tag]; - if (group_event_is_control(event) && group_event_hits(event, local_tick)) - group_play_wire(event->wire); - } - - amy_grab_lock(); - group_definition_release(definition); - amy_release_lock(); - } -} - -static void group_process_events(uint32_t tick) { - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { - amy_grab_lock(); - sequence_group_execution_t *execution = &group_executions[i]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { - amy_release_lock(); - continue; - } - uint32_t elapsed = tick - execution->start_tick; - sequence_group_definition_t *definition = execution->definition; - if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) - || (execution->repeats != 0 - && elapsed / definition->length_ticks >= execution->repeats)) { - group_execution_release(execution); - amy_release_lock(); - continue; - } - if (execution->gate_change_pending - && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { - execution->gate_change_pending = false; - execution->gated = execution->gate_duration != 0; - execution->gate_end_tick = execution->gate_change_tick - + execution->gate_duration; + if (!controls) { + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; + } + if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) + execution->gated = false; } - if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) - execution->gated = false; - bool gated = execution->gated; + bool suppress = !controls && execution->gated; definition->refs++; uint32_t local_tick = elapsed % definition->length_ticks; amy_release_lock(); - if (!gated) { + if (!suppress) { for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { sequence_group_event_t *event = &definition->events[tag]; - if (!group_event_is_control(event) + if (group_event_is_control(event) == controls && group_event_hits(event, local_tick)) group_play_wire(event->wire); } @@ -819,8 +788,8 @@ static void sequencer_process_tick(void) { } // Controls embedded in a group are leaf operations (stop/gate only) and // take effect before any ordinary group event on the same tick. - group_process_control_events(amy_global.sequencer_tick_count); - group_process_events(amy_global.sequencer_tick_count); + group_process_pass(amy_global.sequencer_tick_count, true); + group_process_pass(amy_global.sequencer_tick_count, false); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); From 2c1c052695702fa7b9c62c2db87bbfffd04ff0c1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:46:45 +0200 Subject: [PATCH 022/112] Cover sequencer group control boundaries --- tests/test_sequence_groups.c | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index fce89b19..bb5454a6 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -129,6 +129,7 @@ static void test_group_local_tags_are_independent(void) { clear_marks(); amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); + amy_add_message("H0,4,0zProot-tag-zeroZ"); amy_add_message("zQ6,3,4Z"); amy_add_message("zQ7,3,4Z"); @@ -140,6 +141,9 @@ static void test_group_local_tags_are_independent(void) { "group 6 owns its event tag zero"); CHECK(mark_at("group-seven-tag-zero", start), "group 7 independently owns event tag zero"); + CHECK(mark_at("root-tag-zero", start), + "root tag zero remains independent of every group-local tag zero"); + amy_add_message("H0,0,0Z"); } static void test_one_n_and_infinite_repeats(void) { @@ -374,6 +378,20 @@ static void test_tagged_gate_and_stop_are_selective(void) { CHECK(marks_named_at("other-group", gate_tick) == 1, "a tagged gate does not cross group boundaries"); + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 100, 0, 101, true), + "a longer tagged gate is accepted"); + uint32_t long_gate_tick = sequencer_ticks() + 1; + clock_to(long_gate_tick); + CHECK(marks_named_at("shared", long_gate_tick) == 1, + "a positive gate duration suppresses the selected execution"); + uint32_t ungate_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 0, 0, 101, true), + "gate duration zero requests an early ungate"); + clock_to(ungate_tick); + CHECK(marks_named_at("shared", ungate_tick) == 2, + "gate duration zero resumes the selected execution on its phase"); + clear_marks(); uint32_t tagged_stop_tick = sequencer_ticks() + 1; CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), @@ -435,6 +453,27 @@ static void test_group_lifecycle_control_is_not_recursive(void) { "the rejected nested publish did not discard staged edits"); } +static void test_group_stop_control_is_a_supported_leaf(void) { + printf("a group payload may stop an existing group execution\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,1,0,8zPmust-be-stoppedZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,7zQ8,0,0,0,55Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t boundary = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 0, 4, 55, true), + "the target execution is queued"); + CHECK(sequencer_group_control(7, SEQUENCE_CONTROL_START, 1, 4, 0, false), + "the stopping group is queued on the same boundary"); + clock_to(boundary); + CHECK(!mark_at("must-be-stopped", boundary), + "the leaf stop takes effect before ordinary events on that tick"); +} + static void test_invalid_edits_are_repairable(void) { printf("invalid definitions fail without losing staged edits\n"); sequencer_reset(); @@ -583,6 +622,14 @@ static void test_configured_bounds(void) { CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, 8, true), "one execution beyond the configured pool is rejected"); + clear_marks(); + uint32_t start = next_boundary(sequencer_ticks(), 64); + clock_to(start); + CHECK(marks_named_at("last", start) == 8, + "a rejected ninth start does not disturb the eight queued executions"); + clock_to(start + 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "completed one-shots return their execution slots to the pool"); sequencer_reset(); } @@ -628,6 +675,7 @@ int main(void) { test_quantized_stop_precedes_boundary_event(); test_tagged_gate_and_stop_are_selective(); test_group_lifecycle_control_is_not_recursive(); + test_group_stop_control_is_a_supported_leaf(); test_invalid_edits_are_repairable(); test_clear_preserves_active_revision(); test_resets_keep_definitions_only(); From 2aa432308592816de68cc4510e34c258f7b21567 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:53:10 +0200 Subject: [PATCH 023/112] Clarify sequencer group contracts and errors --- docs/sequencer-groups-abstractions.md | 13 ++++++++----- docs/sequencer-groups.md | 8 +++++--- src/sequencer.c | 18 +++++++++++++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md index 96ad6d4b..f3a3026a 100644 --- a/docs/sequencer-groups-abstractions.md +++ b/docs/sequencer-groups-abstractions.md @@ -143,8 +143,11 @@ but preserve published group definitions. Full AMY shutdown releases the definitions. The native group regression test exercises legacy root behavior and group -behavior in the same process. It covers root compatibility, local tag -namespaces, one/N/infinite repetition, quantization, atomic publication, -immutable active revisions, same-tick root launches, finite phase-preserving -gates, group-to-group lifecycle rejection, resets, 32-bit clock rollover, and -configured bounds. +behavior in the same process. It covers the unchanged three-value C and wire +formats, root/group namespace isolation, one/N/infinite repetition, +quantization, tagged replacement, selective stop and gate, early ungate, +atomic publication, repair after rejected publication, immutable active +revisions, same-tick root launches, non-recursive lifecycle controls, allowed +leaf controls, resets, 32-bit clock rollover, disabled configuration, and +configured storage and execution bounds. The existing AMY C and audio suites +remain the broader backward-compatibility tests. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index ba79613e..8708659b 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -133,6 +133,8 @@ them. Storage and work are bounded by `max_sequence_groups`, `max_sequence_group_tags` and `max_sequence_group_executions` in `amy_config_t`. Group event arrays and wire payloads are allocated only for -definitions that are authored. The tick path scans only the fixed active -execution pool; inactive stored groups are not visited, and starting an -execution does not allocate memory. +definitions that are authored. Setting any of the three capacities to zero +disables sequencer groups. The tick path scans only the fixed execution pool, +not all stored groups, so a larger definition catalogue does not make inactive +definitions part of per-tick work. Starting an execution does not allocate +memory. diff --git a/src/sequencer.c b/src/sequencer.c index d53a7c95..96a3522e 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -520,6 +520,13 @@ static bool group_execution_matches(const sequence_group_execution_t *execution, && execution->execution_tag == execution_tag); } +static const char *group_action_name(uint32_t action) { + if (action == SEQUENCE_CONTROL_START) return "start"; + if (action == SEQUENCE_CONTROL_PUBLISH) return "publish"; + if (action == SEQUENCE_CONTROL_CLEAR) return "clear"; + return "unknown"; +} + static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, uint32_t length) { if (length == 0) { @@ -580,9 +587,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, || action == SEQUENCE_CONTROL_PUBLISH || action == SEQUENCE_CONTROL_CLEAR)) { fprintf(stderr, "sequencer group %" PRIu32 - " cannot perform lifecycle action %" PRIu32 + " cannot perform lifecycle action %s (%" PRIu32 ")" ": grouped events may only stop or gate executions\n", - group, action); + group, group_action_name(action), action); return 0; } @@ -598,8 +605,8 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } else if (action == SEQUENCE_CONTROL_START) { if (slot->published == NULL || slot->published->length_ticks == 0) { - fprintf(stderr, "sequencer group %" PRIu32 " has no published definition\n", - group); + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": no definition has been published\n", group); } else { uint32_t start_tick = group_control_tick(quantize); sequence_group_execution_t *available = NULL; @@ -653,7 +660,8 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, } } else { fprintf(stderr, "cannot control sequencer group %" PRIu32 - ": action %" PRIu32 " is unknown; valid actions are [0, 4]\n", + ": action %" PRIu32 " is unknown; valid actions are " + "stop=0, start=1, gate=2, publish=3, clear=4\n", group, action); } amy_release_lock(); From b791eb2028eb335d89924566ef1b1c94952161e2 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:57:10 +0200 Subject: [PATCH 024/112] Cover sequencer group republish and tag edges --- tests/test_sequence_groups.c | 67 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index bb5454a6..0f078ba0 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -415,6 +415,28 @@ static void test_tagged_gate_and_stop_are_selective(void) { sequencer_midi_clock_tick(); } +static void test_tagged_control_does_not_select_untagged_execution(void) { + printf("tagged controls do not select untagged executions\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,1,0,2zPuntaggedZ"); + amy_add_message("zQ2,3,1Z"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 0, false), + "an untagged execution starts"); + sequencer_midi_clock_tick(); + + clear_marks(); + CHECK(!sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 77, true), + "a tagged stop reports no match for an untagged execution"); + sequencer_midi_clock_tick(); + CHECK(marks_named("untagged") == 2, + "the unmatched tagged stop leaves the untagged execution running"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop still selects the execution"); + sequencer_midi_clock_tick(); +} + static void test_group_lifecycle_control_is_not_recursive(void) { printf("a group payload cannot start, publish or clear a group\n"); sequencer_reset(); @@ -503,6 +525,17 @@ static void test_invalid_edits_are_repairable(void) { CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), "publication succeeds after clearing the invalid tag"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publishing without new edits clones the published definition"); + clear_marks(); + uint32_t cloned_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the cloned definition can be started"); + sequencer_midi_clock_tick(); + CHECK(mark_at("repaired", cloned_start + 1), + "the cloned definition retains its event wire"); + sequencer_reset(); + CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), "an unknown lifecycle action is rejected"); CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), @@ -635,18 +668,27 @@ static void test_configured_bounds(void) { static void test_disabled_configuration(void) { printf("zero capacities disable sequencer groups safely\n"); - amy_config_t config = amy_default_config(); - config.features.startup_bleep = 0; - config.audio = AMY_AUDIO_IS_NONE; - config.max_sequence_groups = 0; - config.max_sequence_group_tags = 0; - config.max_sequence_group_executions = 0; - amy_start(config); - CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), - "group storage rejects events while disabled"); - CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "group control rejects operations while disabled"); - amy_stop(); + const uint32_t capacities[][3] = { + {0, 8, 8}, + {8, 0, 8}, + {8, 8, 0}, + }; + for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequence_groups = capacities[i][0]; + config.max_sequence_group_tags = capacities[i][1]; + config.max_sequence_group_executions = capacities[i][2]; + amy_start(config); + CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), + "group storage is disabled when capacity set %zu contains zero", + i + 1); + CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "group control is disabled when capacity set %zu contains zero", + i + 1); + amy_stop(); + } } // examples.c calls this; the platform normally provides it. @@ -674,6 +716,7 @@ int main(void) { test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); test_tagged_gate_and_stop_are_selective(); + test_tagged_control_does_not_select_untagged_execution(); test_group_lifecycle_control_is_not_recursive(); test_group_stop_control_is_a_supported_leaf(); test_invalid_edits_are_repairable(); From 36372042a6e7102906ff209bbde183ba914e7df6 Mon Sep 17 00:00:00 2001 From: Brian Whitman Date: Fri, 4 Sep 2026 10:28:37 -0400 Subject: [PATCH 025/112] AMY_BLOCK_SIZE can be chosen at compile time amy.h defined the block size unconditionally -- 256, or 128 under AMY_DAISY -- so a host could not ask for anything else without editing the header. tulip5 wants 128 and 64 on the ESP32-P4 for lower latency (the P4's CPU is thought to have the headroom; the S3's does not), and its only route was a patched shadow of amy/src. Now `-DAMY_BLOCK_SIZE=128` is honoured: the default sits behind `#ifndef`, BLOCK_SIZE_BITS is derived from it, and a size that is not a power of two from 32 to 1024 is an #error. Built with nothing passed, the result is byte-identical to before -- 256, and 128 on Daisy. BLOCK_SIZE_BITS is derived as an expression rather than one literal per size on purpose: `make amy/constants.py` greps every numeric #define out of this header and the last one would win, so a ladder of literals would have reported BLOCK_SIZE_BITS=10 to Python and the generated JS. The expression is skipped by that grep and the two literals above it still report the default. Co-Authored-By: Claude Fable 5.1 --- src/amy.h | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/amy.h b/src/amy.h index d5b2dd38..7122cd59 100644 --- a/src/amy.h +++ b/src/amy.h @@ -72,7 +72,11 @@ extern const uint32_t pcm_wavetable_len; -// Set block size and SR. We try for 256/44100, but some platforms don't let us: +// Set block size and SR. We try for 256/44100, but some platforms don't let us. +// A host may pick the block at COMPILE time (-DAMY_BLOCK_SIZE=128, any power of +// two from 32 to 1024); BLOCK_SIZE_BITS then follows it. Left alone, it is +// 256 (128 on Daisy), exactly as before. +#ifndef AMY_BLOCK_SIZE #ifdef AMY_DAISY #define AMY_BLOCK_SIZE 128 #define BLOCK_SIZE_BITS 7 // log2 of BLOCK_SIZE @@ -80,6 +84,18 @@ extern const uint32_t pcm_wavetable_len; #define AMY_BLOCK_SIZE 256 #define BLOCK_SIZE_BITS 8 // log2 of BLOCK_SIZE #endif +#endif +#ifndef BLOCK_SIZE_BITS +#if (AMY_BLOCK_SIZE & (AMY_BLOCK_SIZE - 1)) || AMY_BLOCK_SIZE < 32 || AMY_BLOCK_SIZE > 1024 +#error "AMY_BLOCK_SIZE must be a power of two from 32 to 1024" +#endif +// An expression rather than one literal per size, so `make amy/constants.py` +// (which greps every numeric #define out of this file) keeps reporting the +// default above rather than whichever literal came last. +#define BLOCK_SIZE_BITS (AMY_BLOCK_SIZE == 32 ? 5 : AMY_BLOCK_SIZE == 64 ? 6 : \ + AMY_BLOCK_SIZE == 128 ? 7 : AMY_BLOCK_SIZE == 256 ? 8 : \ + AMY_BLOCK_SIZE == 512 ? 9 : 10) // log2 of AMY_BLOCK_SIZE +#endif #ifdef AMY_DAISY #define AMY_SAMPLE_RATE 48000 From 90b62c6f1395cd7923b013342aaf09bc3231a63f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:49:57 +0200 Subject: [PATCH 026/112] Simplify reusable sequences around sequencer tags --- Makefile | 3 +- amy/__init__.py | 66 ++- amy/constants.py | 3 - godot/amy.gd | 152 +++---- src/amy.c | 7 +- src/amy.h | 11 +- src/amy_api.generated.js | 155 +++---- src/api.c | 8 +- src/parse.c | 91 ++-- src/patches.c | 6 +- src/pyamy.c | 21 +- src/sequencer.c | 574 ++++++++++++------------ src/sequencer.h | 30 +- tests/test_sequence_api.py | 59 +++ tests/test_sequence_groups.c | 736 ------------------------------- tests/test_sequencer_sequences.c | 377 ++++++++++++++++ 16 files changed, 1028 insertions(+), 1271 deletions(-) create mode 100644 tests/test_sequence_api.py delete mode 100644 tests/test_sequence_groups.c create mode 100644 tests/test_sequencer_sequences.c diff --git a/Makefile b/Makefile index 2e465c0f..47cac0b5 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,7 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ - tests/test_sequence_groups \ + tests/test_sequencer_sequences \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ @@ -145,6 +145,7 @@ amy-module: amy-example ${EXTRA_PIP_ENV} ${PYTHON} -m pip install -r requirements.txt; touch src/amy.c; ${EXTRA_PIP_ENV} ${PYTHON} -m pip install . --force-reinstall --no-deps; cd .. test: amy-module + ${PYTHON} tests/test_sequence_api.py ${PYTHON} -m amy.test qtest: amy-module diff --git a/amy/__init__.py b/amy/__init__.py index 7cb08362..7239cca7 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -241,7 +241,10 @@ def str_of_int(arg): _KW_MAP_LIST = [ # Order matters because patch_string must come last. - # 'ticks' must come first: 'H' is recognized only as first char in wire message. + # Sequence/ticks headers must come first: 'H' is only recognized as the + # first wire character. sequence_control follows a ticks/sequence_event + # header when it is used as that scheduled event's payload. + ('sequence_event', 'HAL'), ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -253,8 +256,9 @@ def str_of_int(arg): ('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'), ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), + ('sequence_reset', 'HRI'), + ('sequence_control', 'HCL'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), - ('sequence_control', 'zQL'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), ('synth_level', 'iVF'), @@ -297,6 +301,15 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') + outer_sequence_keys = {'sequence_event', 'ticks', 'sequence_reset'} & kwargs.keys() + if len(outer_sequence_keys) > 1: + raise ValueError('Use only one of sequence_event, sequence_reset, or ticks in a message.') + if 'sequence_reset' in kwargs and len(kwargs) != 1: + raise ValueError('sequence_reset must be sent as a standalone message.') + if ('sequence_control' in kwargs and len(kwargs) != 1 + and not ({'sequence_event', 'ticks'} & kwargs.keys())): + raise ValueError('sequence_control can only be combined with ticks or sequence_event.') + # Validity check all the passed args. prioritized_keys = [] for key, arg in kwargs.items(): @@ -374,6 +387,55 @@ def send(**kwargs): send_raw(m) +def _sequence_ticks(value): + """Normalize a stored-sequence event's local (tick, period) tuple.""" + if isinstance(value, str): + values = value.split(',') + elif isinstance(value, (list, tuple)): + values = list(value) + else: + values = [value] + if not 1 <= len(values) <= 2: + raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') + tick = int(values[0]) + period = int(values[1]) if len(values) == 2 else 0 + if tick < 0 or period < 0: + raise ValueError('Stored sequence tick and period must be non-negative.') + if period and tick >= period: + raise ValueError('A stored sequence tick must be below its nonzero period.') + return tick, period + + +def define_sequence(tag, events): + """Replace one reusable tagged sequence with ordinary AMY events. + + Each event is a mapping accepted by :func:`message` and must contain a + local ``ticks`` value with one or two fields. All event messages are + validated before the reset is sent, then the definition is written as a + per-tag reset followed by explicit cumulative event appends. Executions + which already started keep their previous immutable definition. + """ + sequence_tag = int(tag) + if sequence_tag < 0: + raise ValueError('Sequence tag must be non-negative.') + event_messages = [] + for event in events: + values = dict(event) + if 'ticks' not in values: + raise ValueError('Every stored sequence event needs a ticks value.') + if {'sequence_event', 'sequence_reset'} & values.keys(): + raise ValueError('Stored sequence events cannot contain sequence authoring commands.') + tick, period = _sequence_ticks(values.pop('ticks')) + if not values: + raise ValueError('Every stored sequence event needs an AMY payload.') + event_messages.append(message( + sequence_event=(sequence_tag, tick, period), **values)) + + send_raw(message(sequence_reset=sequence_tag)) + for event_message in event_messages: + send_raw(event_message) + + # Plots a time domain and spectra of audio def show(data): import matplotlib.pyplot as plt diff --git a/amy/constants.py b/amy/constants.py index 4820ffbe..9f85e94d 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,12 +124,9 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 -TICKS_GROUP=3 SEQUENCE_CONTROL_STOP=0 SEQUENCE_CONTROL_START=1 SEQUENCE_CONTROL_GATE=2 -SEQUENCE_CONTROL_PUBLISH=3 -SEQUENCE_CONTROL_CLEAR=4 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/godot/amy.gd b/godot/amy.gd index 435e3693..618d6cb4 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -292,6 +292,7 @@ func _format_ctrl(val: Variant) -> String: # ============================================================ # BEGIN GENERATED - scripts/gen_amy_gd_api.py var _KW_MAP: Dictionary = { + "sequence_event": ["HA", "L"], "ticks": ["H", "L"], "osc": ["v", "I"], "wave": ["w", "I"], @@ -339,11 +340,12 @@ var _KW_MAP: Dictionary = { "reverb": ["h", "L"], "echo": ["M", "L"], "patch": ["K", "I"], + "sequence_reset": ["HR", "I"], + "sequence_control": ["HC", "L"], "external_channel": ["W", "I"], "portamento": ["m", "I"], "tempo": ["j", "F"], "sequencer_run": ["zY", "I"], - "sequence_control": ["zQ", "L"], "external_midi_sync": ["zC", "I"], "synth": ["i", "I"], "pedal": ["ip", "I"], @@ -368,79 +370,81 @@ var _KW_MAP: Dictionary = { } var _KW_PRIORITY: Dictionary = { - "ticks": 0, - "osc": 1, - "wave": 2, - "note": 3, - "vel": 4, - "amp": 5, - "freq": 6, - "duty": 7, - "feedback": 8, - "reset": 9, - "phase": 10, - "sample_offset": 11, - "fit": 12, - "fit_search": 13, - "pan": 14, - "client": 15, - "volume": 16, - "pitch_bend": 17, - "filter_freq": 18, - "resonance": 19, - "bp0": 20, - "bp1": 21, - "eg0": 22, - "eg1": 23, - "eg0_type": 24, - "eg1_type": 25, - "debug": 26, - "chained_osc": 27, - "mod_source": 28, - "eq": 29, - "filter_type": 30, - "ratio": 31, - "latency_ms": 32, - "dist_clip": 33, - "dist_fold": 34, - "dist_crush": 35, - "dist_drive": 36, - "dist_mix": 37, - "algo_source": 38, - "load_sample": 39, - "transfer_file": 40, - "disk_sample": 41, - "algorithm": 42, - "chorus": 43, - "reverb": 44, - "echo": 45, - "patch": 46, - "external_channel": 47, - "portamento": 48, - "tempo": 49, - "sequencer_run": 50, - "sequence_control": 51, - "external_midi_sync": 52, - "synth": 53, - "pedal": 54, - "synth_flags": 55, - "num_voices": 56, - "oscs_per_voice": 57, - "synth_level": 58, - "to_synth": 59, - "grab_midi_notes": 60, - "note_source_channel": 61, - "synth_delay": 62, - "preset": 63, - "num_partials": 64, - "start_sample": 65, - "stop_sample": 66, - "bus": 67, - "mode": 68, - "midi_cc": 69, - "midi_note_cmd": 70, - "cv_trigger": 71, - "patch_string": 72, + "sequence_event": 0, + "ticks": 1, + "osc": 2, + "wave": 3, + "note": 4, + "vel": 5, + "amp": 6, + "freq": 7, + "duty": 8, + "feedback": 9, + "reset": 10, + "phase": 11, + "sample_offset": 12, + "fit": 13, + "fit_search": 14, + "pan": 15, + "client": 16, + "volume": 17, + "pitch_bend": 18, + "filter_freq": 19, + "resonance": 20, + "bp0": 21, + "bp1": 22, + "eg0": 23, + "eg1": 24, + "eg0_type": 25, + "eg1_type": 26, + "debug": 27, + "chained_osc": 28, + "mod_source": 29, + "eq": 30, + "filter_type": 31, + "ratio": 32, + "latency_ms": 33, + "dist_clip": 34, + "dist_fold": 35, + "dist_crush": 36, + "dist_drive": 37, + "dist_mix": 38, + "algo_source": 39, + "load_sample": 40, + "transfer_file": 41, + "disk_sample": 42, + "algorithm": 43, + "chorus": 44, + "reverb": 45, + "echo": 46, + "patch": 47, + "sequence_reset": 48, + "sequence_control": 49, + "external_channel": 50, + "portamento": 51, + "tempo": 52, + "sequencer_run": 53, + "external_midi_sync": 54, + "synth": 55, + "pedal": 56, + "synth_flags": 57, + "num_voices": 58, + "oscs_per_voice": 59, + "synth_level": 60, + "to_synth": 61, + "grab_midi_notes": 62, + "note_source_channel": 63, + "synth_delay": 64, + "preset": 65, + "num_partials": 66, + "start_sample": 67, + "stop_sample": 68, + "bus": 69, + "mode": 70, + "midi_cc": 71, + "midi_note_cmd": 72, + "cv_trigger": 73, + "patch_string": 74, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/src/amy.c b/src/amy.c index d20187da..75cc47b0 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1299,9 +1299,8 @@ int8_t oscs_init() { patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); sequencer_init(amy_global.config.max_sequencer_tags, - amy_global.config.max_sequence_groups, - amy_global.config.max_sequence_group_tags, - amy_global.config.max_sequence_group_executions); + amy_global.config.max_sequence_events, + amy_global.config.max_sequence_executions); if(pcm_samples) pcm_init(); if(AMY_HAS_CUSTOM) custom_init(); // synth and msynth are now pointers to arrays of pointers to dynamically-allocated synth structures. @@ -2479,7 +2478,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; - sequencer_group_reset_timebase(); + sequencer_sequence_reset_timebase(); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; diff --git a/src/amy.h b/src/amy.h index 37a71d03..44803f57 100644 --- a/src/amy.h +++ b/src/amy.h @@ -363,13 +363,10 @@ enum coefs{ #define TICKS_TICK 0 #define TICKS_PERIOD 1 #define TICKS_TAG 2 -#define TICKS_GROUP 3 #define SEQUENCE_CONTROL_STOP 0 #define SEQUENCE_CONTROL_START 1 #define SEQUENCE_CONTROL_GATE 2 -#define SEQUENCE_CONTROL_PUBLISH 3 -#define SEQUENCE_CONTROL_CLEAR 4 // Reset masks #define RESET_SEQUENCER 4096 @@ -674,7 +671,7 @@ typedef struct amy_event { uint16_t num_voices; uint8_t oscs_per_voice; // Used when initializing a synth without a patch. // - uint32_t ticks[4]; // tick, period, tag, optional group tag + uint32_t ticks[3]; // tick, period, tag // uint8_t note_source_channel; // .. to mark the channel of events that come from MIDI so we don't send them back out again. uint32_t reset_osc; @@ -894,10 +891,8 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; - // Group tag zero is reserved for the existing root sequencer. - uint32_t max_sequence_groups; - uint32_t max_sequence_group_tags; - uint32_t max_sequence_group_executions; + uint32_t max_sequence_events; + uint32_t max_sequence_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 230f8876..99f391cf 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -4,6 +4,7 @@ "use strict"; var AMY_KW_MAP = { + sequence_event: {wire: "HA", type: "L"}, ticks: {wire: "H", type: "L"}, osc: {wire: "v", type: "I"}, wave: {wire: "w", type: "I"}, @@ -51,11 +52,12 @@ var AMY_KW_MAP = { reverb: {wire: "h", type: "L"}, echo: {wire: "M", type: "L"}, patch: {wire: "K", type: "I"}, + sequence_reset: {wire: "HR", type: "I"}, + sequence_control: {wire: "HC", type: "L"}, external_channel: {wire: "W", type: "I"}, portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, sequencer_run: {wire: "zY", type: "I"}, - sequence_control: {wire: "zQ", type: "L"}, external_midi_sync: {wire: "zC", type: "I"}, synth: {wire: "i", type: "I"}, pedal: {wire: "ip", type: "I"}, @@ -80,79 +82,81 @@ var AMY_KW_MAP = { }; var AMY_KW_PRIORITY = { - ticks: 0, - osc: 1, - wave: 2, - note: 3, - vel: 4, - amp: 5, - freq: 6, - duty: 7, - feedback: 8, - reset: 9, - phase: 10, - sample_offset: 11, - fit: 12, - fit_search: 13, - pan: 14, - client: 15, - volume: 16, - pitch_bend: 17, - filter_freq: 18, - resonance: 19, - bp0: 20, - bp1: 21, - eg0: 22, - eg1: 23, - eg0_type: 24, - eg1_type: 25, - debug: 26, - chained_osc: 27, - mod_source: 28, - eq: 29, - filter_type: 30, - ratio: 31, - latency_ms: 32, - dist_clip: 33, - dist_fold: 34, - dist_crush: 35, - dist_drive: 36, - dist_mix: 37, - algo_source: 38, - load_sample: 39, - transfer_file: 40, - disk_sample: 41, - algorithm: 42, - chorus: 43, - reverb: 44, - echo: 45, - patch: 46, - external_channel: 47, - portamento: 48, - tempo: 49, - sequencer_run: 50, - sequence_control: 51, - external_midi_sync: 52, - synth: 53, - pedal: 54, - synth_flags: 55, - num_voices: 56, - oscs_per_voice: 57, - synth_level: 58, - to_synth: 59, - grab_midi_notes: 60, - note_source_channel: 61, - synth_delay: 62, - preset: 63, - num_partials: 64, - start_sample: 65, - stop_sample: 66, - bus: 67, - mode: 68, - midi_cc: 69, - midi_note_cmd: 70, - cv_trigger: 71, - patch_string: 72 + sequence_event: 0, + ticks: 1, + osc: 2, + wave: 3, + note: 4, + vel: 5, + amp: 6, + freq: 7, + duty: 8, + feedback: 9, + reset: 10, + phase: 11, + sample_offset: 12, + fit: 13, + fit_search: 14, + pan: 15, + client: 16, + volume: 17, + pitch_bend: 18, + filter_freq: 19, + resonance: 20, + bp0: 21, + bp1: 22, + eg0: 23, + eg1: 24, + eg0_type: 25, + eg1_type: 26, + debug: 27, + chained_osc: 28, + mod_source: 29, + eq: 30, + filter_type: 31, + ratio: 32, + latency_ms: 33, + dist_clip: 34, + dist_fold: 35, + dist_crush: 36, + dist_drive: 37, + dist_mix: 38, + algo_source: 39, + load_sample: 40, + transfer_file: 41, + disk_sample: 42, + algorithm: 43, + chorus: 44, + reverb: 45, + echo: 46, + patch: 47, + sequence_reset: 48, + sequence_control: 49, + external_channel: 50, + portamento: 51, + tempo: 52, + sequencer_run: 53, + external_midi_sync: 54, + synth: 55, + pedal: 56, + synth_flags: 57, + num_voices: 58, + oscs_per_voice: 59, + synth_level: 60, + to_synth: 61, + grab_midi_notes: 62, + note_source_channel: 63, + synth_delay: 64, + preset: 65, + num_partials: 66, + start_sample: 67, + stop_sample: 68, + bus: 69, + mode: 70, + midi_cc: 71, + midi_note_cmd: 72, + cv_trigger: 73, + patch_string: 74 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; @@ -408,12 +412,9 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, - TICKS_GROUP: 3, SEQUENCE_CONTROL_STOP: 0, SEQUENCE_CONTROL_START: 1, SEQUENCE_CONTROL_GATE: 2, - SEQUENCE_CONTROL_PUBLISH: 3, - SEQUENCE_CONTROL_CLEAR: 4, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, diff --git a/src/api.c b/src/api.c index faad006c..978112f5 100644 --- a/src/api.c +++ b/src/api.c @@ -48,9 +48,8 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; - c.max_sequence_groups = 32; - c.max_sequence_group_tags = 64; - c.max_sequence_group_executions = 32; + c.max_sequence_events = 64; + c.max_sequence_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -190,7 +189,6 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->ticks[TICKS_TICK]); AMY_UNSET(e->ticks[TICKS_PERIOD]); AMY_UNSET(e->ticks[TICKS_TAG]); - AMY_UNSET(e->ticks[TICKS_GROUP]); AMY_UNSET(e->eq_l); AMY_UNSET(e->eq_m); AMY_UNSET(e->eq_h); @@ -324,7 +322,7 @@ void amy_send_wire_from_sysex(char *message) { void amy_add_event(amy_event *e) { peek_stack("add_event"); // was amy_process_event - if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG]) || AMY_IS_SET(e->ticks[TICKS_GROUP])) { + if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG])) { // C-API ticks event: serialize it to a wire message and hand it to // the sequencer, so scheduled events have a single storage format. char *buf = (char *)malloc_caps(MAX_MESSAGE_LEN, amy_global.config.ram_caps_events); diff --git a/src/parse.c b/src/parse.c index 8c8b94f8..fdc20c68 100644 --- a/src/parse.c +++ b/src/parse.c @@ -659,20 +659,6 @@ uint16_t amy_parse_transfer_layer_message(char *message) { return total; } } - else if (cmd == 'Q') { - // zQgroup,action,value,quantize[,execution_tag] - uint32_t values[5] = {0, 0, 0, 0, 0}; - int count = parse_list_uint32_t(message, values, 5, 0); - if (count < 2) { - fprintf(stderr, - "invalid sequence_control: expected " - "zQgroup,action[,value,quantize,execution_tag]\n"); - } else { - sequencer_group_control(values[0], values[1], values[2], values[3], - values[4], count >= 5); - } - return 1; - } else if (cmd == 'Y') { // zY: sequencer transport. zY1 starts the sequencer, zY0 stops it. Lets a // host drive playback without MIDI clock sync (see external_midi_sync). @@ -724,8 +710,66 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { // is only ever honored as the first command of a message. void handle_ticks_message(char *message) { assert(message[0] == 'H'); - uint32_t ticks[4] = {0, 0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 4, 0); + if (message[1] == 'A') { + // HAsequence_tag,tick,period: explicitly append one ordinary + // event to a reusable sequence. The sequence tag is the same public + // identity space used by legacy root ticks events. + uint32_t values[3] = {0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 3, 0); + uint16_t header_len = 2 + _next_alpha(message + 2); + if (count != 3) { + fprintf(stderr, + "invalid sequence event: expected " + "HAsequence_tag,tick,period\n"); + return; + } + char *payload = message + header_len; + size_t payload_len = strlen(payload); + char *copy = (char *)malloc_caps(payload_len + 1, + amy_global.config.ram_caps_events); + if (copy == NULL) amy_oom("sequence_event"); + else { + memcpy(copy, payload, payload_len + 1); + sequencer_sequence_add_wire(values[0], values[1], values[2], copy); + } + return; + } + if (message[1] == 'C') { + // HCtag,start_or_stop[,alignment_period] + // HCtag,gate,duration[,alignment_period] + uint32_t values[4] = {0, 0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 4, 0); + if (count < 2) { + fprintf(stderr, + "invalid sequence_control: expected " + "HCtag,start_or_stop[,alignment_period] or " + "HCtag,gate,duration[,alignment_period]\n"); + } else if (values[1] == SEQUENCE_CONTROL_GATE && count < 3) { + fprintf(stderr, + "invalid sequence_control gate: duration is required\n"); + } else { + uint32_t value = values[1] == SEQUENCE_CONTROL_GATE + ? values[2] : 0; + uint32_t alignment = values[1] == SEQUENCE_CONTROL_GATE + ? values[3] : values[2]; + sequencer_sequence_control(values[0], values[1], value, alignment); + } + return; + } + if (message[1] == 'R') { + // HRtag: clear future root/stored events for this tag. Already-active + // immutable sequence executions are intentionally unaffected. + uint32_t values[1] = {0}; + int count = parse_list_uint32_t(message + 2, values, 1, 0); + if (count != 1) + fprintf(stderr, "invalid sequence reset: expected HRtag\n"); + else + sequencer_sequence_reset(values[0]); + return; + } + + uint32_t ticks[3] = {0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); uint16_t schedule_len = 1 + _next_alpha(message + 1); char *payload = message + schedule_len; uint16_t payload_len = (uint16_t)strlen(payload); @@ -734,17 +778,10 @@ void handle_ticks_message(char *message) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - if (num_vals >= 4 && ticks[TICKS_GROUP] != 0) { - // The fourth ticks value selects persistent group-local storage. - // Group zero deliberately follows the legacy root path below. - sequencer_group_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], - ticks[TICKS_TAG], ticks[TICKS_GROUP], stripped); - } else { - // A root tag is only "given" if all 3 values were present; fewer - // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); - } + // A root tag is only "given" if all 3 values were present; fewer + // than that (a 1- or 2-value ticks=) stores anonymously. + sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped); } } diff --git a/src/patches.c b/src/patches.c index 9aa7e50a..63c07b5d 100644 --- a/src/patches.c +++ b/src/patches.c @@ -330,12 +330,12 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { snprintf(s, len - (size_t)(s - s_entry), "amy_event(time=%" PRIu32 ", osc=%u, addr_osc=%d adr_syn=%d adr_bus=%d): ", e->time, (unsigned)e->osc, event_addresses_oscs(e), event_addresses_synth(e), event_addresses_bus(e)); s += strlen(s); - _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group + _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag } else { // e->time has no wire representation anymore (there's no 't' command); // it's only ever meaningful as this event's own near-term playback time. // ticks ("H") must always be the first entry in wire code if used. - _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group + _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag _EPRINT_I(osc, "osc", "v"); } _EPRINT_I(wave, "wave", "w"); @@ -540,7 +540,7 @@ bool event_addresses_oscs(amy_event *e) { _RET_TRUE_IF_SET(eg_type[0]); _RET_TRUE_IF_SET(eg_type[1]); // We don't know - _RET_TRUE_IF_SET_SEQ(ticks, 4); // tick, period, tag, optional group + _RET_TRUE_IF_SET_SEQ(ticks, 3); // tick, period, tag // //_RET_TRUE_IF_SET(status, "status"); _RET_TRUE_IF_SET(reset_osc); diff --git a/src/pyamy.c b/src/pyamy.c index cee64e20..d7b69fef 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,32 +97,23 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_sequencer_tags = (uint32_t)llv; return 0; - } else if (strcmp(key, "max_sequence_groups") == 0) { + } else if (strcmp(key, "max_sequence_events") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { - PyErr_SetString(PyExc_ValueError, "max_sequence_groups must be in range [0, 4294967295]"); + PyErr_SetString(PyExc_ValueError, "max_sequence_events must be in range [0, 4294967295]"); return -1; } - cfg->max_sequence_groups = (uint32_t)llv; + cfg->max_sequence_events = (uint32_t)llv; return 0; - } else if (strcmp(key, "max_sequence_group_tags") == 0) { + } else if (strcmp(key, "max_sequence_executions") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { - PyErr_SetString(PyExc_ValueError, "max_sequence_group_tags must be in range [0, 4294967295]"); + PyErr_SetString(PyExc_ValueError, "max_sequence_executions must be in range [0, 4294967295]"); return -1; } - cfg->max_sequence_group_tags = (uint32_t)llv; - return 0; - } else if (strcmp(key, "max_sequence_group_executions") == 0) { - llv = PyLong_AsLongLong(value); - if (PyErr_Occurred()) return -1; - if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { - PyErr_SetString(PyExc_ValueError, "max_sequence_group_executions must be in range [0, 4294967295]"); - return -1; - } - cfg->max_sequence_group_executions = (uint32_t)llv; + cfg->max_sequence_executions = (uint32_t)llv; return 0; } else if (strcmp(key, "max_voices") == 0) { llv = PyLong_AsLongLong(value); diff --git a/src/sequencer.c b/src/sequencer.c index 96a3522e..f812ef49 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,50 +47,47 @@ static volatile bool sequencer_external_clock = false; // flag makes those nested calls no-ops so a tick is never processed twice. static volatile bool wire_firing = false; -// A group definition is immutable once published. Edits are accumulated in a -// private copy and become visible together through SEQUENCE_CONTROL_PUBLISH. -// Active executions retain the published revision they started with. -typedef struct sequence_group_event_t { +// Reusable sequences use the same public tag space as legacy root events. A +// definition is copy-on-write: executions retain the exact event list they +// started with while cumulative edits become the definition for future starts. +typedef struct stored_sequence_event_t { char *wire; uint32_t tick; uint32_t period; -} sequence_group_event_t; +} stored_sequence_event_t; -typedef struct sequence_group_definition_t { - sequence_group_event_t *events; - uint32_t length_ticks; +typedef struct stored_sequence_definition_t { + stored_sequence_event_t *events; + uint32_t event_count; + uint32_t last_one_shot_tick; + bool has_periodic_event; uint32_t refs; -} sequence_group_definition_t; +} stored_sequence_definition_t; -typedef struct sequence_group_slot_t { - sequence_group_definition_t *published; - sequence_group_definition_t *staging; -} sequence_group_slot_t; +typedef struct stored_sequence_slot_t { + stored_sequence_definition_t *definition; +} stored_sequence_slot_t; -typedef struct sequence_group_execution_t { - sequence_group_definition_t *definition; - uint32_t group; +typedef struct stored_sequence_execution_t { + stored_sequence_definition_t *definition; + uint32_t tag; uint32_t start_tick; - uint32_t repeats; - uint32_t execution_tag; uint32_t stop_tick; uint32_t gate_change_tick; uint32_t gate_duration; uint32_t gate_end_tick; bool occupied; - bool has_execution_tag; bool stop_pending; bool gate_change_pending; bool gated; -} sequence_group_execution_t; +} stored_sequence_execution_t; -static sequence_group_slot_t *sequence_groups = NULL; -static sequence_group_execution_t *group_executions = NULL; -static uint32_t max_sequence_groups = 0; -static uint32_t max_sequence_group_tags = 0; -static uint32_t max_sequence_group_executions = 0; -static size_t sequence_group_event_bytes = 0; -static volatile bool group_wire_firing = false; +static stored_sequence_slot_t *stored_sequences = NULL; +static stored_sequence_execution_t *sequence_executions = NULL; +static uint32_t max_stored_sequence_events = 0; +static uint32_t max_stored_sequence_executions = 0; +static size_t stored_sequence_event_bytes = 0; +static volatile bool stored_sequence_wire_firing = false; static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { @@ -99,52 +96,57 @@ static bool checked_array_size(uint32_t count, size_t element_size, return true; } -static void group_definition_release(sequence_group_definition_t *definition) { +static void stored_sequence_definition_release( + stored_sequence_definition_t *definition) { if (definition == NULL || definition->refs == 0) return; definition->refs--; if (definition->refs != 0) return; - for (uint32_t i = 0; i < max_sequence_group_tags; ++i) + for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); free(definition); } -static sequence_group_definition_t *group_definition_new(void) { - sequence_group_definition_t *definition = - (sequence_group_definition_t *)malloc_caps(sizeof(sequence_group_definition_t), - amy_global.config.ram_caps_synth); +static stored_sequence_definition_t *stored_sequence_definition_new(void) { + stored_sequence_definition_t *definition = + (stored_sequence_definition_t *)malloc_caps( + sizeof(stored_sequence_definition_t), + amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; - definition->events = (sequence_group_event_t *)malloc_caps( - sequence_group_event_bytes, amy_global.config.ram_caps_synth); + definition->events = (stored_sequence_event_t *)malloc_caps( + stored_sequence_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); return NULL; } - memset(definition->events, 0, sequence_group_event_bytes); - definition->length_ticks = 0; + memset(definition->events, 0, stored_sequence_event_bytes); + definition->event_count = 0; + definition->last_one_shot_tick = 0; + definition->has_periodic_event = false; definition->refs = 1; return definition; } -static char *group_wire_copy(const char *wire) { +static char *stored_sequence_wire_copy(const char *wire) { size_t len = strlen(wire); char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); if (copy != NULL) memcpy(copy, wire, len + 1); return copy; } -static sequence_group_definition_t *group_definition_clone( - const sequence_group_definition_t *source) { - sequence_group_definition_t *copy = group_definition_new(); +static stored_sequence_definition_t *stored_sequence_definition_clone( + const stored_sequence_definition_t *source) { + stored_sequence_definition_t *copy = stored_sequence_definition_new(); if (copy == NULL) return NULL; if (source == NULL) return copy; - copy->length_ticks = source->length_ticks; - for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { - const sequence_group_event_t *from = &source->events[i]; - if (from->wire == NULL) continue; - copy->events[i].wire = group_wire_copy(from->wire); + copy->event_count = source->event_count; + copy->last_one_shot_tick = source->last_one_shot_tick; + copy->has_periodic_event = source->has_periodic_event; + for (uint32_t i = 0; i < source->event_count; ++i) { + const stored_sequence_event_t *from = &source->events[i]; + copy->events[i].wire = stored_sequence_wire_copy(from->wire); if (copy->events[i].wire == NULL) { - group_definition_release(copy); + stored_sequence_definition_release(copy); return NULL; } copy->events[i].tick = from->tick; @@ -153,80 +155,84 @@ static sequence_group_definition_t *group_definition_clone( return copy; } -static void group_execution_release(sequence_group_execution_t *execution) { +static void stored_sequence_execution_release( + stored_sequence_execution_t *execution) { if (!execution->occupied) return; - sequence_group_definition_t *definition = execution->definition; + stored_sequence_definition_t *definition = execution->definition; memset(execution, 0, sizeof(*execution)); - group_definition_release(definition); + stored_sequence_definition_release(definition); } -static void group_executions_reset(void) { - if (group_executions == NULL) return; - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) - group_execution_release(&group_executions[i]); +static void stored_sequence_executions_reset(void) { + if (sequence_executions == NULL) return; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_execution_release(&sequence_executions[i]); } -static void sequence_groups_deinit(void) { - group_executions_reset(); - if (sequence_groups != NULL) { - for (uint32_t i = 0; i < max_sequence_groups; ++i) { - group_definition_release(sequence_groups[i].published); - group_definition_release(sequence_groups[i].staging); - } - free(sequence_groups); - sequence_groups = NULL; +static void stored_sequences_clear_definitions(void) { + if (stored_sequences == NULL) return; + for (int32_t i = 0; i < max_sequences; ++i) { + stored_sequence_definition_release(stored_sequences[i].definition); + stored_sequences[i].definition = NULL; + } +} + +static void stored_sequences_deinit(void) { + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); + if (stored_sequences != NULL) { + free(stored_sequences); + stored_sequences = NULL; } - if (group_executions != NULL) { - free(group_executions); - group_executions = NULL; + if (sequence_executions != NULL) { + free(sequence_executions); + sequence_executions = NULL; } - max_sequence_groups = 0; - max_sequence_group_tags = 0; - max_sequence_group_executions = 0; - sequence_group_event_bytes = 0; + max_stored_sequence_events = 0; + max_stored_sequence_executions = 0; + stored_sequence_event_bytes = 0; } -static void sequence_groups_init(uint32_t groups, uint32_t tags, - uint32_t executions) { - max_sequence_groups = groups; - max_sequence_group_tags = tags; - max_sequence_group_executions = executions; - group_wire_firing = false; - if (groups == 0 || tags == 0 || executions == 0) return; +static void stored_sequences_init(uint32_t events, uint32_t executions) { + max_stored_sequence_events = events; + max_stored_sequence_executions = executions; + stored_sequence_wire_firing = false; + if (max_sequences == 0 || events == 0 || executions == 0) return; - size_t group_bytes = 0; + size_t slot_bytes = 0; size_t execution_bytes = 0; - if (!checked_array_size(groups, sizeof(sequence_group_slot_t), &group_bytes) - || !checked_array_size(tags, sizeof(sequence_group_event_t), - &sequence_group_event_bytes) + if (!checked_array_size((uint32_t)max_sequences, + sizeof(stored_sequence_slot_t), &slot_bytes) + || !checked_array_size(events, sizeof(stored_sequence_event_t), + &stored_sequence_event_bytes) || !checked_array_size(executions, - sizeof(sequence_group_execution_t), + sizeof(stored_sequence_execution_t), &execution_bytes)) { fprintf(stderr, - "sequencer group configuration exceeds addressable memory: " - "groups=%" PRIu32 ", event_tags=%" PRIu32 + "stored sequence configuration exceeds addressable memory: " + "tags=%" PRIi32 ", events=%" PRIu32 ", executions=%" PRIu32 "\n", - groups, tags, executions); - sequence_groups_deinit(); + max_sequences, events, executions); + stored_sequences_deinit(); return; } - sequence_groups = (sequence_group_slot_t *)malloc_caps( - group_bytes, amy_global.config.ram_caps_synth); - if (sequence_groups != NULL) - memset(sequence_groups, 0, group_bytes); - group_executions = (sequence_group_execution_t *)malloc_caps( + stored_sequences = (stored_sequence_slot_t *)malloc_caps( + slot_bytes, amy_global.config.ram_caps_synth); + if (stored_sequences != NULL) + memset(stored_sequences, 0, slot_bytes); + sequence_executions = (stored_sequence_execution_t *)malloc_caps( execution_bytes, amy_global.config.ram_caps_synth); - if (group_executions != NULL) - memset(group_executions, 0, execution_bytes); - if (sequence_groups == NULL || group_executions == NULL) { - amy_oom("sequencer groups"); - sequence_groups_deinit(); + if (sequence_executions != NULL) + memset(sequence_executions, 0, execution_bytes); + if (stored_sequences == NULL || sequence_executions == NULL) { + amy_oom("stored sequences"); + stored_sequences_deinit(); return; } } -void sequencer_init(int max_sequencer_tags, uint32_t groups, - uint32_t group_tags, uint32_t group_execution_count) { +void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, + uint32_t sequence_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). sequencer_running = true; @@ -244,7 +250,7 @@ void sequencer_init(int max_sequencer_tags, uint32_t groups, sequences[i].next_active = -1; } first_active = -1; - sequence_groups_init(groups, group_tags, group_execution_count); + stored_sequences_init(sequence_events, sequence_execution_count); // We are read to go. sequencer_recompute(); } @@ -262,9 +268,8 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; - // Definitions are preloadable state and deliberately survive a transport - // reset; only their active or quantized executions are discarded. - group_executions_reset(); + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); } void sequencer_deinit() { @@ -274,13 +279,13 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; - sequence_groups_deinit(); + stored_sequences_deinit(); } -void sequencer_group_reset_timebase() { +void sequencer_sequence_reset_timebase() { // Absolute activation/control ticks cannot be meaningfully rebased across - // a timebase reset. Persistent definitions remain available for relaunch. - group_executions_reset(); + // a timebase reset. Stored definitions remain available for relaunch. + stored_sequence_executions_reset(); } void sequencer_debug() { @@ -391,6 +396,13 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); + // A public tag identifies one future sequencer object. A legacy tagged + // write therefore replaces any reusable definition at the same tag; an + // execution which already retained that definition can still finish. + if (has_tag && stored_sequences != NULL) { + stored_sequence_definition_release(stored_sequences[tag].definition); + stored_sequences[tag].definition = NULL; + } // Release any existing message for this tag, even if we're just going to rewrite it. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; @@ -430,223 +442,181 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } -static sequence_group_slot_t *group_slot(uint32_t group) { - if (sequence_groups == NULL || group == 0 || group > max_sequence_groups) - return NULL; - return &sequence_groups[group - 1]; +static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { + if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; + return &stored_sequences[tag]; } -uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, - uint32_t tag, uint32_t group, char *wire) { - sequence_group_slot_t *slot = group_slot(group); +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); if (slot == NULL) { - if (sequence_groups == NULL) - fprintf(stderr, "cannot add event to sequencer group %" PRIu32 - ": sequencer groups are disabled\n", group); + if (stored_sequences == NULL) + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": stored sequences are disabled\n", tag); else - fprintf(stderr, "cannot add event: sequencer group %" PRIu32 - " is outside the configured range [1, %" PRIu32 "]\n", - group, max_sequence_groups); + fprintf(stderr, "cannot append event: sequence tag %" PRIu32 + " is outside the configured range [0, %" PRIi32 "]\n", + tag, max_sequences - 1); free(wire); return 0; } - if (tag >= max_sequence_group_tags) { - fprintf(stderr, "cannot add event tag %" PRIu32 - " to sequencer group %" PRIu32 - ": valid event tags are [0, %" PRIu32 "]\n", - tag, group, max_sequence_group_tags - 1); + if (wire == NULL || wire[0] == '\0' || wire[0] == 'Z') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": event payload is empty\n", tag); free(wire); return 0; } - if (wire == NULL) { - fprintf(stderr, "cannot add event tag %" PRIu32 - " to sequencer group %" PRIu32 ": wire is NULL\n", - tag, group); + if (wire[0] == 'H' && wire[1] != 'C') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": only H sequence-control payloads may be nested\n", tag); + free(wire); return 0; } - if (wire[0] == 'H') { - fprintf(stderr, "cannot add event tag %" PRIu32 - " to sequencer group %" PRIu32 - ": a grouped event cannot contain another ticks command\n", - tag, group); + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": tick %" PRIu32 " must be below period %" PRIu32 "\n", + tag, tick, period); free(wire); return 0; } amy_grab_lock(); - if (slot->staging == NULL) { - slot->staging = group_definition_clone(slot->published); - if (slot->staging == NULL) { - amy_release_lock(); - amy_oom("sequencer group edit"); - free(wire); - return 0; - } + // Explicit cumulative sequence authoring and legacy root scheduling share + // one tag identity. Appending a stored event removes any future root event + // at that tag, while unrelated tags are untouched. + if (sequences[tag].wire != NULL) free(sequences[tag].wire); + sequences[tag].wire = NULL; + sequences[tag].tick = 0; + sequences[tag].period = 0; + active_unlink((int32_t)tag); + stored_sequence_definition_t *definition = slot->definition; + if (definition == NULL) { + definition = stored_sequence_definition_new(); + } else if (definition->refs > 1) { + definition = stored_sequence_definition_clone(definition); + } + if (definition == NULL) { + amy_release_lock(); + amy_oom("stored sequence edit"); + free(wire); + return 0; } - sequence_group_event_t *event = &slot->staging->events[tag]; - if (event->wire != NULL) free(event->wire); - event->wire = NULL; - event->tick = 0; - event->period = 0; - if (tick != 0 || period != 0) { - event->wire = wire; - event->tick = tick; - event->period = period; - wire = NULL; + if (definition != slot->definition) { + stored_sequence_definition_release(slot->definition); + slot->definition = definition; } + if (definition->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + stored_sequence_event_t *event = + &definition->events[definition->event_count++]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (period != 0) definition->has_periodic_event = true; + else if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; amy_release_lock(); - if (wire != NULL) free(wire); return 1; } -static uint32_t group_control_tick(uint32_t quantize) { +uint8_t sequencer_sequence_reset(uint32_t tag) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot reset sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot reset sequence: tag %" PRIu32 + " is outside the configured range [0, %" PRIi32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (stored_sequence_wire_firing) { + fprintf(stderr, "sequence %" PRIu32 + " cannot reset definitions from a stored sequence event\n", + tag); + return 0; + } + + amy_grab_lock(); + if (sequences[tag].wire != NULL) free(sequences[tag].wire); + sequences[tag].wire = NULL; + sequences[tag].tick = 0; + sequences[tag].period = 0; + active_unlink((int32_t)tag); + stored_sequence_definition_release(slot->definition); + slot->definition = NULL; + amy_release_lock(); + return 1; +} + +static uint32_t sequence_control_tick(uint32_t alignment_period) { // A control fired by the root sequencer participates in this tick. A // control arriving between ticks begins no earlier than the next tick. uint32_t tick = wire_firing ? amy_global.sequencer_tick_count : amy_global.sequencer_tick_count + 1; - if (quantize != 0) { - uint32_t remainder = tick % quantize; - if (remainder != 0) tick += quantize - remainder; + if (alignment_period != 0) { + uint32_t remainder = tick % alignment_period; + if (remainder != 0) tick += alignment_period - remainder; } return tick; } -static bool group_execution_matches(const sequence_group_execution_t *execution, - uint32_t group, uint32_t execution_tag, - bool has_execution_tag) { - if (!execution->occupied || execution->group != group) return false; - return !has_execution_tag - || (execution->has_execution_tag - && execution->execution_tag == execution_tag); -} - -static const char *group_action_name(uint32_t action) { - if (action == SEQUENCE_CONTROL_START) return "start"; - if (action == SEQUENCE_CONTROL_PUBLISH) return "publish"; - if (action == SEQUENCE_CONTROL_CLEAR) return "clear"; - return "unknown"; -} - -static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, - uint32_t length) { - if (length == 0) { - fprintf(stderr, "cannot publish sequencer group %" PRIu32 - ": length must be greater than zero\n", group); - return 0; - } - if (slot->staging == NULL) { - slot->staging = group_definition_clone(slot->published); - if (slot->staging == NULL) { - amy_oom("sequencer group publish"); - return 0; - } - } - for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { - sequence_group_event_t *event = &slot->staging->events[i]; - if (event->wire == NULL) continue; - if (event->tick >= length) { - fprintf(stderr, "cannot publish sequencer group %" PRIu32 - ": event tag %" PRIu32 " has tick %" PRIu32 - ", which must be below group length %" PRIu32 "\n", - group, i, event->tick, length); - return 0; - } - if (event->period != 0 && event->tick >= event->period) { - fprintf(stderr, "cannot publish sequencer group %" PRIu32 - ": event tag %" PRIu32 " has tick %" PRIu32 - ", which must be below its period %" PRIu32 "\n", - group, i, event->tick, event->period); - return 0; - } - } - slot->staging->length_ticks = length; - sequence_group_definition_t *previous = slot->published; - slot->published = slot->staging; - slot->staging = NULL; - group_definition_release(previous); - return 1; -} - -uint8_t sequencer_group_control(uint32_t group, uint32_t action, - uint32_t value, uint32_t quantize, - uint32_t execution_tag, - bool has_execution_tag) { - sequence_group_slot_t *slot = group_slot(group); +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); if (slot == NULL) { - if (sequence_groups == NULL) - fprintf(stderr, "cannot control sequencer group %" PRIu32 - ": sequencer groups are disabled\n", group); + if (stored_sequences == NULL) + fprintf(stderr, "cannot control sequence %" PRIu32 + ": stored sequences are disabled\n", tag); else - fprintf(stderr, "cannot control sequencer group %" PRIu32 - ": valid groups are [1, %" PRIu32 "]\n", - group, max_sequence_groups); - return 0; - } - if (group_wire_firing - && (action == SEQUENCE_CONTROL_START - || action == SEQUENCE_CONTROL_PUBLISH - || action == SEQUENCE_CONTROL_CLEAR)) { - fprintf(stderr, "sequencer group %" PRIu32 - " cannot perform lifecycle action %s (%" PRIu32 ")" - ": grouped events may only stop or gate executions\n", - group, group_action_name(action), action); + fprintf(stderr, "cannot control sequence %" PRIu32 + ": valid tags are [0, %" PRIi32 "]\n", + tag, max_sequences - 1); return 0; } uint8_t result = 0; amy_grab_lock(); - if (action == SEQUENCE_CONTROL_PUBLISH) { - result = group_publish(slot, group, value); - } else if (action == SEQUENCE_CONTROL_CLEAR) { - group_definition_release(slot->published); - group_definition_release(slot->staging); - slot->published = NULL; - slot->staging = NULL; - result = 1; - } else if (action == SEQUENCE_CONTROL_START) { - if (slot->published == NULL || slot->published->length_ticks == 0) { - fprintf(stderr, "cannot start sequencer group %" PRIu32 - ": no definition has been published\n", group); + if (action == SEQUENCE_CONTROL_START) { + if (slot->definition == NULL || slot->definition->event_count == 0) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": its definition is empty\n", tag); } else { - uint32_t start_tick = group_control_tick(quantize); - sequence_group_execution_t *available = NULL; - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { - sequence_group_execution_t *execution = &group_executions[i]; + uint32_t start_tick = sequence_control_tick(alignment_period); + stored_sequence_execution_t *available = NULL; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; if (!execution->occupied && available == NULL) available = execution; } if (available == NULL) { - fprintf(stderr, "cannot start sequencer group %" PRIu32 + fprintf(stderr, "cannot start sequence %" PRIu32 ": all %" PRIu32 " execution slots are occupied\n", - group, max_sequence_group_executions); + tag, max_stored_sequence_executions); } else { - if (has_execution_tag) { - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { - sequence_group_execution_t *execution = &group_executions[i]; - if (group_execution_matches(execution, group, execution_tag, true)) { - execution->stop_tick = start_tick; - execution->stop_pending = true; - } - } - } memset(available, 0, sizeof(*available)); - available->definition = slot->published; + available->definition = slot->definition; available->definition->refs++; - available->group = group; + available->tag = tag; available->start_tick = start_tick; - available->repeats = value; - available->execution_tag = execution_tag; - available->has_execution_tag = has_execution_tag; available->occupied = true; result = 1; } } } else if (action == SEQUENCE_CONTROL_STOP || action == SEQUENCE_CONTROL_GATE) { - uint32_t control_tick = group_control_tick(quantize); - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { - sequence_group_execution_t *execution = &group_executions[i]; - if (!group_execution_matches(execution, group, execution_tag, - has_execution_tag)) + uint32_t control_tick = sequence_control_tick(alignment_period); + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; + if (!execution->occupied || execution->tag != tag) continue; if (action == SEQUENCE_CONTROL_STOP) { execution->stop_tick = control_tick; @@ -659,47 +629,46 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } } else { - fprintf(stderr, "cannot control sequencer group %" PRIu32 + fprintf(stderr, "cannot control sequence %" PRIu32 ": action %" PRIu32 " is unknown; valid actions are " - "stop=0, start=1, gate=2, publish=3, clear=4\n", - group, action); + "stop=0, start=1, gate=2\n", tag, action); } amy_release_lock(); return result; } -static bool group_event_hits(const sequence_group_event_t *event, - uint32_t local_tick) { - if (event->wire == NULL) return false; +static bool stored_sequence_event_hits(const stored_sequence_event_t *event, + uint32_t local_tick) { return event->period != 0 ? local_tick % event->period == event->tick : local_tick == event->tick; } -static bool group_event_is_control(const sequence_group_event_t *event) { - return event->wire != NULL && strncmp(event->wire, "zQ", 2) == 0; +static bool stored_sequence_event_is_control( + const stored_sequence_event_t *event) { + return strncmp(event->wire, "HC", 2) == 0; } -static void group_play_wire(const char *wire) { - bool previous = group_wire_firing; - group_wire_firing = true; - amy_play_message((char *)wire); - group_wire_firing = previous; +static void stored_sequence_play_wire(const char *wire) { + bool previous = stored_sequence_wire_firing; + stored_sequence_wire_firing = true; + amy_add_message((char *)wire); + stored_sequence_wire_firing = previous; } -static void group_process_pass(uint32_t tick, bool controls) { - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { +static void stored_sequence_process_pass(uint32_t tick, bool controls) { + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { amy_grab_lock(); - sequence_group_execution_t *execution = &group_executions[i]; + stored_sequence_execution_t *execution = &sequence_executions[i]; if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { amy_release_lock(); continue; } uint32_t elapsed = tick - execution->start_tick; - sequence_group_definition_t *definition = execution->definition; + stored_sequence_definition_t *definition = execution->definition; if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) - || (execution->repeats != 0 - && elapsed / definition->length_ticks >= execution->repeats)) { - group_execution_release(execution); + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release(execution); amy_release_lock(); continue; } @@ -716,20 +685,21 @@ static void group_process_pass(uint32_t tick, bool controls) { } bool suppress = !controls && execution->gated; definition->refs++; - uint32_t local_tick = elapsed % definition->length_ticks; amy_release_lock(); if (!suppress) { - for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { - sequence_group_event_t *event = &definition->events[tag]; - if (group_event_is_control(event) == controls - && group_event_hits(event, local_tick)) - group_play_wire(event->wire); + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = + &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire); } } amy_grab_lock(); - group_definition_release(definition); + stored_sequence_definition_release(definition); amy_release_lock(); } } @@ -787,17 +757,17 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_play_message(wire); + amy_add_message(wire); free(wire); } } } tag = next; } - // Controls embedded in a group are leaf operations (stop/gate only) and - // take effect before any ordinary group event on the same tick. - group_process_pass(amy_global.sequencer_tick_count, true); - group_process_pass(amy_global.sequencer_tick_count, false); + // Nested controls take effect before ordinary stored-sequence events on + // the same tick. This lets a parent stop a child without one extra onset. + stored_sequence_process_pass(amy_global.sequencer_tick_count, true); + stored_sequence_process_pass(amy_global.sequencer_tick_count, false); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); diff --git a/src/sequencer.h b/src/sequencer.h index 82eda5ba..876fd047 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,8 +5,8 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences, uint32_t max_groups, - uint32_t max_group_tags, uint32_t max_group_executions); +void sequencer_init(int max_num_sequences, uint32_t max_sequence_events, + uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); @@ -23,18 +23,20 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) and can't be addressed // or cancelled by any tag. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); -// Store one ordinary ticks event in a group's unpublished revision. Takes -// ownership of wire. Group zero is reserved for sequencer_add_wire(). -uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, - uint32_t tag, uint32_t group, char *wire); - -// sequence_control actions. The wire/API representation is always -// [group, action, value, quantize, optional execution_tag]. -uint8_t sequencer_group_control(uint32_t group, uint32_t action, - uint32_t value, uint32_t quantize, - uint32_t execution_tag, - bool has_execution_tag); -void sequencer_group_reset_timebase(); +// Append one ordinary ticks event to the reusable sequence identified by tag. +// Takes ownership of wire. Unlike the legacy root ticks syntax, tick=period=0 +// is a valid one-shot event here. +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire); +// Clear the future root event and reusable definition at tag. Executions which +// already started retain their immutable definition and may finish. +uint8_t sequencer_sequence_reset(uint32_t tag); +// sequence_control is [tag, start_or_stop, alignment_period] or +// [tag, gate, duration, alignment_period]. +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period); +void sequencer_sequence_reset_timebase(); void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py new file mode 100644 index 00000000..144354dc --- /dev/null +++ b/tests/test_sequence_api.py @@ -0,0 +1,59 @@ +"""Small, audio-independent checks for the reusable-sequence Python API.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import amy + + +def expect_error(fragment, fn): + try: + fn() + except ValueError as exc: + assert fragment in str(exc), str(exc) + else: + raise AssertionError("expected ValueError containing %r" % fragment) + + +def main(): + assert amy.message(sequence_event=(7, 0, 0), synth=1, note=60, vel=1) \ + == "HA7,0,0n60l1i1Z" + assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ + == "HC7,1,48Z" + assert amy.message(ticks=(0, 48, 3), + sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ + == "H0,48,3HC7,1,1Z" + assert amy.message(sequence_reset=7) == "HR7Z" + assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ + == "H1,4,2n60l1i1Z" + + sent = [] + old_override = amy.override_send + amy.override_send = sent.append + try: + amy.define_sequence(7, [ + {"ticks": (0,), "synth": 1, "note": 60, "vel": 1}, + {"ticks": (3, 8), "synth": 1, "note": 60, "vel": 0}, + ]) + finally: + amy.override_send = old_override + assert sent == [ + "HR7Z", + "HA7,0,0n60l1i1Z", + "HA7,3,8n60l0i1Z", + ] + + expect_error("only one", lambda: amy.message( + ticks=(0, 4, 1), sequence_event=(2, 0, 0), synth=1)) + expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("only be combined", lambda: amy.message( + sequence_control=(2, 1), synth=1)) + expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) + expect_error("needs an AMY payload", lambda: amy.define_sequence( + 2, [{"ticks": (0,)}])) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c deleted file mode 100644 index 0f078ba0..00000000 --- a/tests/test_sequence_groups.c +++ /dev/null @@ -1,736 +0,0 @@ -// Regression and behavior tests for reusable sequencer groups. - -#include -#include -#include -#include "amy.h" -#include "sequencer.h" - -static int failures = 0; - -#define CHECK(cond, fmt, ...) do { \ - if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ - else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ -} while (0) - -typedef struct mark_t { - char name[24]; - uint32_t tick; -} mark_t; - -static mark_t marks[128]; -static int mark_count = 0; - -static void mark_hook(const char *code) { - if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; - snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); - marks[mark_count].tick = sequencer_ticks(); - mark_count++; -} - -static void clear_marks(void) { - mark_count = 0; - memset(marks, 0, sizeof(marks)); -} - -static void clock_to(uint32_t target) { - while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); -} - -static uint32_t next_boundary(uint32_t now, uint32_t quantum) { - uint32_t remainder = now % quantum; - return now + (remainder == 0 ? quantum : quantum - remainder); -} - -static int mark_at(const char *name, uint32_t tick) { - for (int i = 0; i < mark_count; ++i) - if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; - return 0; -} - -static int marks_named(const char *name) { - int count = 0; - for (int i = 0; i < mark_count; ++i) - if (!strcmp(marks[i].name, name)) count++; - return count; -} - -static int marks_named_at(const char *name, uint32_t tick) { - int count = 0; - for (int i = 0; i < mark_count; ++i) - if (!strcmp(marks[i].name, name) && marks[i].tick == tick) count++; - return count; -} - -static void clear_group(uint32_t group) { - char wire[32]; - snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); - amy_add_message(wire); -} - -static void test_legacy_ticks_are_unchanged(void) { - printf("legacy root ticks behavior remains unchanged\n"); - sequencer_reset(); - clear_marks(); - uint32_t first = next_boundary(sequencer_ticks(), 4); - - amy_add_message("H0,4,0zProotZ"); - clock_to(first + 4); - CHECK(mark_at("root", first), "root period event fires at global modulo"); - CHECK(mark_at("root", first + 4), "root period event keeps looping"); - amy_add_message("H0,0,0Z"); - - clear_marks(); - uint32_t target = sequencer_ticks() + 4; - char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); - amy_add_message(wire); - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); - amy_add_message(wire); - clock_to(target); - CHECK(!marks_named("old") && mark_at("new", target), - "legacy root tags still replace by tag"); - - clear_marks(); - uint32_t group_zero = next_boundary(sequencer_ticks(), 4); - amy_add_message("H0,4,5,0zPgroup-zero-rootZ"); - clock_to(group_zero); - CHECK(mark_at("group-zero-root", group_zero), - "an explicit group tag zero follows the legacy root path"); - amy_add_message("H0,0,5Z"); -} - -static void test_legacy_c_event_wire_is_unchanged(void) { - printf("legacy C events keep their three-value ticks wire format\n"); - amy_event event = amy_default_event(); - event.osc = 2; - event.wave = TRIANGLE; - event.ticks[TICKS_TICK] = 3; - event.ticks[TICKS_PERIOD] = 8; - event.ticks[TICKS_TAG] = 7; - - char wire[MAX_MESSAGE_LEN]; - sprint_event(&event, wire, sizeof(wire), true); - CHECK(strncmp(wire, "H3,8,7", 6) == 0 - && strncmp(wire, "H3,8,7,", 7) != 0, - "an unset group field adds no fourth ticks value: %s", wire); - - event.ticks[TICKS_GROUP] = 2; - sprint_event(&event, wire, sizeof(wire), true); - CHECK(strncmp(wire, "H3,8,7,2", 8) == 0, - "a grouped C event adds exactly one ticks value: %s", wire); -} - -static void test_group_local_tags_are_independent(void) { - printf("event tags are local to each sequencer group\n"); - sequencer_reset(); - clear_group(6); - clear_group(7); - clear_marks(); - amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); - amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); - amy_add_message("H0,4,0zProot-tag-zeroZ"); - amy_add_message("zQ6,3,4Z"); - amy_add_message("zQ7,3,4Z"); - - uint32_t start = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ6,1,1,4Z"); - amy_add_message("zQ7,1,1,4Z"); - clock_to(start); - CHECK(mark_at("group-six-tag-zero", start), - "group 6 owns its event tag zero"); - CHECK(mark_at("group-seven-tag-zero", start), - "group 7 independently owns event tag zero"); - CHECK(mark_at("root-tag-zero", start), - "root tag zero remains independent of every group-local tag zero"); - amy_add_message("H0,0,0Z"); -} - -static void test_one_n_and_infinite_repeats(void) { - printf("groups support one, N and infinite repeats\n"); - sequencer_reset(); - clear_group(1); - clear_marks(); - amy_add_message("H0,4,0,1zPzeroZ"); - amy_add_message("H2,4,1,1zPtwoZ"); - amy_add_message("zQ1,3,4Z"); - - uint32_t one = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ1,1,1,4Z"); - clock_to(one + 6); - CHECK(mark_at("zero", one) && mark_at("two", one + 2), - "one-shot uses local ticks from its activation"); - CHECK(marks_named("zero") == 1 && marks_named("two") == 1, - "one-shot does not wrap"); - - clear_marks(); - uint32_t twice = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ1,1,2,4Z"); - clock_to(twice + 10); - CHECK(mark_at("zero", twice) && mark_at("zero", twice + 4), - "repeat count two runs exactly two phrases"); - CHECK(marks_named("zero") == 2, "N-shot finishes after N phrases"); - - clear_marks(); - uint32_t loop = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ1,1,0,4,77Z"); - clock_to(loop + 8); - CHECK(mark_at("zero", loop) && mark_at("zero", loop + 8), - "repeat count zero loops indefinitely"); - amy_add_message("zQ1,0,0,0,77Z"); - clock_to(loop + 12); - CHECK(!mark_at("zero", loop + 12), "tagged stop ends the loop"); -} - -static void test_atomic_revision_lifetime(void) { - printf("published revisions are atomic and immutable while active\n"); - sequencer_reset(); - clear_group(2); - clear_marks(); - amy_add_message("H0,8,0,2zPold-zeroZ"); - amy_add_message("H6,8,1,2zPold-tailZ"); - amy_add_message("zQ2,3,8Z"); - - uint32_t old_start = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ2,1,1,4Z"); - amy_add_message("H0,8,0,2zPnew-zeroZ"); - amy_add_message("H0,0,1,2Z"); - - uint32_t still_old = old_start + 8; - char root[80]; - snprintf(root, sizeof(root), "H%" PRIu32 ",0,31zQ2,1,1,0Z", still_old); - amy_add_message(root); - clock_to(old_start + 6); - CHECK(mark_at("old-zero", old_start) && mark_at("old-tail", old_start + 6), - "an active execution finishes its original revision"); - - clock_to(still_old); - CHECK(mark_at("old-zero", still_old), - "staged edits are invisible before publication"); - amy_add_message("zQ2,3,8Z"); - uint32_t new_start = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ2,1,1,4Z"); - clock_to(new_start + 6); - CHECK(mark_at("new-zero", new_start), "future execution uses published edit"); - CHECK(!mark_at("old-tail", new_start + 6), "published local-tag clear took effect"); -} - -static void test_root_launches_local_zero_on_same_tick(void) { - printf("a root event can launch group local tick zero on the same tick\n"); - sequencer_reset(); - clear_group(3); - clear_marks(); - amy_add_message("H0,4,0,3zPchildZ"); - amy_add_message("zQ3,3,4Z"); - - uint32_t start = sequencer_ticks() + 4; - char wire[80]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,22zQ3,1,1,0Z", start); - amy_add_message(wire); - clock_to(start); - CHECK(mark_at("child", start), "root launch and group local zero coincide"); -} - -static void test_direct_start_begins_on_next_tick(void) { - printf("an unquantized direct start begins on the next tick\n"); - sequencer_reset(); - clear_group(1); - clear_marks(); - amy_add_message("H0,4,0,1zPnext-tickZ"); - amy_add_message("zQ1,3,4Z"); - - uint32_t start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "unquantized direct start is accepted"); - CHECK(!marks_named("next-tick"), "start does not fire synchronously"); - sequencer_midi_clock_tick(); - CHECK(mark_at("next-tick", start), "local tick zero fires on the next tick"); -} - -static void test_tagged_start_replaces_at_activation(void) { - printf("a tagged start replaces its predecessor at the activation boundary\n"); - sequencer_reset(); - clear_group(2); - clear_marks(); - amy_add_message("H0,2,0,2zPold-executionZ"); - amy_add_message("zQ2,3,2Z"); - uint32_t predecessor_start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 41, true), - "the predecessor starts"); - sequencer_midi_clock_tick(); - CHECK(mark_at("old-execution", predecessor_start), - "the predecessor is running before replacement"); - - amy_add_message("H0,2,0,2zPnew-executionZ"); - amy_add_message("zQ2,3,2Z"); - clear_marks(); - uint32_t replacement = next_boundary(sequencer_ticks(), 4); - CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 1, 4, 41, true), - "the tagged replacement is accepted"); - clock_to(replacement); - CHECK(!mark_at("old-execution", replacement), - "the predecessor does not fire at the replacement boundary"); - CHECK(marks_named_at("new-execution", replacement) == 1, - "exactly one replacement fires at the boundary"); -} - -static void test_c_event_uses_fourth_ticks_field(void) { - printf("the C event API defines grouped events through ticks[3]\n"); - sequencer_reset(); - clear_group(6); - amy_event event = amy_default_event(); - event.osc = 0; - event.wave = TRIANGLE; - event.ticks[TICKS_TICK] = 0; - event.ticks[TICKS_PERIOD] = 4; - event.ticks[TICKS_TAG] = 0; - event.ticks[TICKS_GROUP] = 6; - amy_add_event(&event); - CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "C-authored grouped event publishes"); - CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "C-authored group starts"); - clock_to(sequencer_ticks() + 2); - amy_execute_deltas(); - CHECK(synth[0] != NULL && synth[0]->wave == TRIANGLE, - "C-authored grouped event reaches normal playback"); -} - -static void test_quantized_gate_preserves_phase(void) { - printf("finite event gating preserves local phase\n"); - sequencer_reset(); - clear_group(4); - clear_group(5); - clear_marks(); - amy_add_message("H0,2,0,4zPbackgroundZ"); - amy_add_message("zQ4,3,4Z"); - amy_add_message("H0,4,0,5zQ4,2,4,0,81Z"); - amy_add_message("H0,4,1,5zPforegroundZ"); - amy_add_message("zQ5,3,4Z"); - - uint32_t background = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ4,1,0,4,81Z"); - clock_to(background + 2); - CHECK(mark_at("background", background) - && mark_at("background", background + 2), - "background loop initially emits on phase"); - - uint32_t foreground = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ5,1,1,4Z"); - clock_to(foreground + 4); - CHECK(mark_at("foreground", foreground), "foreground group starts normally"); - CHECK(!mark_at("background", foreground) - && !mark_at("background", foreground + 2), - "gate suppresses events for its exact duration"); - CHECK(mark_at("background", foreground + 4), - "background resumes on its unchanged phase"); - amy_add_message("zQ4,0,0,0,81Z"); - clock_to(foreground + 6); -} - -static void test_quantized_stop_precedes_boundary_event(void) { - printf("quantized stop takes effect before an event at its boundary\n"); - sequencer_reset(); - clear_group(6); - clear_marks(); - amy_add_message("H0,4,0,6zPstoppedZ"); - amy_add_message("zQ6,3,4Z"); - uint32_t start = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ6,1,0,4,91Z"); - clock_to(start); - CHECK(mark_at("stopped", start), "loop starts on its boundary"); - - uint32_t stop = next_boundary(sequencer_ticks(), 8); - amy_add_message("zQ6,0,0,8,91Z"); - clock_to(stop); - CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); -} - -static void test_tagged_gate_and_stop_are_selective(void) { - printf("execution tags make gate and stop selective\n"); - sequencer_reset(); - clear_group(3); - clear_group(4); - clear_marks(); - amy_add_message("H0,1,0,3zPsharedZ"); - amy_add_message("zQ3,3,8Z"); - amy_add_message("H0,1,0,4zPother-groupZ"); - amy_add_message("zQ4,3,8Z"); - amy_add_message("zQ3,1,0,0,101Z"); - amy_add_message("zQ3,1,0,0,102Z"); - amy_add_message("zQ4,1,0,0,101Z"); - sequencer_midi_clock_tick(); - CHECK(marks_named_at("shared", sequencer_ticks()) == 2, - "two tagged executions of one group can overlap"); - CHECK(marks_named_at("other-group", sequencer_ticks()) == 1, - "the same execution tag is independent in another group"); - - clear_marks(); - CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 2, 0, 101, true), - "a matching tagged gate is accepted"); - uint32_t gate_tick = sequencer_ticks() + 1; - clock_to(gate_tick + 2); - CHECK(marks_named_at("shared", gate_tick) == 1 - && marks_named_at("shared", gate_tick + 1) == 1, - "only the selected execution is gated"); - CHECK(marks_named_at("shared", gate_tick + 2) == 2, - "the selected execution resumes after the exact duration"); - CHECK(marks_named_at("other-group", gate_tick) == 1, - "a tagged gate does not cross group boundaries"); - - clear_marks(); - CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 100, 0, 101, true), - "a longer tagged gate is accepted"); - uint32_t long_gate_tick = sequencer_ticks() + 1; - clock_to(long_gate_tick); - CHECK(marks_named_at("shared", long_gate_tick) == 1, - "a positive gate duration suppresses the selected execution"); - uint32_t ungate_tick = sequencer_ticks() + 1; - CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 0, 0, 101, true), - "gate duration zero requests an early ungate"); - clock_to(ungate_tick); - CHECK(marks_named_at("shared", ungate_tick) == 2, - "gate duration zero resumes the selected execution on its phase"); - - clear_marks(); - uint32_t tagged_stop_tick = sequencer_ticks() + 1; - CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), - "a matching tagged stop is accepted"); - clock_to(tagged_stop_tick); - CHECK(marks_named_at("shared", tagged_stop_tick) == 1, - "only the selected execution stops"); - CHECK(!sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 999, true), - "a nonmatching execution tag reports no affected execution"); - uint32_t all_stop_tick = sequencer_ticks() + 1; - CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), - "an untagged stop selects every remaining execution in the group"); - clock_to(all_stop_tick); - int remaining = marks_named_at("shared", all_stop_tick); - CHECK(remaining == 0, - "the untagged stop removed the remaining execution (got %d events)", - remaining); - CHECK(mark_at("other-group", all_stop_tick), - "the untagged stop remains scoped to its group"); - amy_add_message("zQ4,0Z"); - sequencer_midi_clock_tick(); -} - -static void test_tagged_control_does_not_select_untagged_execution(void) { - printf("tagged controls do not select untagged executions\n"); - sequencer_reset(); - clear_group(2); - clear_marks(); - amy_add_message("H0,1,0,2zPuntaggedZ"); - amy_add_message("zQ2,3,1Z"); - CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 0, false), - "an untagged execution starts"); - sequencer_midi_clock_tick(); - - clear_marks(); - CHECK(!sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 77, true), - "a tagged stop reports no match for an untagged execution"); - sequencer_midi_clock_tick(); - CHECK(marks_named("untagged") == 2, - "the unmatched tagged stop leaves the untagged execution running"); - CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), - "an untagged stop still selects the execution"); - sequencer_midi_clock_tick(); -} - -static void test_group_lifecycle_control_is_not_recursive(void) { - printf("a group payload cannot start, publish or clear a group\n"); - sequencer_reset(); - clear_group(7); - clear_group(8); - clear_marks(); - amy_add_message("H0,4,0,8zPpublished-revisionZ"); - amy_add_message("zQ8,3,4Z"); - amy_add_message("H0,4,0,8zPstaged-revisionZ"); - amy_add_message("H0,4,0,7zQ8,1,1,0Z"); - amy_add_message("H0,4,1,7zQ8,3,4Z"); - amy_add_message("H0,4,2,7zQ8,4Z"); - amy_add_message("zQ7,3,4Z"); - - uint32_t start = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ7,1,1,4Z"); - clock_to(start); - CHECK(!marks_named("published-revision") && !marks_named("staged-revision"), - "group-to-group start is rejected"); - - uint32_t old_revision_start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "the target group can still be started directly"); - sequencer_midi_clock_tick(); - CHECK(mark_at("published-revision", old_revision_start), - "nested clear was rejected and the published revision remains"); - CHECK(!mark_at("staged-revision", old_revision_start), - "nested publish was rejected and staged edits remain private"); - - amy_add_message("zQ8,3,4Z"); - uint32_t new_revision_start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "the newly published target group starts"); - sequencer_midi_clock_tick(); - CHECK(mark_at("staged-revision", new_revision_start), - "the rejected nested publish did not discard staged edits"); -} - -static void test_group_stop_control_is_a_supported_leaf(void) { - printf("a group payload may stop an existing group execution\n"); - sequencer_reset(); - clear_group(7); - clear_group(8); - clear_marks(); - amy_add_message("H0,1,0,8zPmust-be-stoppedZ"); - amy_add_message("zQ8,3,4Z"); - amy_add_message("H0,4,0,7zQ8,0,0,0,55Z"); - amy_add_message("zQ7,3,4Z"); - - uint32_t boundary = next_boundary(sequencer_ticks(), 4); - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 0, 4, 55, true), - "the target execution is queued"); - CHECK(sequencer_group_control(7, SEQUENCE_CONTROL_START, 1, 4, 0, false), - "the stopping group is queued on the same boundary"); - clock_to(boundary); - CHECK(!mark_at("must-be-stopped", boundary), - "the leaf stop takes effect before ordinary events on that tick"); -} - -static void test_invalid_edits_are_repairable(void) { - printf("invalid definitions fail without losing staged edits\n"); - sequencer_reset(); - clear_group(5); - CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 0, 0, 0, false), - "zero-length publication is rejected"); - CHECK(!sequencer_group_add_wire(0, 1, 0, 5, NULL), - "a NULL wire is rejected safely"); - CHECK(!sequencer_group_add_wire(0, 1, 0, 5, strdup("H0zPnestedZ")), - "a second ticks command is rejected"); - - CHECK(sequencer_group_add_wire(3, 2, 0, 5, strdup("zPbad-periodZ")), - "an invalid-period edit can be staged"); - CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "publication rejects tick >= period"); - CHECK(sequencer_group_add_wire(1, 2, 0, 5, strdup("zPrepairedZ")), - "the invalid staged event can be replaced"); - CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "the repaired definition publishes"); - - CHECK(sequencer_group_add_wire(4, 0, 1, 5, strdup("zPtoo-lateZ")), - "an out-of-length event can be staged"); - CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "publication rejects tick >= group length"); - CHECK(sequencer_group_add_wire(0, 0, 1, 5, strdup("")), - "the invalid local tag can be cleared"); - CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "publication succeeds after clearing the invalid tag"); - - CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "publishing without new edits clones the published definition"); - clear_marks(); - uint32_t cloned_start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "the cloned definition can be started"); - sequencer_midi_clock_tick(); - CHECK(mark_at("repaired", cloned_start + 1), - "the cloned definition retains its event wire"); - sequencer_reset(); - - CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), - "an unknown lifecycle action is rejected"); - CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "reserved group zero is rejected by group control"); - CHECK(!sequencer_group_control(9, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "a group beyond the configured range is rejected"); - clear_group(6); - CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "start without a published definition is rejected"); - CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_GATE, 1, 0, 0, false), - "gate with no active execution reports no affected execution"); -} - -static void test_clear_preserves_active_revision(void) { - printf("clearing storage does not invalidate an active revision\n"); - sequencer_reset(); - clear_group(6); - clear_marks(); - amy_add_message("H0,4,0,6zPactive-after-clearZ"); - amy_add_message("zQ6,3,4Z"); - uint32_t start = sequencer_ticks() + 1; - CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "the execution starts before storage is cleared"); - clear_group(6); - sequencer_midi_clock_tick(); - CHECK(mark_at("active-after-clear", start), - "an active execution retains its published revision"); - CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "clear prevents future starts until another publication"); -} - -static void test_resets_keep_definitions_only(void) { - printf("sequencer and timebase resets stop executions but keep definitions\n"); - sequencer_reset(); - clear_group(8); - clear_marks(); - amy_add_message("H0,4,0,8zPsurvivorZ"); - amy_add_message("zQ8,3,4Z"); - uint32_t first = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ8,1,0,4Z"); - clock_to(first); - CHECK(mark_at("survivor", first), "definition runs before reset"); - - clear_marks(); - sequencer_reset(); - clock_to(first + 4); - CHECK(!marks_named("survivor"), "RESET_SEQUENCER stops active executions"); - uint32_t second = next_boundary(sequencer_ticks(), 4); - amy_add_message("zQ8,1,1,4Z"); - clock_to(second); - CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); - - clear_marks(); - amy_add_message("zQ8,1,0,0Z"); - sequencer_midi_clock_tick(); - amy_add_message("S4096Z"); - amy_execute_deltas(); - clear_marks(); - clock_to(sequencer_ticks() + 4); - CHECK(!marks_named("survivor"), - "the public RESET_SEQUENCER wire stops group executions"); - amy_add_message("zQ8,1,1,0Z"); - sequencer_midi_clock_tick(); - CHECK(marks_named("survivor") == 1, - "the public RESET_SEQUENCER wire preserves definitions"); - - clear_marks(); - amy_add_message("zQ8,1,0,0Z"); - clock_to(sequencer_ticks() + 2); - sequencer_group_reset_timebase(); - clear_marks(); - uint32_t after_reset = sequencer_ticks() + 4; - clock_to(after_reset); - CHECK(!marks_named("survivor"), "RESET_TIMEBASE stops active executions"); - amy_add_message("zQ8,1,1,0Z"); - clock_to(sequencer_ticks() + 2); - CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); -} - -static void test_group_start_crosses_clock_rollover(void) { - printf("group phase remains correct across the 32-bit tick rollover\n"); - sequencer_reset(); - clear_group(5); - clear_marks(); - amy_add_message("H0,4,0,5zPwrap-zeroZ"); - amy_add_message("H1,0,1,5zPwrap-oneZ"); - amy_add_message("zQ5,3,4Z"); - - amy_global.sequencer_tick_count = UINT32_MAX - 2; - amy_add_message("zQ5,1,1,4Z"); - clock_to(1); - CHECK(mark_at("wrap-zero", 0), - "quantized local tick zero fired after rollover"); - CHECK(mark_at("wrap-one", 1), - "local elapsed time advanced across rollover"); -} - -static void test_configured_bounds(void) { - printf("configured group, local-tag and execution bounds are enforced\n"); - sequencer_reset(); - clear_group(8); - char *valid = strdup("zPlastZ"); - char *bad_group = strdup("zPbad-groupZ"); - char *bad_tag = strdup("zPbad-tagZ"); - CHECK(sequencer_group_add_wire(0, 4, 7, 8, valid), - "last configured group and local tag are valid"); - CHECK(!sequencer_group_add_wire(0, 4, 0, 9, bad_group), - "first group past the configured range is rejected"); - CHECK(!sequencer_group_add_wire(0, 4, 8, 8, bad_tag), - "first local tag past the configured range is rejected"); - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), - "last group publishes"); - for (uint32_t i = 0; i < 8; ++i) - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, - i, true), - "execution slot %" PRIu32 " is available", i); - CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, - 8, true), - "one execution beyond the configured pool is rejected"); - clear_marks(); - uint32_t start = next_boundary(sequencer_ticks(), 64); - clock_to(start); - CHECK(marks_named_at("last", start) == 8, - "a rejected ninth start does not disturb the eight queued executions"); - clock_to(start + 4); - CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "completed one-shots return their execution slots to the pool"); - sequencer_reset(); -} - -static void test_disabled_configuration(void) { - printf("zero capacities disable sequencer groups safely\n"); - const uint32_t capacities[][3] = { - {0, 8, 8}, - {8, 0, 8}, - {8, 8, 0}, - }; - for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { - amy_config_t config = amy_default_config(); - config.features.startup_bleep = 0; - config.audio = AMY_AUDIO_IS_NONE; - config.max_sequence_groups = capacities[i][0]; - config.max_sequence_group_tags = capacities[i][1]; - config.max_sequence_group_executions = capacities[i][2]; - amy_start(config); - CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), - "group storage is disabled when capacity set %zu contains zero", - i + 1); - CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "group control is disabled when capacity set %zu contains zero", - i + 1); - amy_stop(); - } -} - -// examples.c calls this; the platform normally provides it. -void delay_ms(uint32_t ms) { (void)ms; } - -int main(void) { - amy_config_t config = amy_default_config(); - config.features.startup_bleep = 0; - config.audio = AMY_AUDIO_IS_NONE; - config.amy_external_exec_hook = mark_hook; - config.max_sequence_groups = 8; - config.max_sequence_group_tags = 8; - config.max_sequence_group_executions = 8; - amy_start(config); - - test_legacy_ticks_are_unchanged(); - test_legacy_c_event_wire_is_unchanged(); - test_group_local_tags_are_independent(); - test_one_n_and_infinite_repeats(); - test_atomic_revision_lifetime(); - test_root_launches_local_zero_on_same_tick(); - test_direct_start_begins_on_next_tick(); - test_tagged_start_replaces_at_activation(); - test_c_event_uses_fourth_ticks_field(); - test_quantized_gate_preserves_phase(); - test_quantized_stop_precedes_boundary_event(); - test_tagged_gate_and_stop_are_selective(); - test_tagged_control_does_not_select_untagged_execution(); - test_group_lifecycle_control_is_not_recursive(); - test_group_stop_control_is_a_supported_leaf(); - test_invalid_edits_are_repairable(); - test_clear_preserves_active_revision(); - test_resets_keep_definitions_only(); - test_group_start_crosses_clock_rollover(); - test_configured_bounds(); - - amy_stop(); - test_disabled_configuration(); - if (failures) { - printf("\n%d check(s) FAILED\n", failures); - return 1; - } - printf("\nall sequencer group checks passed\n"); - return 0; -} diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c new file mode 100644 index 00000000..9d057b37 --- /dev/null +++ b/tests/test_sequencer_sequences.c @@ -0,0 +1,377 @@ +// Regression and behavior tests for reusable tagged sequencer sequences. + +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[32]; + uint32_t tick; +} mark_t; + +static mark_t marks[256]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + memset(marks, 0, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static uint32_t next_boundary(uint32_t now, uint32_t quantum) { + uint32_t remainder = now % quantum; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +static int mark_at(const char *name, uint32_t tick) { + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; + return 0; +} + +static int marks_named(const char *name) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name)) count++; + return count; +} + +static void test_legacy_ticks_are_unchanged(void) { + printf("legacy root ticks remain unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H0,4,0zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), "periodic root event fires at global modulo"); + CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); + amy_add_message("H0,0,0Z"); + + clear_marks(); + uint32_t target = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); + amy_add_message(wire); + clock_to(target); + CHECK(!marks_named("old") && mark_at("new", target), + "legacy tagged writes still replace rather than accumulate"); +} + +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events retain three-value ticks\n"); + amy_event event = amy_default_event(); + event.osc = 2; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 3; + event.ticks[TICKS_PERIOD] = 8; + event.ticks[TICKS_TAG] = 7; + char wire[MAX_MESSAGE_LEN]; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7", 6) == 0, + "C ticks serialization remains three values: %s", wire); +} + +static void test_explicit_append_and_one_shot_lifetime(void) { + printf("explicit sequence events accumulate and finite events retire\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA10,0,0zPzeroZ"); + amy_add_message("HA10,2,0zPtwoZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("zero", start), "local tick zero fires at activation"); + CHECK(mark_at("two", start + 2), "a second event shares the same tag"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "period-zero sequence events fire once and execution retires"); +} + +static void test_root_and_stored_forms_share_one_tag_identity(void) { + printf("legacy and reusable forms share one public tag identity\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,10zProot-replacedZ"); + amy_add_message("HA10,0,0zPstoredZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("stored", start) && !marks_named("root-replaced"), + "explicit append replaces the root object at the same tag"); + + amy_add_message("H0,4,10zProotZ"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "legacy replacement removes the future stored definition"); + clear_marks(); + uint32_t root = next_boundary(sequencer_ticks(), 4); + clock_to(root); + CHECK(mark_at("root", root), "the replacement legacy event remains active"); +} + +static void test_active_definition_is_immutable(void) { + printf("active executions retain the definition they started with\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA11,0,0zPold-headZ"); + amy_add_message("HA11,4,0zPold-tailZ"); + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(old_start + 2); + + amy_add_message("HR11Z"); + amy_add_message("HA11,0,0zPnew-headZ"); + clock_to(old_start + 4); + CHECK(mark_at("old-tail", old_start + 4), + "resetting future contents does not remove an old note release"); + + clear_marks(); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(new_start + 2); + CHECK(mark_at("new-head", new_start) && !marks_named("old-head") + && !marks_named("old-tail"), + "a later start uses only the replacement definition"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("root events can launch stored sequences\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA12,0,0zPchild-zeroZ"); + uint32_t start = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,1HC12,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child-zero", start), + "a root launch includes the child's local tick zero"); +} + +static void test_overlapping_executions_need_no_host_identity(void) { + printf("one sequence tag supports bounded overlapping executions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA13,0,0zPonZ"); + amy_add_message("HA13,4,0zPoffZ"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,2HC13,1,0Z", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,3HC13,1,0Z", first + 2); + amy_add_message(wire); + clock_to(first + 6); + CHECK(mark_at("on", first) && mark_at("on", first + 2), + "two starts of one tag can overlap"); + CHECK(mark_at("off", first + 4) && mark_at("off", first + 6), + "each overlap retains its own scheduled release"); +} + +static void test_parent_stop_leaves_started_child_to_finish(void) { + printf("stopping a parent prevents future children without truncating one\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA15,0,0zPnote-onZ"); + amy_add_message("HA15,4,0zPnote-offZ"); + amy_add_message("HA14,0,4HC15,1,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC14,1,4Z"); + clock_to(start + 2); + amy_add_message("HC14,0,0Z"); + clock_to(start + 8); + CHECK(mark_at("note-on", start), "parent starts its child"); + CHECK(mark_at("note-off", start + 4), + "the already-started child delivers its own note-off"); + CHECK(marks_named("note-on") == 1, + "the stopped parent launches no later child"); +} + +static void test_controller_sequence_bounds_repetition(void) { + printf("a finite controller sequence can bound a periodic child\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA8,0,4zPpulseZ"); + amy_add_message("HA7,0,0HC8,1,0Z"); + amy_add_message("HA7,12,0HC8,0,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC7,1,4Z"); + clock_to(start + 14); + CHECK(mark_at("pulse", start) && mark_at("pulse", start + 4) + && mark_at("pulse", start + 8), + "controller permits exactly three periods"); + CHECK(!mark_at("pulse", start + 12) && marks_named("pulse") == 3, + "same-tick stop precedes the child's ordinary event"); +} + +static void test_finite_gate_preserves_phase(void) { + printf("finite event gating preserves the target phase\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA6,0,4zPbeatZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC6,1,4Z"); + clock_to(start); + CHECK(mark_at("beat", start), "loop begins on its aligned boundary"); + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_GATE, 6, 0), + "finite gate is accepted without a host timer"); + clock_to(start + 8); + CHECK(!mark_at("beat", start + 4), "event inside gate is suppressed"); + CHECK(mark_at("beat", start + 8), + "event resumes on the original phase after gate expiry"); +} + +static void test_per_tag_and_global_reset_semantics(void) { + printf("per-tag replacement and global reset have distinct scopes\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA5,0,0zPsurvivorZ"); + amy_add_message("HC5,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + amy_add_message("HR5Z"); + clock_to(start); + CHECK(mark_at("survivor", start), + "per-tag reset leaves an already-started snapshot alive"); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "per-tag reset removed the future definition"); + + amy_add_message("HA5,0,4zPclearedZ"); + amy_add_message("HC5,1,0Z"); + sequencer_reset(); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "global RESET_SEQUENCER clears stored definitions"); +} + +static void test_timebase_reset_keeps_definitions(void) { + printf("timebase reset drops runtime but keeps definitions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA4,0,0zPafter-rebaseZ"); + amy_add_message("HC4,1,0Z"); + sequencer_sequence_reset_timebase(); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("after-rebase"), "pending execution is discarded"); + CHECK(sequencer_sequence_control(4, SEQUENCE_CONTROL_START, 0, 0), + "definition remains available after timebase reset"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("after-rebase", start), "definition can be relaunched"); +} + +static void test_bounds_and_validation(void) { + printf("tag, event and execution bounds fail deterministically\n"); + sequencer_reset(); + CHECK(!sequencer_sequence_add_wire(16, 0, 0, strdup("zPbad-tagZ")), + "first tag beyond max_sequencer_tags is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 4, 4, strdup("zPbad-periodZ")), + "tick equal to period is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), + "empty payload is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("HA1,0,0zPbadZ")), + "stored sequences cannot edit definitions recursively"); + + for (uint32_t i = 0; i < 8; ++i) { + char *payload = strdup("zPfullZ"); + CHECK(sequencer_sequence_add_wire(3, i, 0, payload), + "event slot %" PRIu32 " is available", i); + } + CHECK(!sequencer_sequence_add_wire(3, 9, 0, strdup("zPoverflowZ")), + "one event beyond configured capacity is rejected"); + + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "one execution beyond configured capacity is rejected"); + CHECK(!sequencer_sequence_control(3, 99, 0, 0), + "unknown control action is rejected"); +} + +static void test_start_crosses_clock_rollover(void) { + printf("relative sequence phase crosses uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA2,0,0zPwrap-zeroZ"); + amy_add_message("HA2,2,0zPwrap-twoZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("HC2,1,4Z"); + clock_to(2); + CHECK(mark_at("wrap-zero", 0), "aligned local zero fires after rollover"); + CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); +} + +static void test_disabled_configuration(void) { + printf("zero reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][2] = {{0, 8}, {8, 0}}; + for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequence_events = capacities[i][0]; + config.max_sequence_executions = capacities[i][1]; + amy_start(config); + CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), + "append is disabled for zero capacity set %zu", i + 1); + CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "control is disabled for zero capacity set %zu", i + 1); + amy_stop(); + } +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 16; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_legacy_ticks_are_unchanged(); + test_legacy_c_event_wire_is_unchanged(); + test_explicit_append_and_one_shot_lifetime(); + test_root_and_stored_forms_share_one_tag_identity(); + test_active_definition_is_immutable(); + test_root_launches_local_zero_on_same_tick(); + test_overlapping_executions_need_no_host_identity(); + test_parent_stop_leaves_started_child_to_finish(); + test_controller_sequence_bounds_repetition(); + test_finite_gate_preserves_phase(); + test_per_tag_and_global_reset_semantics(); + test_timebase_reset_keeps_definitions(); + test_start_crosses_clock_rollover(); + test_bounds_and_validation(); + + amy_stop(); + test_disabled_configuration(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall reusable sequencer sequence checks passed\n"); + return 0; +} From bc3c85b03e9496188166809c1d45aecdd7372338 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:54:17 +0200 Subject: [PATCH 027/112] Document reusable tagged sequences --- ...groups-abstractions.md => sequencer-sequences-abstractions.md} | 0 docs/{sequencer-groups-howto.md => sequencer-sequences-howto.md} | 0 ...ical-use-cases.md => sequencer-sequences-musical-use-cases.md} | 0 docs/{sequencer-groups.md => sequencer-sequences.md} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename docs/{sequencer-groups-abstractions.md => sequencer-sequences-abstractions.md} (100%) rename docs/{sequencer-groups-howto.md => sequencer-sequences-howto.md} (100%) rename docs/{sequencer-groups-musical-use-cases.md => sequencer-sequences-musical-use-cases.md} (100%) rename docs/{sequencer-groups.md => sequencer-sequences.md} (100%) diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-sequences-abstractions.md similarity index 100% rename from docs/sequencer-groups-abstractions.md rename to docs/sequencer-sequences-abstractions.md diff --git a/docs/sequencer-groups-howto.md b/docs/sequencer-sequences-howto.md similarity index 100% rename from docs/sequencer-groups-howto.md rename to docs/sequencer-sequences-howto.md diff --git a/docs/sequencer-groups-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md similarity index 100% rename from docs/sequencer-groups-musical-use-cases.md rename to docs/sequencer-sequences-musical-use-cases.md diff --git a/docs/sequencer-groups.md b/docs/sequencer-sequences.md similarity index 100% rename from docs/sequencer-groups.md rename to docs/sequencer-sequences.md From 16c7e51b0fd0f7aff4d468bcc3d7ff3e8d2343ff Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:54:27 +0200 Subject: [PATCH 028/112] Rewrite sequence guides for the simplified API --- README.md | 4 +- docs/api.md | 13 +- docs/sequencer-sequences-abstractions.md | 220 +++++-------- docs/sequencer-sequences-howto.md | 289 +++++------------- docs/sequencer-sequences-musical-use-cases.md | 162 ++++------ docs/sequencer-sequences.md | 194 +++++------- docs/synth.md | 25 +- 7 files changed, 319 insertions(+), 588 deletions(-) diff --git a/README.md b/README.md index eea0daf0..e0dfa8df 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) - * [**AMY Sequencer Groups**](docs/sequencer-groups.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) @@ -172,7 +172,7 @@ It's good to understand what wire messages are but you don't need to construct t * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) - * [**AMY Sequencer Groups**](docs/sequencer-groups.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) diff --git a/docs/api.md b/docs/api.md index 16f07b90..a68cdbbc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,10 +203,9 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | -| `max_sequence_groups` | Int | 32 | Number of persistent sequencer groups; group tags are 1 through this value | -| `max_sequence_group_tags` | Int | 64 | Addressable local event tags in each allocated group definition | -| `max_sequence_group_executions` | Int | 32 | Maximum active or quantized-pending group executions | +| `max_sequencer_tags` | Int | 256 | Size of the tag space shared by legacy root events and reusable sequences | +| `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | +| `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `max_voices` | Int | 64 | How many voices | | `max_synths` | Int | 64 | How many synths | | `max_memory_patches` | Int | 32 | How many in memory patches to supprot | @@ -506,9 +505,11 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[4]` | `ticks` | int[,int[,tag[,group]]] | Tick, period and tag for root sequencing. A nonzero fourth value instead addresses a persistent [sequencer group](sequencer-groups.md), with the third value as its local event tag. `tag` omitted at root: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Existing tick, period and tag scheduling. `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. A legacy tagged write keeps its replace-by-tag behavior. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HA` | — | `sequence_event` | tag,tick,period | Explicitly append an ordinary event to a [reusable tagged sequence](sequencer-sequences.md). Prefer `amy.define_sequence()` in Python. | +| `HR` | — | `sequence_reset` | tag | Clear the future root event and reusable definition at one tag; already-started immutable executions may finish. | +| `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | -| `zQ` | — | `sequence_control` | group,action,value,quantize[,execution_tag] | Publish, start, stop, gate or clear a [sequencer group](sequencer-groups.md). | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | | `N` | `latency_ms`| `latency_ms` | uint | Sets latency in ms. default 0 (see LATENCY) | diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index f3a3026a..310d1c16 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -1,153 +1,87 @@ -# Sequencer-group abstractions and implementation +# Reusable sequence abstractions and implementation -AMY's root sequencer stores ordinary events on one global musical timeline. -Sequencer groups add one reusable, bounded phrase level below that timeline: a -root event can start a finite or repeating group of ordinary AMY events. They -do not add a drum machine, arpeggiator, song model, or scheduler hierarchy. +## Public model -For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). -For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). -The concise argument reference is in [Sequencer groups](sequencer-groups.md). +The public model has two ways to use the existing sequencer tag identity: -## The model +1. `ticks=(tick, period, tag)` keeps the established single-event behavior; +2. `define_sequence(tag, events)` explicitly gives that tag multiple local + events which can be started and stopped as a reusable sequence. -The model separates stored content, scheduled starts, and active playback: +There is no second public group ID, no local event-tag namespace, no fourth +`ticks` field, no explicit length, and no publish/revision command. -| Object | Purpose | Lifetime | -| --- | --- | --- | -| Root sequencer event | Decides when a group starts | Existing `H` tick/period/tag semantics | -| Group tag | Selects one reusable definition slot | From 1 through the configured group capacity | -| Staging revision | Receives local event edits privately | Until published or cleared | -| Published revision | Supplies immutable content to future starts | Until replaced or cleared | -| Execution | Plays one captured revision | Until its repeat count completes or it is stopped | -| Execution tag | Optionally addresses live or pending executions | Supplied by the start operation | -| Local event tag | Replaces or clears one event in one group's staging revision | Scoped to that group only | +`sequence_control` supplies the three generic runtime operations: -Root tags, group tags, execution tags, and local event tags are separate -identities. For example, replacing a tagged root event changes which phrase -will start in the future. It does not edit the phrase definition or shorten an -execution that has already started. +- start, optionally aligned to an AMY sequencer period; +- stop every active execution of the tag at an optional alignment boundary; +- gate ordinary events for a finite duration without resetting local phase. -## Authoring and publication - -The existing `ticks` tuple accepts an optional fourth value: +Sequences may start or stop other sequences. A finite controller sequence can +therefore express a fixed repeat count, and a parent can stop launching new +note-pair children while children already in progress deliver their note-offs. -```text -tick,period,event_tag,group_tag -``` +## Why executions still exist internally -With a nonzero `group_tag`, the `H` message edits that group's private staging -revision instead of the root sequencer. The first edit after publication clones -the current published revision, so a host can replace only the local tags that -changed. A local tag is cleared with `tick=0,period=0`, exactly like a tagged -root event. +A stored definition and an active execution have different lifetimes even +though that distinction is not a second public API. An execution needs a local +start tick and must retain the event data it began with. Without that internal +separation, changing a future phrase could remove a note-off or alter a fill +which is already sounding. -Because that pair means clear, an event at local tick zero must use a nonzero -period. Using the group length as its period is usually the clearest choice; a -finite execution still fires it only once per repetition. - -Publication uses action 3 of the `sequence_control` family: - -```text -zQ,3,Z -``` - -The length is explicit. AMY validates every staged event against it, then -publishes the complete revision atomically. Playback therefore never observes -a partly rewritten phrase. AMY does not infer a potentially expensive least -common multiple from event periods. - -## Execution lifetime - -A start captures the currently published revision. Its repeat value is: - -- `1` for one performance; -- `N` for exactly N performances; -- `0` for indefinite repetition. - -Editing, publishing, or clearing the group afterward affects future starts -only. Every active execution retains a reference to the revision it captured -and can deliver the note-offs or other closing events already stored in that -revision. This is the key guarantee for glitch-free live phrase changes. - -Starts and stops can be quantized to the next multiple of a sequencer tick -interval. A zero quantization value means the next sequencer tick for a direct -command. When a root event starts a group, local tick zero is processed on that -same root tick. - -An optional execution tag gives live playback a stable control identity. A new -start with the same group and execution tag replaces the matching execution at -the requested boundary. Untagged starts may overlap. Stop and gate operations -can address one execution tag or, when the tag is omitted, all executions of a -group. - -## Finite event gates - -Gate action 2 suppresses event dispatch for a duration while the execution's -local clock continues advancing. It does not stop already-sounding audio. When -the gate ends, the next event occurs at its original phase rather than at a -restarted phase. A zero duration releases a current gate. - -A group may contain a gate control as a leaf event. This lets one finite phrase -temporarily suppress events from another tagged repeating layer. AMY assigns no -musical meaning to either layer; the controller owns that policy. - -## Bounded scheduling - -The root sequencer may start a group. A group may contain ordinary AMY events -and finite gate controls, but it cannot start, publish, or clear a group. This -provides the two useful musical levels—global arrangement and reusable -phrase—without cycles or variable scheduling depth. - -The configured limits independently bound: - -- persistent group slots; -- local event tags in each allocated definition; -- active or quantized-pending executions. - -The portable defaults are 32 groups, 64 local tags per group, and 32 active or -pending executions. Definition storage is allocated only when a group is -authored. The audio-time tick path scans only the fixed execution pool, not all -stored groups, so an application can choose a larger definition catalogue -without making every inactive definition part of per-tick work. - -## Implementation outline - -The implementation in [`src/sequencer.c`](../src/sequencer.c) deliberately -reuses the normal event path: - -- grouped `H` messages store the same wire payloads AMY already parses; -- staged and published definitions use fixed-capacity local-tag tables; -- published revisions are reference-counted and remain alive while captured by - an execution; -- an independently bounded execution pool owns start phase, repeat count, - execution identity, pending stop, and gate state; -- root events are processed before group events, which makes a root launch and - its local tick-zero payload sample-clock coherent; -- group-to-group lifecycle operations are rejected while a grouped payload is - firing. - -The public configuration fields and constants are declared in -[`src/amy.h`](../src/amy.h). The group engine entry points are in -[`src/sequencer.h`](../src/sequencer.h), and Python uses the existing -`amy.send(ticks=...)` and `amy.send(sequence_control=...)` interface. - -## Compatibility contract - -An absent or zero fourth `ticks` value follows the existing root-sequencer path. -Existing three-field `H` messages, anonymous root events, tag replacement and -clear behavior, modulo periods, and `amy_add_event()` scheduling are unchanged. - -`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and pending executions -but preserve published group definitions. Full AMY shutdown releases the -definitions. - -The native group regression test exercises legacy root behavior and group -behavior in the same process. It covers the unchanged three-value C and wire -formats, root/group namespace isolation, one/N/infinite repetition, -quantization, tagged replacement, selective stop and gate, early ungate, -atomic publication, repair after rejected publication, immutable active -revisions, same-tick root launches, non-recursive lifecycle controls, allowed -leaf controls, resets, 32-bit clock rollover, disabled configuration, and -configured storage and execution bounds. The existing AMY C and audio suites -remain the broader backward-compatibility tests. +AMY therefore uses a small bounded execution pool and reference-counted, +copy-on-write definitions. Appending to a definition which an execution still +uses first clones it. The active execution keeps the old snapshot; later starts +see the updated contents. No revision number is exposed to callers. + +Multiple finite executions of one tag may overlap. This is important for +ordinary musical phrases whose gate time is longer than the interval between +starts. The execution pool, rather than a caller-managed ID scheme, is the +bound. + +## Lifetime inference + +The component events define lifetime: + +- if every event has `period=0`, the execution retires after its greatest local + tick has been processed; +- if any event has a nonzero period, the execution remains active and evaluates + that event against elapsed local time until stopped. + +This avoids an independent length that could disagree with the ordinary +sequencer periods. A fixed number of repeats is composition: a finite parent +starts a periodic child and stops it at the required local tick. + +## Tick processing + +Only active root entries and active sequence executions are visited per tick. +Stored but inactive definitions have no per-tick cost. + +Sequence controls are processed before ordinary events for a tick. Consequently +a stop scheduled at a period boundary prevents the event on that boundary, and +a parent launch can make a child's local tick-zero event run on the launch tick. + +Temporary gating suppresses ordinary payload dispatch but advances elapsed +local time normally. Control events are not gated; otherwise a controller could +mute its own future stop or recovery operation. + +## Bounds and recovery + +All storage is configured at startup: + +- `max_sequencer_tags`: shared public identities; +- `max_sequence_events`: maximum events in one stored definition; +- `max_sequence_executions`: active and pending executions. + +Definitions allocate event storage only when first used. The render path does +not perform unbounded allocation. A recursive or cyclic control graph can fill +the execution pool, but cannot grow past it; further starts fail and the caller +can stop a tag or reset the sequencer. + +## Compatibility boundary + +The legacy parser, C event layout, anonymous-event pool, modulo timing, +same-tag replacement, MIDI/external-clock behavior, and root active-list order +are unchanged. Reusable accumulation only occurs through the explicit sequence +API. Tests cover both the old path and the interaction between legacy and +reusable forms. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 9c747a52..c2bf891b 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,258 +1,121 @@ -# Sequencer-group how-to: switchable arpeggios and a percussion gate +# Reusable sequence how-to -This example sends complete AMY wire messages, including the final `Z`. AMY's -sequencer uses 48 ticks per quarter note, so the arpeggios use 24 ticks per -eighth note and a 96-tick phrase length. +This example preloads two simple arpeggios, launches them from the root +sequencer, and changes which one will launch without cutting short a note which +already started. -The examples use `amy.send()` as the Python API. Each expandable section emits -the same wire message shown directly above it. +## 1. Define note-pair sequences -## 1. Configure a simple sound - -Use oscillator 0 with a sine wave so the example does not depend on a stored -patch bank: - -```text -v0w0Z -``` - -
-Python API equivalent +Each finite child owns its note-on and note-off: ```python -import amy - -amy.send(osc=0, wave=amy.SINE) +amy.define_sequence(20, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(18,), synth=1, note=60, vel=0), +]) +amy.define_sequence(21, [ + dict(ticks=(0,), synth=1, note=64, vel=1), + dict(ticks=(18,), synth=1, note=64, vel=0), +]) ``` -
+## 2. Define two arpeggio parents -## 2. Preload an ascending arpeggio +The parents contain only starts of their note-pair children: -Group 10 plays C4, E4, G4, and C5. Each note begins 24 ticks after the previous -one and has an 18-tick gate: +```python +amy.define_sequence(30, [ + dict(ticks=(0, 48), + sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(24, 48), + sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), +]) -```text -H0,96,0,10v0n60l1Z -H18,96,1,10v0l0Z -H24,96,2,10v0n64l1Z -H42,96,3,10v0l0Z -H48,96,4,10v0n67l1Z -H66,96,5,10v0l0Z -H72,96,6,10v0n72l1Z -H90,96,7,10v0l0Z -zQ10,3,96Z +amy.define_sequence(31, [ + dict(ticks=(0, 24), + sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(12, 24), + sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), +]) ``` -The fourth `H` value selects group 10. The third value is a local event tag, -not a root tag. These messages update private staging storage; publish action 3 -makes the complete 96-tick revision visible atomically. +Because these parents contain periodic events, they run until stopped. -
-Python API equivalent +## 3. Start the first arpeggio ```python -amy.send(ticks=[0, 96, 0, 10], osc=0, note=60, vel=1) -amy.send(ticks=[18, 96, 1, 10], osc=0, vel=0) -amy.send(ticks=[24, 96, 2, 10], osc=0, note=64, vel=1) -amy.send(ticks=[42, 96, 3, 10], osc=0, vel=0) -amy.send(ticks=[48, 96, 4, 10], osc=0, note=67, vel=1) -amy.send(ticks=[66, 96, 5, 10], osc=0, vel=0) -amy.send(ticks=[72, 96, 6, 10], osc=0, note=72, vel=1) -amy.send(ticks=[90, 96, 7, 10], osc=0, vel=0) -amy.send(sequence_control=[10, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) ``` -
- -## 3. Preload a descending arpeggio +The start is aligned to the next 48-tick boundary. -Group 11 uses the same timing and reverses the pitches: - -```text -H0,96,0,11v0n72l1Z -H18,96,1,11v0l0Z -H24,96,2,11v0n67l1Z -H42,96,3,11v0l0Z -H48,96,4,11v0n64l1Z -H66,96,5,11v0l0Z -H72,96,6,11v0n60l1Z -H90,96,7,11v0l0Z -zQ11,3,96Z -``` - -
-Python API equivalent +## 4. Switch parents ```python -amy.send(ticks=[0, 96, 0, 11], osc=0, note=72, vel=1) -amy.send(ticks=[18, 96, 1, 11], osc=0, vel=0) -amy.send(ticks=[24, 96, 2, 11], osc=0, note=67, vel=1) -amy.send(ticks=[42, 96, 3, 11], osc=0, vel=0) -amy.send(ticks=[48, 96, 4, 11], osc=0, note=64, vel=1) -amy.send(ticks=[66, 96, 5, 11], osc=0, vel=0) -amy.send(ticks=[72, 96, 6, 11], osc=0, note=60, vel=1) -amy.send(ticks=[90, 96, 7, 11], osc=0, vel=0) -amy.send(sequence_control=[11, amy.SEQUENCE_CONTROL_PUBLISH, 96]) -``` - -
- -## 4. Turn on the ascending arpeggio - -Install a normal repeating root event. Every 96 ticks it starts group 10 once. -Root tag 200 gives that future schedule a replaceable identity: - -```text -H0,96,200zQ10,1,1,0Z -zY1Z -``` - -The embedded control arguments are: - -```text -zQ group,action,repeats,quantize Z - 10 1 1 0 +amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) +amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) ``` -Action 1 means start, and repeat value 1 makes each execution finite. The root -event supplies the repetition. Quantization is zero because the root event -already fires on the exact musical boundary; the group's local tick-zero event -is delivered on that same tick. +Both controls select the same next boundary. The old parent starts no more +children there. A note-pair child which started earlier remains independent and +still sends its tick-18 note-off.
-Python API equivalent +Equivalent low-level wire messages -```python -amy.send( - ticks=[0, 96, 200], - sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 0], -) -amy.send(sequencer_run=1) -``` - -
- -## 5. Switch to the descending arpeggio - -Replace root tag 200 with a start for group 11: +The Python API above emits these sequence-authoring messages: ```text -H0,96,200zQ11,1,1,0Z -``` - -The next matching root boundary starts the descending revision. An ascending -execution that already began keeps its captured revision and reaches every -original note-off normally. - -
-Python API equivalent - -```python -amy.send( - ticks=[0, 96, 200], - sequence_control=[11, amy.SEQUENCE_CONTROL_START, 1, 0], -) -``` +HR20Z +HA20,0,0n60l1i1Z +HA20,18,0n60l0i1Z +HR21Z +HA21,0,0n64l1i1Z +HA21,18,0n64l0i1Z +HR30Z +HA30,0,48HC20,1,1Z +HA30,24,48HC21,1,1Z +HR31Z +HA31,0,24HC20,1,1Z +HA31,12,24HC21,1,1Z +HC30,1,48Z +HC30,0,48Z +HC31,1,48Z +``` + +`HA` is the explicit cumulative event form, `HR` resets the future contents of +one tag, and `HC` controls a tagged sequence. They are all part of the +sequencer-oriented `H` family. Existing `Htick,period,tag...` messages retain +their original replace-by-tag behavior.
-## 6. Turn the arpeggio off and on - -Clear root tag 200 with the unchanged root-sequencer operation: +## Temporarily gate one percussion layer -```text -H0,0,200Z -``` - -This prevents future starts. It does not stop an execution that has already -begun, so the current phrase finishes with its normal note gates. Re-send the -root message from step 4 or 5 to turn the selected arpeggio on again. - -
-Python API equivalent +Suppose tag `50` is already running a periodic percussion sequence. A caller +can suppress its events for one quarter note at 48 PPQ without stopping its +clock: ```python -amy.send(ticks=[0, 0, 200]) +amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) ``` -
- -To play group 10 only once instead of installing a root schedule, start one -execution at the next 96-tick boundary: - -```text -zQ10,1,1,96Z -``` - -
-Python API equivalent +After 48 ticks the gate expires automatically and events resume on their +original phase. Duration zero removes a current gate explicitly: ```python -amy.send( - sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 96] -) +amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) ``` -
- -## 7. Gate one percussion instrument from a controller - -An independently controllable percussion role needs its own group execution. -Assume synth 10 is already configured as a percussion instrument and MIDI note -42 produces the desired closed hi-hat. Group 20 triggers that hit every 24 -ticks, and execution tag 300 is its live control address: - -```text -H0,24,0,20i10n42l1Z -zQ20,3,24Z -zQ20,1,0,24,300Z -``` - -The start repeat value is zero, so the execution repeats indefinitely. Other -percussion roles should use separate groups and execution tags when they need -independent control. - -Suppose a MIDI foot controller, switch, or other input has already been mapped -by the sending application. On press, it can apply a long finite event gate: - -```text -zQ20,2,2147483647,0,300Z -``` - -On release, duration zero removes the gate immediately: - -```text -zQ20,2,0,0,300Z -``` - -The gate suppresses future events from execution 300. It does not cut off a -sample that is already sounding, and the execution's clock continues. When the -gate is released, the hi-hat resumes on its original 24-tick phase. Reading the -controller and mapping it to these messages remain outside AMY. -
-Python API equivalent - -```python -# Define and start the independently controllable hi-hat layer. -amy.send(ticks=[0, 24, 0, 20], synth=10, note=42, vel=1) -amy.send(sequence_control=[20, amy.SEQUENCE_CONTROL_PUBLISH, 24]) -amy.send( - sequence_control=[20, amy.SEQUENCE_CONTROL_START, 0, 24, 300] -) +Equivalent low-level wire messages -# Controller press, then controller release. -amy.send( - sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 2147483647, 0, 300] -) -amy.send( - sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 0, 0, 300] -) +```text +HC50,2,48,1Z +HC50,2,0,1Z ```
-When the silence has a known musical duration, send that duration directly. -For example, `zQ20,2,192,0,300Z` suppresses four quarter notes at 48 PPQ and -then releases automatically without another controller message. +The source of these commands could be a foot pedal, UI, network controller, or +another sequence. AMY only sees generic tagged sequence control. diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 1db6b7a8..4f1f087a 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -1,100 +1,62 @@ -# Musical use cases for sequencer groups - -Sequencer groups are useful when a musical phrase must remain a coherent unit -while a controller changes what will play next. Two representative applications -are an interactive rhythm engine with selectable drum fills and an arpeggiator -whose timing, direction, or notes can change during playback. Both are expressed -as ordinary AMY events on a local timeline; AMY contains no policy specific to -either application. - -## Dynamic drum fills - -Consider a rhythm engine that combines repeating percussion layers with a -selectable fill and a fill density. It may offer hundreds of short fills, let a -player change the active selection while transport continues, and temporarily -silence some background layers during a fill while allowing others to continue. - -A flat root sequence can represent one final arrangement. Live editing is more -complicated: the host must expand every chosen fill into root events, identify -which future events are safe to replace, coordinate the background boundaries, -avoid truncating a fill already in progress, and resend a large schedule whenever -selection or density changes. Combining fills, densities, and independently -controlled background layers multiplies that state even though every individual -phrase is small. - -Sequencer groups preserve the useful phrase boundary: - -1. The controller preloads each fill once as a finite group. -2. A small tagged root event starts the selected group at a musical boundary. -3. Independently controllable background roles run as tagged repeating group - executions. -4. A fill can contain finite gate events for background executions that should - not dispatch events during that fill. -5. Replacing or clearing the root event changes future fills only. A fill that - already started retains its immutable revision and finishes normally. - -The controller still owns every musical choice: fill selection, density, -instrument roles, and which roles continue. AMY only provides reusable phrase -storage, coherent execution, and generic event gating. Live control therefore -changes a small reference instead of rewriting the expanded leaf-event schedule. - -Stored definitions and active executions have independent limits. A rhythm -engine can configure enough group slots for a large fill catalogue without -creating hundreds of live players or scanning every stored fill on each tick. - -## Arpeggios with clean live changes - -An arpeggio can also be expanded into the root sequencer. The difficult part is -changing rate, direction, pitch, or voicing while notes are already in flight. -Deleting old root entries can remove a future note-off and leave a note hanging. -Sending an immediate all-off prevents the hang but shortens a valid note. A -host-side timer can defer the edit, but then the host must mirror AMY's musical -clock and track the lifetimes of overlapping phrases. - -Instead, one group revision stores the complete arpeggio phrase, including every -note-on and its matching note-off. Tagged root events determine when that phrase -starts. When a player changes the arpeggio: - -- the controller stages and atomically publishes the complete replacement; -- future starts capture the new published revision; -- an execution already sounding retains its previous immutable revision; -- every release in that execution therefore occurs at its original gate; -- quantized root starts preserve the musical boundary; -- untagged executions may overlap when a new phrase starts before an older one - has finished. - -The result avoids both abrupt releases and delayed hanging notes. AMY does not -know that the event collection is an arpeggio; the same lifetime guarantee -applies to any finite musical gesture. - -## Independently controlled repeating layers - -A drum voice, ostinato, control phrase, or other repeating part can run as an -independently tagged group execution. A controller can stop it at a quantized -boundary or gate future event dispatch without stopping the sequencer, changing -the phase, or affecting unrelated layers. - -For example, a foot controller can gate the event stream that triggers one -percussion instrument. Pedal-down suppresses future hits for that tagged -execution, while a sample already sounding ends naturally. Pedal-up releases the -gate and the next hit occurs on the layer's original phase. Reading the pedal and -choosing the execution tag remain responsibilities of the controller application. - -## The common abstraction - -All three applications share the same structure: - -```text -root timeline: decide when a stored phrase starts -group definition: store a coherent local event sequence -group execution: play one immutable revision with a bounded lifetime -execution control: start, stop, or temporarily gate that playback -``` - -A flat sequence can ultimately represent the same notes. The group boundary is -valuable because it makes live changes atomic, compact, and independent of host -timing. It moves phrase completion and release ownership into AMY without moving -application-specific musical policy into the synthesizer. - -See the [step-by-step arpeggio and percussion-gate example](sequencer-groups-howto.md) -for the corresponding wire commands and Python calls. +# Musical use cases for reusable sequences + +Reusable sequences reduce controller complexity when a musical phrase contains +several events but should be launched as one unit. The examples below describe +generic rhythm-engine behavior; AMY assigns no musical meaning to a tag. + +## Preloaded fills + +A rhythm engine can preload each fill once as a finite tagged sequence. Its +root schedule then stores only sequence starts. Selecting or deselecting a fill +changes future root launches, not the complete fill body. + +An already-started fill holds its immutable definition and finishes even if its +future launches are removed. The controller does not calculate an end time, +stream the phrase repeatedly, or maintain an active-fill state machine. + +## Arpeggios and note lifetime + +A short child sequence can contain one note-on and its matching note-off. A +parent sequence starts these children in an arpeggio pattern. Stopping or +replacing the parent prevents future child starts; children which already +started keep their scheduled release. + +This makes live rate, direction, voicing, or chord changes predictable without +requiring the controller to mirror AMY's clock or remember which note-offs are +still pending. Starting the same finite child again may overlap with an older +execution; each execution retains its own event snapshot. + +An explicit stop of the child tag has the different, generic meaning of +terminating every active execution of that child. A caller can therefore choose +between stopping future launches at a parent and deliberately truncating the +leaf itself. + +## Temporarily reducing a rhythm + +A repeating percussion layer can be represented by a periodic sequence. A +finite gate suppresses its ordinary events for a chosen number of ticks while +its local phase keeps advancing. Once the gate expires, it resumes at the point +it would otherwise have reached; already-ringing audio is unaffected. + +The controller decides which musical layer a tag represents and which layers +to gate. AMY implements only generic event dispatch, duration, and phase. + +## Fixed repeat counts + +Component periods define looping. When a phrase should repeat exactly `N` +times, a finite controller sequence can start the periodic phrase at tick zero +and stop it at `N * period`. Control processing precedes ordinary events, so the +event on the stop boundary is not dispatched. + +This composes existing concepts instead of adding a separate repeat-mode or +published-length state. + +## Live definition changes + +A controller can remove future launches, reset and append the replacement +definition, then install new launches. Executions which started before the +change keep the old snapshot. Future starts use the new contents. + +The controller still owns musical policy and transaction ordering, but it does +not own active execution revisions, note lifetime, phrase completion, or the +sequencer clock. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 8708659b..37bcb289 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,140 +1,112 @@ -# Sequencer groups +# Reusable sequencer sequences -Sequencer groups are reusable collections of ordinary AMY sequencer events. -They add one bounded level below the existing root sequencer: a root event may -start a group, but a group cannot start another group. +AMY's existing sequencer tags can also identify reusable sequences. A reusable +sequence is a collection of ordinary AMY events with local `tick` and `period` +values. It can be started from Python, from the wire protocol, or from another +sequenced event. -This is useful when a musical controller needs to trigger a complete phrase -as one operation. Examples include a drum fill, a short arpeggio with its own -note-on and note-off, or a repeating percussion layer. The controller can -preload these phrases and later send one small, quantized control message. It -does not need to reproduce AMY's clock or resend every event at performance -time. +The ordinary three-value `ticks=(tick, period, tag)` API remains unchanged. A +legacy tagged write replaces the event at that tag. Multi-event accumulation is +always explicit. -Related guides: +## Defining a sequence -- [Abstractions and implementation](sequencer-groups-abstractions.md) -- [Musical use cases](sequencer-groups-musical-use-cases.md) -- [Step-by-step wire and Python how-to](sequencer-groups-howto.md) +The Python convenience API replaces all future contents at a tag: -## Defining and publishing a group - -The normal `ticks` tuple accepts an optional fourth value: - -```text -tick,period,event_tag,group_tag +```python +amy.define_sequence(40, [ + dict(ticks=(0,), synth=2, note=60, vel=1), + dict(ticks=(12,), synth=2, note=60, vel=0), +]) ``` -`group_tag` values start at 1. An absent or zero group tag uses the existing -root sequencer without changing any of its semantics. +Each event uses the normal AMY keyword arguments. Its `ticks` value is local to +the start of the sequence and contains `tick` plus an optional `period`. -This wire sequence stages a four-beat phrase in group 1 and then publishes it -atomically with a length of 192 ticks: +`define_sequence()` validates every event before sending anything. It then +performs a per-tag reset followed by explicit cumulative writes. If a sequence +may be launched while it is being rewritten, first remove or stop those future +launches. An execution which already started is safe: it retains the immutable +definition it started with, including later note-offs. -```text -H0,192,0,1i2n60l1Z -H24,192,1,1i2n60l0Z -H48,192,2,1i2n64l1Z -H72,192,3,1i2n64l0Z -zQ1,3,192Z +Low-level callers can use `sequence_reset` and `sequence_event` directly: + +```python +amy.send(sequence_reset=40) +amy.send(sequence_event=(40, 0, 0), synth=2, note=60, vel=1) +amy.send(sequence_event=(40, 12, 0), synth=2, note=60, vel=0) ``` -The equivalent Python calls are: +The sequence tag and legacy root tag are one identity space. Writing a legacy +tagged `ticks` event replaces the future reusable definition at that tag; +explicitly appending a reusable event removes the future legacy root event at +that tag. Applications should assign distinct tags to stored phrases and root +launch events. + +## Starting and stopping ```python -amy.send(ticks="0,192,0,1", synth=2, note=60, vel=1) -amy.send(ticks="24,192,1,1", synth=2, note=60, vel=0) -amy.send(ticks="48,192,2,1", synth=2, note=64, vel=1) -amy.send(ticks="72,192,3,1", synth=2, note=64, vel=0) -amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_PUBLISH, 192]) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) ``` -Grouped `ticks` commands update a private staging revision. Publishing is one -action in the generic control family rather than a separate begin/add/commit -API. It makes all staged local-tag replacements visible together, so a launch -can never observe a half-updated phrase. As at the root, `tick=0,period=0` -clears the specified event tag. Use a nonzero period for an event at local tick -zero. +The optional final value is `alignment_period`. `0` or `1` acts at the next +available sequencer tick for a direct command. A larger value selects the next +tick divisible by that period. When a root sequencer event fires a start on a +tick, the child sequence's local tick zero participates in that same tick. -The published length is explicit and bounded; AMY does not derive it using an -LCM of event periods. Within each phrase, a nonzero event period repeats by -local modulo and a zero period fires once at its local tick. +A start creates a bounded execution. More than one execution of a finite +sequence may overlap; no caller-generated execution ID is required. Stop +targets every active execution of the tag. Stopping a parent prevents its +future child starts but does not stop child sequences which already started. +This lets a note-on/note-off child own its complete lifetime. -## Controlling executions +## Finite and repeating lifetime -The control layout is fixed: +No explicit sequence length or publish action is needed: -```text -group,action,value,quantize[,execution_tag] -``` +- a definition containing only `period=0` events is finite and retires after + its last event; +- an event with nonzero `period` repeats on its local period, and keeps that + execution alive until it is stopped; +- a controlling finite sequence can start a periodic child at local tick zero + and stop it after a chosen number of periods. -| Action | Number | Meaning of `value` | -|---|---:|---| -| stop | 0 | reserved; use 0 | -| start | 1 | repeat count: 1 once, N exactly N times, 0 indefinitely | -| gate | 2 | suppress group-event firings for this many ticks; 0 releases a gate | -| publish | 3 | explicit group length in ticks | -| clear | 4 | reserved; use 0 | - -`quantize=0` means the next sequencer tick for a direct command. Otherwise the -control takes effect at the next multiple of that many ticks. When a root -sequencer event issues the control on the boundary itself, it takes effect on -that same tick, including the group's local tick-zero events. - -For example, start group 1 indefinitely at the next 192-tick boundary, assign -execution tag 100, and later stop that execution at a boundary: - -```text -zQ1,1,0,192,100Z -zQ1,0,0,192,100Z -``` +## Temporary event gating ```python -amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_START, 0, 192, 100]) -amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_STOP, 0, 192, 100]) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) ``` -Omit `execution_tag` to address every active execution of the group for stop -or gate operations. Supplying a tag to start makes a later start with the same -group and execution tag replace it on the requested boundary. Untagged starts -may overlap, which is useful for one-shot note phrases whose releases must be -allowed to finish independently. +This suppresses ordinary event dispatch from active executions of tag `40` for +24 ticks. Their local phase continues and dispatch resumes on the original +phase. Audio which is already ringing is not cut off. Nested sequence controls +remain active while ordinary payload events are gated, so controller sequences +can still complete their lifecycle. -A finite gate advances the execution's local clock but suppresses its event -firings. Audio already sounding is not stopped, and the first event after the -gate occurs at its original phase. A gate can itself be placed in another -group as a leaf control; start, publish and clear are rejected while a group -payload is firing. A group therefore never launches or edits another group. +Gate duration `0` removes a gate at the selected alignment boundary. -## Scheduling a launch at the root +## Reset behavior -Because `sequence_control` is an ordinary wire command, it can be the payload -of a normal root `ticks` event. This starts group 1 once at absolute tick 960: +- `amy.send(sequence_reset=tag)` removes the future legacy/root event and the + future reusable definition for that tag. Active immutable executions finish. +- `RESET_TIMEBASE` discards active/pending executions because their absolute + activation ticks cannot be rebased, but retains stored definitions. +- `RESET_SEQUENCER` retains its global meaning: it clears root events, reusable + definitions, and active/pending executions. -```text -H960,0,40zQ1,1,1,0Z -``` +## Capacity and realtime behavior + +`max_sequencer_tags` bounds the shared tag space. `max_sequence_events` bounds +the number of events in one reusable definition, and +`max_sequence_executions` independently bounds active or alignment-pending +executions. Definitions are allocated only for tags which use them, and +inactive definitions are not scanned on each tick. + +Starts fail clearly when the execution pool is full. Cyclic sequence launches +cannot allocate beyond that fixed pool and can be recovered with targeted stop +commands or `RESET_SEQUENCER`. -A repeating root entry can launch the same group sparsely without copying its -events. Clear that future launch with the unchanged root operation -`H0,0,40Z`; an execution already started from it keeps running. - -## Lifetime and memory guarantees - -An active execution retains the immutable published revision it started with. -Editing, publishing or clearing the group affects future starts only. This is -important for phrases containing releases: an old note-off cannot disappear -because a new definition was loaded while it was sounding. - -`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and quantized-pending -executions but preserve published group definitions. Full AMY shutdown frees -them. - -Storage and work are bounded by `max_sequence_groups`, -`max_sequence_group_tags` and `max_sequence_group_executions` in -`amy_config_t`. Group event arrays and wire payloads are allocated only for -definitions that are authored. Setting any of the three capacities to zero -disables sequencer groups. The tick path scans only the fixed execution pool, -not all stored groups, so a larger definition catalogue does not make inactive -definitions part of per-tick work. Starting an execution does not allocate -memory. +See the [implementation model](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and +[step-by-step examples](sequencer-sequences-howto.md). diff --git a/docs/synth.md b/docs/synth.md index d3159403..6f2ec886 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -241,20 +241,20 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. -### Reusable sequencer groups +### Reusable tagged sequences -A fourth `ticks` value stores an event in a reusable group instead of the root -sequencer: `tick,period,event_tag,group_tag`. Group tag zero is reserved for -the root sequencer, so existing one-, two- and three-value `ticks` messages -retain their original behavior. Groups are controlled through the single -`sequence_control` parameter; they can run once, a fixed number of times, or -continuously, and start/stop can be quantized to AMY's tick clock. +An existing sequencer tag can explicitly hold several ordinary events with +local tick values. `amy.define_sequence(tag, events)` replaces that reusable +definition, while legacy three-value `ticks=(tick, period, tag)` retains its +single-event replace behavior. `sequence_control` starts, stops, aligns, or +temporarily gates an active tagged sequence. Component periods define looping; +a definition containing only period-zero events finishes after its last event. -See [Sequencer groups](sequencer-groups.md) for the concise wire format and -lifecycle reference. The accompanying guides explain the -[abstractions and implementation](sequencer-groups-abstractions.md), -[musical use cases](sequencer-groups-musical-use-cases.md), and a -[step-by-step wire and Python example](sequencer-groups-howto.md). +See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API +and lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and a +[step-by-step Python example](sequencer-sequences-howto.md). ## Core oscillators @@ -491,4 +491,3 @@ amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play ba amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - From db1e9238692b5a85566917657e4714261310430f Mon Sep 17 00:00:00 2001 From: Brian Whitman Date: Fri, 4 Sep 2026 11:13:15 -0400 Subject: [PATCH 029/112] the knob is BLOCK_SIZE_BITS; AMY_BLOCK_SIZE is derived dpwe's review: a host should choose the block in BITS, with the size (1 << BLOCK_SIZE_BITS), because asking in bits is what says the block has to be a power of two. Checked against the code and it does: every use of BLOCK_SIZE_BITS is a shift -- the per-block amplitude ramps in oscillators.c and the pan ramp in amy.c -- so a block that was not a power of two would ramp to the wrong place with no error. So -DBLOCK_SIZE_BITS=7 is the override, 5..10 is #error'd outside, and AMY_BLOCK_SIZE is (1 << BLOCK_SIZE_BITS). Built with nothing passed it is 8 (256), or 7 (128) on Daisy, as before. The special case is amy/constants.py: it is a grep of the NUMERIC #defines in amy.h, so a derived AMY_BLOCK_SIZE dropped out of it and with it out of amy.render() and the generated JS API. The Makefile rule now appends AMY_BLOCK_SIZE computed from the BLOCK_SIZE_BITS that landed; constants.py and amy_api.generated.js are regenerated (the JS diff is the one key moving to the end). Co-Authored-By: Claude Fable 5.1 --- Makefile | 6 ++++++ amy/constants.py | 3 +-- src/amy.h | 28 ++++++++++------------------ src/amy_api.generated.js | 4 ++-- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index 232ebc3b..130527ec 100644 --- a/Makefile +++ b/Makefile @@ -96,8 +96,14 @@ HEADERS_BUILD := $(filter-out src/patches.h,$(HEADERS)) PYTHONS = $(wildcard *.py) +# The grep below takes every NUMERIC #define out of amy.h. AMY_BLOCK_SIZE is +# the one derived define -- (1 << BLOCK_SIZE_BITS), since the block has to be +# a power of two and the bits are the knob -- so it is spelt out afterwards +# from the BLOCK_SIZE_BITS that landed, or amy.render() and the generated JS +# API would lose it. src/patches.h: $(PYTHONS) $(HEADERS_BUILD) cat src/amy.h | sed -e 's@^//.*@@' | tr '\t' ' ' | egrep 'define +[^ ]+ +[.0-9-]+' | sed -e 's/\([-0-9][0-9]*\.[0-9]*\)f.*/\1/' | awk '{print $$2 "=" $$3}' > amy/constants.py + echo "AMY_BLOCK_SIZE=$$((1 << $$(sed -n 's/^BLOCK_SIZE_BITS=//p' amy/constants.py | tail -1)))" >> amy/constants.py ${PYTHON} -m amy.headers %.o: %.c $(HEADERS) src/patches.h diff --git a/amy/constants.py b/amy/constants.py index 248b75e6..811b39bb 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -1,7 +1,5 @@ MAX_FILENAME_LEN=127 -AMY_BLOCK_SIZE=128 BLOCK_SIZE_BITS=7 -AMY_BLOCK_SIZE=256 BLOCK_SIZE_BITS=8 AMY_SAMPLE_RATE=48000 AMY_SAMPLE_RATE=48000 @@ -156,3 +154,4 @@ AMY_AUDIO_DEVICE_OUT=0 AMY_AUDIO_DEVICE_IN=1 AMY_NUM_MIDI_CHANNELS=16 +AMY_BLOCK_SIZE=256 diff --git a/src/amy.h b/src/amy.h index 7122cd59..44ff8fb2 100644 --- a/src/amy.h +++ b/src/amy.h @@ -73,29 +73,21 @@ extern const uint32_t pcm_wavetable_len; // Set block size and SR. We try for 256/44100, but some platforms don't let us. -// A host may pick the block at COMPILE time (-DAMY_BLOCK_SIZE=128, any power of -// two from 32 to 1024); BLOCK_SIZE_BITS then follows it. Left alone, it is -// 256 (128 on Daisy), exactly as before. -#ifndef AMY_BLOCK_SIZE +// The block is a POWER OF TWO -- the per-block amplitude and pan ramps are +// SHIFTR(delta, BLOCK_SIZE_BITS), not a divide -- so a host chooses it in +// BITS, at compile time: -DBLOCK_SIZE_BITS=7 is a 128-sample block, 6 is 64. +// Left alone it is 8 (256 samples), or 7 (128) on Daisy, exactly as before. +#ifndef BLOCK_SIZE_BITS #ifdef AMY_DAISY -#define AMY_BLOCK_SIZE 128 -#define BLOCK_SIZE_BITS 7 // log2 of BLOCK_SIZE +#define BLOCK_SIZE_BITS 7 #else -#define AMY_BLOCK_SIZE 256 -#define BLOCK_SIZE_BITS 8 // log2 of BLOCK_SIZE -#endif +#define BLOCK_SIZE_BITS 8 #endif -#ifndef BLOCK_SIZE_BITS -#if (AMY_BLOCK_SIZE & (AMY_BLOCK_SIZE - 1)) || AMY_BLOCK_SIZE < 32 || AMY_BLOCK_SIZE > 1024 -#error "AMY_BLOCK_SIZE must be a power of two from 32 to 1024" #endif -// An expression rather than one literal per size, so `make amy/constants.py` -// (which greps every numeric #define out of this file) keeps reporting the -// default above rather than whichever literal came last. -#define BLOCK_SIZE_BITS (AMY_BLOCK_SIZE == 32 ? 5 : AMY_BLOCK_SIZE == 64 ? 6 : \ - AMY_BLOCK_SIZE == 128 ? 7 : AMY_BLOCK_SIZE == 256 ? 8 : \ - AMY_BLOCK_SIZE == 512 ? 9 : 10) // log2 of AMY_BLOCK_SIZE +#if BLOCK_SIZE_BITS < 5 || BLOCK_SIZE_BITS > 10 +#error "BLOCK_SIZE_BITS must be 5..10 (a block of 32..1024 samples)" #endif +#define AMY_BLOCK_SIZE (1 << BLOCK_SIZE_BITS) #ifdef AMY_DAISY #define AMY_SAMPLE_RATE 48000 diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index fc7ff3b2..340ac949 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -270,7 +270,6 @@ function amy_send(params, log) { // Constants from amy/constants.py (mirrors amy.SINE, amy.FILTER_LPF, etc.) var AMY = { MAX_FILENAME_LEN: 127, - AMY_BLOCK_SIZE: 256, BLOCK_SIZE_BITS: 8, AMY_SAMPLE_RATE: 44100, PCM_AMY_SAMPLE_RATE: 22050, @@ -419,7 +418,8 @@ var AMY = { AMYBOARD_MIDI_IN: 21, AMY_AUDIO_DEVICE_OUT: 0, AMY_AUDIO_DEVICE_IN: 1, - AMY_NUM_MIDI_CHANNELS: 16 + AMY_NUM_MIDI_CHANNELS: 16, + AMY_BLOCK_SIZE: 256 }; if (typeof globalThis !== "undefined") { From 29aa50a8d2f35ff1b1e2d355f30cf8f022833ba4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:18:07 +0200 Subject: [PATCH 030/112] Make sequencer tags cumulative sequences --- amy/__init__.py | 16 ++-- src/amy_api.generated.js | 150 +++++++++++++++---------------- src/parse.c | 26 +----- src/sequencer.c | 70 +++++++-------- src/sequencer.h | 18 ++-- tests/test_sequence_api.py | 10 +-- tests/test_sequencer_active.c | 33 +++---- tests/test_sequencer_bounds.c | 21 ++--- tests/test_sequencer_sequences.c | 101 ++++++++++----------- 9 files changed, 193 insertions(+), 252 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 7239cca7..7ae89f94 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -242,9 +242,8 @@ def str_of_int(arg): _KW_MAP_LIST = [ # Order matters because patch_string must come last. # Sequence/ticks headers must come first: 'H' is only recognized as the - # first wire character. sequence_control follows a ticks/sequence_event + # first wire character. sequence_control follows a ticks # header when it is used as that scheduled event's payload. - ('sequence_event', 'HAL'), ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -301,14 +300,14 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') - outer_sequence_keys = {'sequence_event', 'ticks', 'sequence_reset'} & kwargs.keys() + outer_sequence_keys = {'ticks', 'sequence_reset'} & kwargs.keys() if len(outer_sequence_keys) > 1: - raise ValueError('Use only one of sequence_event, sequence_reset, or ticks in a message.') + raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') if ('sequence_control' in kwargs and len(kwargs) != 1 - and not ({'sequence_event', 'ticks'} & kwargs.keys())): - raise ValueError('sequence_control can only be combined with ticks or sequence_event.') + and 'ticks' not in kwargs): + raise ValueError('sequence_control can only be combined with ticks.') # Validity check all the passed args. prioritized_keys = [] @@ -423,13 +422,12 @@ def define_sequence(tag, events): values = dict(event) if 'ticks' not in values: raise ValueError('Every stored sequence event needs a ticks value.') - if {'sequence_event', 'sequence_reset'} & values.keys(): + if 'sequence_reset' in values: raise ValueError('Stored sequence events cannot contain sequence authoring commands.') tick, period = _sequence_ticks(values.pop('ticks')) if not values: raise ValueError('Every stored sequence event needs an AMY payload.') - event_messages.append(message( - sequence_event=(sequence_tag, tick, period), **values)) + event_messages.append(message(ticks=(tick, period, sequence_tag), **values)) send_raw(message(sequence_reset=sequence_tag)) for event_message in event_messages: diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 99f391cf..f8a7311e 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -4,7 +4,6 @@ "use strict"; var AMY_KW_MAP = { - sequence_event: {wire: "HA", type: "L"}, ticks: {wire: "H", type: "L"}, osc: {wire: "v", type: "I"}, wave: {wire: "w", type: "I"}, @@ -82,81 +81,80 @@ var AMY_KW_MAP = { }; var AMY_KW_PRIORITY = { - sequence_event: 0, - ticks: 1, - osc: 2, - wave: 3, - note: 4, - vel: 5, - amp: 6, - freq: 7, - duty: 8, - feedback: 9, - reset: 10, - phase: 11, - sample_offset: 12, - fit: 13, - fit_search: 14, - pan: 15, - client: 16, - volume: 17, - pitch_bend: 18, - filter_freq: 19, - resonance: 20, - bp0: 21, - bp1: 22, - eg0: 23, - eg1: 24, - eg0_type: 25, - eg1_type: 26, - debug: 27, - chained_osc: 28, - mod_source: 29, - eq: 30, - filter_type: 31, - ratio: 32, - latency_ms: 33, - dist_clip: 34, - dist_fold: 35, - dist_crush: 36, - dist_drive: 37, - dist_mix: 38, - algo_source: 39, - load_sample: 40, - transfer_file: 41, - disk_sample: 42, - algorithm: 43, - chorus: 44, - reverb: 45, - echo: 46, - patch: 47, - sequence_reset: 48, - sequence_control: 49, - external_channel: 50, - portamento: 51, - tempo: 52, - sequencer_run: 53, - external_midi_sync: 54, - synth: 55, - pedal: 56, - synth_flags: 57, - num_voices: 58, - oscs_per_voice: 59, - synth_level: 60, - to_synth: 61, - grab_midi_notes: 62, - note_source_channel: 63, - synth_delay: 64, - preset: 65, - num_partials: 66, - start_sample: 67, - stop_sample: 68, - bus: 69, - mode: 70, - midi_cc: 71, - midi_note_cmd: 72, - cv_trigger: 73, - patch_string: 74 + ticks: 0, + osc: 1, + wave: 2, + note: 3, + vel: 4, + amp: 5, + freq: 6, + duty: 7, + feedback: 8, + reset: 9, + phase: 10, + sample_offset: 11, + fit: 12, + fit_search: 13, + pan: 14, + client: 15, + volume: 16, + pitch_bend: 17, + filter_freq: 18, + resonance: 19, + bp0: 20, + bp1: 21, + eg0: 22, + eg1: 23, + eg0_type: 24, + eg1_type: 25, + debug: 26, + chained_osc: 27, + mod_source: 28, + eq: 29, + filter_type: 30, + ratio: 31, + latency_ms: 32, + dist_clip: 33, + dist_fold: 34, + dist_crush: 35, + dist_drive: 36, + dist_mix: 37, + algo_source: 38, + load_sample: 39, + transfer_file: 40, + disk_sample: 41, + algorithm: 42, + chorus: 43, + reverb: 44, + echo: 45, + patch: 46, + sequence_reset: 47, + sequence_control: 48, + external_channel: 49, + portamento: 50, + tempo: 51, + sequencer_run: 52, + external_midi_sync: 53, + synth: 54, + pedal: 55, + synth_flags: 56, + num_voices: 57, + oscs_per_voice: 58, + synth_level: 59, + to_synth: 60, + grab_midi_notes: 61, + note_source_channel: 62, + synth_delay: 63, + preset: 64, + num_partials: 65, + start_sample: 66, + stop_sample: 67, + bus: 68, + mode: 69, + midi_cc: 70, + midi_note_cmd: 71, + cv_trigger: 72, + patch_string: 73 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; diff --git a/src/parse.c b/src/parse.c index fdc20c68..f5cfb6d1 100644 --- a/src/parse.c +++ b/src/parse.c @@ -711,27 +711,9 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { void handle_ticks_message(char *message) { assert(message[0] == 'H'); if (message[1] == 'A') { - // HAsequence_tag,tick,period: explicitly append one ordinary - // event to a reusable sequence. The sequence tag is the same public - // identity space used by legacy root ticks events. - uint32_t values[3] = {0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 3, 0); - uint16_t header_len = 2 + _next_alpha(message + 2); - if (count != 3) { - fprintf(stderr, - "invalid sequence event: expected " - "HAsequence_tag,tick,period\n"); - return; - } - char *payload = message + header_len; - size_t payload_len = strlen(payload); - char *copy = (char *)malloc_caps(payload_len + 1, - amy_global.config.ram_caps_events); - if (copy == NULL) amy_oom("sequence_event"); - else { - memcpy(copy, payload, payload_len + 1); - sequencer_sequence_add_wire(values[0], values[1], values[2], copy); - } + fprintf(stderr, + "invalid ticks command: HA is not needed; append with " + "Htick,period,tag\n"); return; } if (message[1] == 'C') { @@ -757,7 +739,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'R') { - // HRtag: clear future root/stored events for this tag. Already-active + // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. uint32_t values[1] = {0}; int count = parse_list_uint32_t(message + 2, values, 1, 0); diff --git a/src/sequencer.c b/src/sequencer.c index f812ef49..ffde3932 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -21,10 +21,10 @@ typedef struct sequence_info_t { int32_t next_active; } sequence_info_t; -struct sequence_info_t *sequences = NULL; // An array indexed by tag. +struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. int32_t max_sequences = 0; // Number of user-addressable tags. -// Head of the ascending list of occupied slots (user tags and anonymous -// entries alike); -1 when nothing is scheduled. This replaces `highest_tag`, +// Head of the ascending list of occupied anonymous slots; -1 when nothing is +// scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag // made every tick scan that far for the rest of the session, long after that // sequence was cleared. The anonymous pool made that the common case, not a @@ -33,11 +33,8 @@ int32_t max_sequences = 0; // Number of user-addressable tags. // end of the table permanently. The cost is proportional to what is // scheduled now. int32_t first_active = -1; -// Anonymous (no-tag) entries live past the user-addressable tag range, at -// indices [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS), so a -// user-supplied tag (bounds-checked against max_sequences) can never reach -// or clobber one. Allocated round-robin; a new anonymous entry silently -// evicts the oldest one once the pool wraps around. +// Anonymous (no-tag) entries have their own fixed pool. Allocated round-robin; +// a new anonymous entry silently evicts the oldest once the pool wraps. #define AMY_ANON_SEQUENCE_SLOTS 256 static int32_t anon_cursor = 0; static volatile bool sequencer_running = true; @@ -240,10 +237,9 @@ void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, wire_firing = false; anon_cursor = 0; max_sequences = max_sequencer_tags; - int32_t total_slots = max_sequences + AMY_ANON_SEQUENCE_SLOTS; - sequences = (struct sequence_info_t *)malloc_caps(total_slots * sizeof(struct sequence_info_t), + sequences = (struct sequence_info_t *)malloc_caps(AMY_ANON_SEQUENCE_SLOTS * sizeof(struct sequence_info_t), amy_global.config.ram_caps_synth); - for (int32_t i = 0; i < total_slots; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { sequences[i].wire = NULL; sequences[i].tick = 0; sequences[i].period = 0; @@ -258,7 +254,7 @@ void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, void sequencer_reset() { // Remove all events (tagged and anonymous). No lock here: this is called // from play_delta() (RESET_SEQUENCER), which already runs under the amy lock. - for (int32_t i = 0; i < max_sequences + AMY_ANON_SEQUENCE_SLOTS; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { if (sequences[i].wire) { free(sequences[i].wire); sequences[i].wire = NULL; @@ -294,8 +290,10 @@ void sequencer_debug() { fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { - fprintf(stderr, "sequence tag %" PRIi32"%s tick %" PRIu32 " period %"PRIu32 " wire \"%s\"\n", - tag, tag >= max_sequences ? " (anon)" : "", sequences[tag].tick, sequences[tag].period, sequences[tag].wire); + fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 + " period %" PRIu32 " wire \"%s\"\n", + tag, sequences[tag].tick, sequences[tag].period, + sequences[tag].wire); } } } @@ -366,10 +364,10 @@ void sequencer_recompute() { // Store a wire message in the sequencer. Takes ownership of wire (malloc'd). // // has_tag false means tag wasn't supplied by the caller (a 1- or 2-value -// ticks= form): the entry is allocated round-robin from the anonymous pool -// instead of the given tag value, so it's stored but not addressable or -// individually cancelable. has_tag true is the normal tag-indexed form: tick -// and period both zero clears that tag's entry (the only way to cancel one). +// ticks= form): the entry is allocated round-robin from the anonymous pool, so +// it is stored but not addressable or individually cancelable. has_tag true +// appends to the reusable definition at that tag; an empty tick-zero message +// resets the definition. // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. @@ -385,6 +383,18 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } + // Tagged ticks are the events of the reusable sequence identified by + // that tag. Repeating a tag therefore accumulates events, matching + // the way repeated synth= messages build one synth. The historical + // empty H0,0,tag form remains a convenient spelling for per-tag reset; + // with a payload, tick zero is an ordinary (and essential) local + // one-shot event. + if (tick == 0 && period == 0 + && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { + free(wire); + return sequencer_sequence_reset(tag); + } + return sequencer_sequence_add_wire(tag, tick, period, wire); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -392,18 +402,11 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } - tag = (uint32_t)(max_sequences + anon_cursor); + tag = (uint32_t)anon_cursor; anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); - // A public tag identifies one future sequencer object. A legacy tagged - // write therefore replaces any reusable definition at the same tag; an - // execution which already retained that definition can still finish. - if (has_tag && stored_sequences != NULL) { - stored_sequence_definition_release(stored_sequences[tag].definition); - stored_sequences[tag].definition = NULL; - } - // Release any existing message for this tag, even if we're just going to rewrite it. + // Reuse the selected anonymous slot, evicting its previous message. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; sequences[tag].tick = 0; @@ -482,14 +485,6 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } amy_grab_lock(); - // Explicit cumulative sequence authoring and legacy root scheduling share - // one tag identity. Appending a stored event removes any future root event - // at that tag, while unrelated tags are untouched. - if (sequences[tag].wire != NULL) free(sequences[tag].wire); - sequences[tag].wire = NULL; - sequences[tag].tick = 0; - sequences[tag].period = 0; - active_unlink((int32_t)tag); stored_sequence_definition_t *definition = slot->definition; if (definition == NULL) { definition = stored_sequence_definition_new(); @@ -546,11 +541,6 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { } amy_grab_lock(); - if (sequences[tag].wire != NULL) free(sequences[tag].wire); - sequences[tag].wire = NULL; - sequences[tag].tick = 0; - sequences[tag].period = 0; - active_unlink((int32_t)tag); stored_sequence_definition_release(slot->definition); slot->definition = NULL; amy_release_lock(); diff --git a/src/sequencer.h b/src/sequencer.h index 876fd047..37a40c4e 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -17,19 +17,19 @@ void sequencer_check_and_fill(); // called once per block from amy_execute_delt void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif // Store a wire message (with its leading 'H' already stripped) in the -// sequencer. If has_tag is true, it's stored under tag (replacing/clearing -// any existing entry there, addressable later by that same tag); clears the -// tag if tick and period are both 0. If has_tag is false, it's stored -// anonymously (round-robin in a small reserved pool) and can't be addressed -// or cancelled by any tag. Takes ownership of wire. +// sequencer. If has_tag is true, append it to the reusable sequence identified +// by tag. An empty tick=period=0 command clears that sequence; the same timing +// with a payload appends a local tick-zero event. If has_tag is false, store it +// anonymously (round-robin in a small reserved pool) for immediate sequencer +// playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); // Append one ordinary ticks event to the reusable sequence identified by tag. -// Takes ownership of wire. Unlike the legacy root ticks syntax, tick=period=0 -// is a valid one-shot event here. +// Takes ownership of wire. A tick=period=0 event is a valid one-shot when its +// wire payload is nonempty. uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire); -// Clear the future root event and reusable definition at tag. Executions which -// already started retain their immutable definition and may finish. +// Clear the future definition at tag. Executions which already started retain +// their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); // sequence_control is [tag, start_or_stop, alignment_period] or // [tag, gate, duration, alignment_period]. diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 144354dc..7230994d 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -18,8 +18,8 @@ def expect_error(fragment, fn): def main(): - assert amy.message(sequence_event=(7, 0, 0), synth=1, note=60, vel=1) \ - == "HA7,0,0n60l1i1Z" + assert amy.message(ticks=(0, 0, 7), synth=1, note=60, vel=1) \ + == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" assert amy.message(ticks=(0, 48, 3), @@ -41,12 +41,10 @@ def main(): amy.override_send = old_override assert sent == [ "HR7Z", - "HA7,0,0n60l1i1Z", - "HA7,3,8n60l0i1Z", + "H0,0,7n60l1i1Z", + "H3,8,7n60l0i1Z", ] - expect_error("only one", lambda: amy.message( - ticks=(0, 4, 1), sequence_event=(2, 0, 0), synth=1)) expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) diff --git a/tests/test_sequencer_active.c b/tests/test_sequencer_active.c index 4fd7087b..e0895ca1 100644 --- a/tests/test_sequencer_active.c +++ b/tests/test_sequencer_active.c @@ -1,21 +1,8 @@ -// The sequencer's per-tick cost should track what is SCHEDULED, not what -// tag number happened to be used. -// -// sequencer_process_tick() used to sweep 0..highest_tag, and highest_tag -// was a high-water mark that only ever grew — cleared sequences never -// brought it down. So one event parked at a high tag made every tick -// scan that far for the rest of the session, and raising -// max_sequencer_tags made the worst case proportionally worse. The -// anonymous pool made this the common case, not a corner: anonymous -// ticks= entries are allocated round-robin at indices past -// max_sequences, so a burst of one-shots pinned the mark at the very -// end of the table permanently. The occupied slots are threaded through -// the table as an ascending list now. -// -// The headline check here is an INVARIANT rather than a benchmark: one -// sequence at tag 0 and one sequence at tag max-1 must cost the same, -// because both are one sequence. Under the old sweep the second cost -// ~max times the first. +// The sequencer's per-tick cost should track active work, not the numeric value +// of a public tag. Tagged definitions are stored separately from the small +// anonymous direct-scheduling pool, and active executions occupy a bounded +// pool. Consequently one sequence at tag 0 and one at tag max-1 have the same +// scan cost. // // Build/run with `make ctest`. @@ -53,10 +40,12 @@ static void seq_note_on(int32_t tag, int osc) { e.ticks[TICKS_PERIOD] = 16; e.ticks[TICKS_TAG] = (uint32_t)tag; amy_add_event(&e); + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_START, 0, 0); } -// Clearing is a send to the same tag with neither tick nor period. +// Stop active playback, then clear the future definition. static void seq_clear(int32_t tag) { + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_STOP, 0, 0); amy_event e = amy_default_event(); e.ticks[TICKS_TICK] = 0; e.ticks[TICKS_PERIOD] = 0; @@ -105,10 +94,8 @@ static void test_out_of_order_and_clear(void) { all_off(); } -// Anonymous entries (1- or 2-value ticks=, no tag) live past the user tag -// range. They should fire once, disappear, and — with the active list — -// leave no lasting per-tick cost behind. Under the old sweep, one -// anonymous entry pinned the scan at the far end of the table forever. +// Anonymous entries (1- or 2-value ticks=, no tag) use a separate pool. They +// should fire once, disappear, and leave no lasting per-tick cost behind. static void test_anonymous_one_shots(void) { printf("anonymous one-shots fire once and leave the list empty\n"); sequencer_reset(); diff --git a/tests/test_sequencer_bounds.c b/tests/test_sequencer_bounds.c index 960e3989..ba886364 100644 --- a/tests/test_sequencer_bounds.c +++ b/tests/test_sequencer_bounds.c @@ -1,13 +1,12 @@ // Regression test for the sequencer tag bounds check. // -// User-addressable tags index `sequences[0 .. max_sequences-1]`, and the -// anonymous pool lives immediately after, at -// [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS). An earlier +// User-addressable tags once indexed `sequences[0 .. max_sequences-1]`, with +// the anonymous pool immediately after it. An earlier // version of the sequencer guarded with `tag > max_sequences` (and read // the tag into an int32_t), which let tag == max_sequences write one // entry past the user range — in those days one element past the whole -// allocation, a heap overflow; today it would silently clobber an -// anonymous entry instead. sequencer_add_wire() now checks +// allocation, a heap overflow. Tagged definitions and anonymous direct events +// now use separate storage, and sequencer_add_wire() still checks // `tag >= (uint32_t)max_sequences` unsigned, which also disposes of the // negative-reindex case: a tag past INT32_MAX stays a huge unsigned // value and fails the same compare, so it can never index backwards. @@ -76,16 +75,14 @@ static int audible(int osc) { return synth[osc] != NULL && synth[osc]->status == SYNTH_AUDIBLE; } -// Whether a tag was accepted is observable two ways: the sequence fires -// (osc goes audible), and something is in the active list at all. -extern int32_t first_active; - static int accepted(uint32_t tag) { sequencer_reset(); seq_note_on_at_tag(tag, 0); + int scheduled = sequencer_sequence_control( + tag, SEQUENCE_CONTROL_START, 0, 0); advance_secs(0.5); int fired = audible(0); - int scheduled = (first_active != -1); + sequencer_sequence_control(tag, SEQUENCE_CONTROL_STOP, 0, 0); seq_clear(tag); all_off(); sequencer_reset(); @@ -110,9 +107,7 @@ static void test_tag_bounds(void) { CHECK(!accepted(0x80000000u), "a tag past INT32_MAX is rejected"); } -// An out-of-range user tag must not clobber the anonymous pool that sits -// right past the user range. Occupy anonymous slot 0 (the entry a -// too-lenient check would land tag==max on), then try to overwrite it. +// An out-of-range user tag must not affect the separate anonymous pool. static void test_no_anon_clobber(void) { printf("an out-of-range tag can't clobber an anonymous entry\n"); sequencer_reset(); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 9d057b37..644d2cfd 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -56,28 +56,26 @@ static int marks_named(const char *name) { return count; } -static void test_legacy_ticks_are_unchanged(void) { - printf("legacy root ticks remain unchanged\n"); +static void test_untagged_ticks_and_cumulative_tags(void) { + printf("untagged root ticks and cumulative tagged sequences\n"); sequencer_reset(); clear_marks(); uint32_t first = next_boundary(sequencer_ticks(), 4); - amy_add_message("H0,4,0zProotZ"); + amy_add_message("H0,4zProotZ"); clock_to(first + 4); CHECK(mark_at("root", first), "periodic root event fires at global modulo"); CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); - amy_add_message("H0,0,0Z"); + sequencer_reset(); clear_marks(); - uint32_t target = sequencer_ticks() + 4; - char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); - amy_add_message(wire); - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); - amy_add_message(wire); - clock_to(target); - CHECK(!marks_named("old") && mark_at("new", target), - "legacy tagged writes still replace rather than accumulate"); + amy_add_message("H0,0,9zPfirstZ"); + amy_add_message("H2,0,9zPsecondZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC9,1,4Z"); + clock_to(start + 2); + CHECK(mark_at("first", start) && mark_at("second", start + 2), + "repeating a tag cumulates ordinary events into one sequence"); } static void test_legacy_c_event_wire_is_unchanged(void) { @@ -98,8 +96,8 @@ static void test_explicit_append_and_one_shot_lifetime(void) { printf("explicit sequence events accumulate and finite events retire\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA10,0,0zPzeroZ"); - amy_add_message("HA10,2,0zPtwoZ"); + amy_add_message("H0,0,10zPzeroZ"); + amy_add_message("H2,0,10zPtwoZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); clock_to(start + 4); @@ -109,39 +107,34 @@ static void test_explicit_append_and_one_shot_lifetime(void) { "period-zero sequence events fire once and execution retires"); } -static void test_root_and_stored_forms_share_one_tag_identity(void) { - printf("legacy and reusable forms share one public tag identity\n"); +static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { + printf("empty tick-zero reset remains distinct from a tick-zero event\n"); sequencer_reset(); clear_marks(); - amy_add_message("H0,4,10zProot-replacedZ"); - amy_add_message("HA10,0,0zPstoredZ"); + amy_add_message("H0,0,10zPstoredZ"); + amy_add_message("H0,0,10Z"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "an empty H0,0,tag resets that tag"); + amy_add_message("H0,0,10zPstoredZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); - clock_to(start + 4); - CHECK(mark_at("stored", start) && !marks_named("root-replaced"), - "explicit append replaces the root object at the same tag"); - - amy_add_message("H0,4,10zProotZ"); - CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), - "legacy replacement removes the future stored definition"); - clear_marks(); - uint32_t root = next_boundary(sequencer_ticks(), 4); - clock_to(root); - CHECK(mark_at("root", root), "the replacement legacy event remains active"); + clock_to(start); + CHECK(mark_at("stored", start), + "H0,0,tag with a payload is a local tick-zero event"); } static void test_active_definition_is_immutable(void) { printf("active executions retain the definition they started with\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA11,0,0zPold-headZ"); - amy_add_message("HA11,4,0zPold-tailZ"); + amy_add_message("H0,0,11zPold-headZ"); + amy_add_message("H4,0,11zPold-tailZ"); uint32_t old_start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC11,1,4Z"); clock_to(old_start + 2); amy_add_message("HR11Z"); - amy_add_message("HA11,0,0zPnew-headZ"); + amy_add_message("H0,0,11zPnew-headZ"); clock_to(old_start + 4); CHECK(mark_at("old-tail", old_start + 4), "resetting future contents does not remove an old note release"); @@ -159,10 +152,10 @@ static void test_root_launches_local_zero_on_same_tick(void) { printf("root events can launch stored sequences\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA12,0,0zPchild-zeroZ"); + amy_add_message("H0,0,12zPchild-zeroZ"); uint32_t start = sequencer_ticks() + 4; char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,1HC12,1,0Z", start); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC12,1,0Z", start); amy_add_message(wire); clock_to(start); CHECK(mark_at("child-zero", start), @@ -173,13 +166,13 @@ static void test_overlapping_executions_need_no_host_identity(void) { printf("one sequence tag supports bounded overlapping executions\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA13,0,0zPonZ"); - amy_add_message("HA13,4,0zPoffZ"); + amy_add_message("H0,0,13zPonZ"); + amy_add_message("H4,0,13zPoffZ"); uint32_t first = next_boundary(sequencer_ticks(), 4); char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,2HC13,1,0Z", first); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first); amy_add_message(wire); - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,3HC13,1,0Z", first + 2); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first + 2); amy_add_message(wire); clock_to(first + 6); CHECK(mark_at("on", first) && mark_at("on", first + 2), @@ -192,9 +185,9 @@ static void test_parent_stop_leaves_started_child_to_finish(void) { printf("stopping a parent prevents future children without truncating one\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA15,0,0zPnote-onZ"); - amy_add_message("HA15,4,0zPnote-offZ"); - amy_add_message("HA14,0,4HC15,1,0Z"); + amy_add_message("H0,0,15zPnote-onZ"); + amy_add_message("H4,0,15zPnote-offZ"); + amy_add_message("H0,4,14HC15,1,0Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC14,1,4Z"); clock_to(start + 2); @@ -211,9 +204,9 @@ static void test_controller_sequence_bounds_repetition(void) { printf("a finite controller sequence can bound a periodic child\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA8,0,4zPpulseZ"); - amy_add_message("HA7,0,0HC8,1,0Z"); - amy_add_message("HA7,12,0HC8,0,0Z"); + amy_add_message("H0,4,8zPpulseZ"); + amy_add_message("H0,0,7HC8,1,0Z"); + amy_add_message("H12,0,7HC8,0,0Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC7,1,4Z"); clock_to(start + 14); @@ -228,7 +221,7 @@ static void test_finite_gate_preserves_phase(void) { printf("finite event gating preserves the target phase\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA6,0,4zPbeatZ"); + amy_add_message("H0,4,6zPbeatZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC6,1,4Z"); clock_to(start); @@ -245,7 +238,7 @@ static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA5,0,0zPsurvivorZ"); + amy_add_message("H0,0,5zPsurvivorZ"); amy_add_message("HC5,1,0Z"); uint32_t start = sequencer_ticks() + 1; amy_add_message("HR5Z"); @@ -255,7 +248,7 @@ static void test_per_tag_and_global_reset_semantics(void) { CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), "per-tag reset removed the future definition"); - amy_add_message("HA5,0,4zPclearedZ"); + amy_add_message("H0,4,5zPclearedZ"); amy_add_message("HC5,1,0Z"); sequencer_reset(); CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), @@ -266,7 +259,7 @@ static void test_timebase_reset_keeps_definitions(void) { printf("timebase reset drops runtime but keeps definitions\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA4,0,0zPafter-rebaseZ"); + amy_add_message("H0,0,4zPafter-rebaseZ"); amy_add_message("HC4,1,0Z"); sequencer_sequence_reset_timebase(); clock_to(sequencer_ticks() + 2); @@ -287,7 +280,7 @@ static void test_bounds_and_validation(void) { "tick equal to period is rejected"); CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), "empty payload is rejected"); - CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("HA1,0,0zPbadZ")), + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), "stored sequences cannot edit definitions recursively"); for (uint32_t i = 0; i < 8; ++i) { @@ -311,8 +304,8 @@ static void test_start_crosses_clock_rollover(void) { printf("relative sequence phase crosses uint32 clock rollover\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA2,0,0zPwrap-zeroZ"); - amy_add_message("HA2,2,0zPwrap-twoZ"); + amy_add_message("H0,0,2zPwrap-zeroZ"); + amy_add_message("H2,0,2zPwrap-twoZ"); amy_global.sequencer_tick_count = UINT32_MAX - 2; amy_add_message("HC2,1,4Z"); clock_to(2); @@ -351,10 +344,10 @@ int main(void) { config.max_sequence_executions = 8; amy_start(config); - test_legacy_ticks_are_unchanged(); + test_untagged_ticks_and_cumulative_tags(); test_legacy_c_event_wire_is_unchanged(); test_explicit_append_and_one_shot_lifetime(); - test_root_and_stored_forms_share_one_tag_identity(); + test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_root_launches_local_zero_on_same_tick(); test_overlapping_executions_need_no_host_identity(); From 06309fa988cdec69c8e72362146704798ec24637 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:18:15 +0200 Subject: [PATCH 031/112] Document cumulative sequencer tags --- docs/api.md | 7 +- docs/billie_jean.md | 2 +- docs/midi.md | 8 +- docs/sequencer-sequences-abstractions.md | 107 +++++++++-------------- docs/sequencer-sequences-howto.md | 68 +++++--------- docs/sequencer-sequences.md | 89 ++++++++----------- docs/synth.md | 21 ++--- docs/tutorial.html | 17 ++-- 8 files changed, 128 insertions(+), 191 deletions(-) diff --git a/docs/api.md b/docs/api.md index a68cdbbc..b59b8e9c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,7 +203,7 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_sequencer_tags` | Int | 256 | Size of the tag space shared by legacy root events and reusable sequences | +| `max_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | | `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | | `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `max_voices` | Int | 64 | How many voices | @@ -505,9 +505,8 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Existing tick, period and tag scheduling. `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. A legacy tagged write keeps its replace-by-tag behavior. **If used in a wire string message**, the `H` **must** be the first character of the message. | -| `HA` | — | `sequence_event` | tag,tick,period | Explicitly append an ordinary event to a [reusable tagged sequence](sequencer-sequences.md). Prefer `amy.define_sequence()` in Python. | -| `HR` | — | `sequence_reset` | tag | Clear the future root event and reusable definition at one tag; already-started immutable executions may finish. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | | `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | diff --git a/docs/billie_jean.md b/docs/billie_jean.md index 56a38cad..3ec4ea56 100644 --- a/docs/billie_jean.md +++ b/docs/billie_jean.md @@ -293,7 +293,7 @@ timed_note chord_notes[] = { }; ``` -We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns - `e.ticks[1]` is a repeat period and `e.ticks[2]` a tag you can use to replace or cancel an entry - but here we only need the one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. +We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns with `e.ticks[1]`, while `e.ticks[2]` adds the event to a reusable tagged sequence; here we only need the untagged one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. ```C float amy_ticks_per_tick = 24.0f; diff --git a/docs/midi.md b/docs/midi.md index 6ef8988e..0dcd6643 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -81,9 +81,9 @@ Because an `AMY_MIDI` osc emits MIDI in response to ordinary note events, you ca amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender once # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. -amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each 48-tick period -amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each 48-tick period +amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 +amy.send(osc=0, note=60, vel=0, ticks="24,48,1") +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 48)) ``` -AMY keeps sending those MIDI messages out the port at the configured tempo until you remove them (by their `tag`) or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. - +AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 310d1c16..266dbd70 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -2,86 +2,61 @@ ## Public model -The public model has two ways to use the existing sequencer tag identity: +The existing sequencer tag is the sequence identity. Every ordinary +`ticks=(tick, period, tag)` message appends an event to that tag. The tag is +reset explicitly and controlled with one start/stop operation. There is no +second group namespace, separate append command, fourth `ticks` field, +explicit length, or publish/revision operation. -1. `ticks=(tick, period, tag)` keeps the established single-event behavior; -2. `define_sequence(tag, events)` explicitly gives that tag multiple local - events which can be started and stopped as a reusable sequence. - -There is no second public group ID, no local event-tag namespace, no fourth -`ticks` field, no explicit length, and no publish/revision command. - -`sequence_control` supplies the three generic runtime operations: +`sequence_control` provides: - start, optionally aligned to an AMY sequencer period; -- stop every active execution of the tag at an optional alignment boundary; +- stop all active executions of a tag at an optional boundary; - gate ordinary events for a finite duration without resetting local phase. -Sequences may start or stop other sequences. A finite controller sequence can -therefore express a fixed repeat count, and a parent can stop launching new -note-pair children while children already in progress deliver their note-offs. - -## Why executions still exist internally - -A stored definition and an active execution have different lifetimes even -though that distinction is not a second public API. An execution needs a local -start tick and must retain the event data it began with. Without that internal -separation, changing a future phrase could remove a note-off or alter a fill -which is already sounding. - -AMY therefore uses a small bounded execution pool and reference-counted, -copy-on-write definitions. Appending to a definition which an execution still -uses first clones it. The active execution keeps the old snapshot; later starts -see the updated contents. No revision number is exposed to callers. +Sequences may start or stop other sequences. A finite controller can therefore +express a fixed repeat count, and a parent can stop launching new note-pair +children while children already in progress deliver their note-offs. -Multiple finite executions of one tag may overlap. This is important for -ordinary musical phrases whose gate time is longer than the interval between -starts. The execution pool, rather than a caller-managed ID scheme, is the -bound. +## Why executions exist internally -## Lifetime inference +A stored definition and an active execution have different lifetimes without +being different public abstractions. An execution needs a local start tick and +must retain the event data it began with. Otherwise editing a future phrase +could remove a note-off or alter a fill already sounding. -The component events define lifetime: +AMY therefore uses a bounded execution pool and reference-counted copy-on-write +definitions. Editing a definition used by an execution clones it. The active +execution keeps its old snapshot; later starts see the new contents. No +revision number or execution ID is exposed. -- if every event has `period=0`, the execution retires after its greatest local - tick has been processed; -- if any event has a nonzero period, the execution remains active and evaluates - that event against elapsed local time until stopped. +Finite executions of one tag may overlap. This supports phrases whose note +gate exceeds their trigger interval without transferring note state to the +caller. -This avoids an independent length that could disagree with the ordinary -sequencer periods. A fixed number of repeats is composition: a finite parent -starts a periodic child and stops it at the required local tick. +## Lifetime inference and tick processing -## Tick processing +If every event has `period=0`, the execution retires after its greatest local +tick. If any event has a nonzero period, it remains active until stopped. -Only active root entries and active sequence executions are visited per tick. -Stored but inactive definitions have no per-tick cost. +Only untagged scheduled entries and active sequence executions are visited per +tick. Stored inactive definitions have no per-tick cost. Sequence controls are +processed before ordinary events, so a boundary stop prevents an event on that +boundary and a child start can include local tick zero on the same tick. -Sequence controls are processed before ordinary events for a tick. Consequently -a stop scheduled at a period boundary prevents the event on that boundary, and -a parent launch can make a child's local tick-zero event run on the launch tick. - -Temporary gating suppresses ordinary payload dispatch but advances elapsed -local time normally. Control events are not gated; otherwise a controller could -mute its own future stop or recovery operation. +Gating suppresses ordinary payload dispatch while elapsed local time advances. +Control events are not gated, preventing a controller from muting its own +recovery operation. ## Bounds and recovery -All storage is configured at startup: - -- `max_sequencer_tags`: shared public identities; -- `max_sequence_events`: maximum events in one stored definition; -- `max_sequence_executions`: active and pending executions. - -Definitions allocate event storage only when first used. The render path does -not perform unbounded allocation. A recursive or cyclic control graph can fill -the execution pool, but cannot grow past it; further starts fail and the caller -can stop a tag or reset the sequencer. - -## Compatibility boundary +Startup configuration bounds tags, events per definition, and simultaneous +executions. A cyclic control graph may fill the execution pool, but cannot grow +beyond it; later starts fail clearly and the caller can stop a tag or reset the +sequencer. -The legacy parser, C event layout, anonymous-event pool, modulo timing, -same-tag replacement, MIDI/external-clock behavior, and root active-list order -are unchanged. Reusable accumulation only occurs through the explicit sequence -API. Tests cover both the old path and the interaction between legacy and -reusable forms. +The ordinary three-field C event layout remains unchanged. Untagged one-off +and periodic scheduling, MIDI/external-clock behavior, and global reset retain +their existing behavior. The intentional API change is that a supplied tag now +creates a stopped reusable sequence and repeated writes cumulate instead of +replacing one scheduled event. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index c2bf891b..88c49dab 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,13 +1,10 @@ # Reusable sequence how-to -This example preloads two simple arpeggios, launches them from the root -sequencer, and changes which one will launch without cutting short a note which -already started. +This example preloads two arpeggios and switches between them without cutting +short a note which already started. ## 1. Define note-pair sequences -Each finite child owns its note-on and note-off: - ```python amy.define_sequence(20, [ dict(ticks=(0,), synth=1, note=60, vel=1), @@ -21,8 +18,6 @@ amy.define_sequence(21, [ ## 2. Define two arpeggio parents -The parents contain only starts of their note-pair children: - ```python amy.define_sequence(30, [ dict(ticks=(0, 48), @@ -39,54 +34,44 @@ amy.define_sequence(31, [ ]) ``` -Because these parents contain periodic events, they run until stopped. +The parents contain periodic events and run until stopped. -## 3. Start the first arpeggio +## 3. Start and switch ```python amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) -``` - -The start is aligned to the next 48-tick boundary. -## 4. Switch parents - -```python +# Later, switch both parents at the same boundary. amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) ``` -Both controls select the same next boundary. The old parent starts no more -children there. A note-pair child which started earlier remains independent and -still sends its tick-18 note-off. +The old parent starts no more children at that boundary. A note-pair child +started earlier remains independent and still sends its tick-18 note-off.
Equivalent low-level wire messages -The Python API above emits these sequence-authoring messages: - ```text HR20Z -HA20,0,0n60l1i1Z -HA20,18,0n60l0i1Z +H0,0,20n60l1i1Z +H18,0,20n60l0i1Z HR21Z -HA21,0,0n64l1i1Z -HA21,18,0n64l0i1Z +H0,0,21n64l1i1Z +H18,0,21n64l0i1Z HR30Z -HA30,0,48HC20,1,1Z -HA30,24,48HC21,1,1Z +H0,48,30HC20,1,1Z +H24,48,30HC21,1,1Z HR31Z -HA31,0,24HC20,1,1Z -HA31,12,24HC21,1,1Z +H0,24,31HC20,1,1Z +H12,24,31HC21,1,1Z HC30,1,48Z HC30,0,48Z HC31,1,48Z ``` -`HA` is the explicit cumulative event form, `HR` resets the future contents of -one tag, and `HC` controls a tagged sequence. They are all part of the -sequencer-oriented `H` family. Existing `Htick,period,tag...` messages retain -their original replace-by-tag behavior. +Ordinary `Htick,period,tag...` messages cumulate behind the tag. `HR` resets +one definition and `HC` controls its executions.
@@ -100,22 +85,13 @@ clock: amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) ``` -After 48 ticks the gate expires automatically and events resume on their -original phase. Duration zero removes a current gate explicitly: +After 48 ticks the gate expires and events resume on their original phase. +Duration zero removes a current gate explicitly: ```python amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) ``` -
-Equivalent low-level wire messages - -```text -HC50,2,48,1Z -HC50,2,0,1Z -``` - -
- -The source of these commands could be a foot pedal, UI, network controller, or -another sequence. AMY only sees generic tagged sequence control. +The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their +source may be a foot pedal, UI, network controller, or another sequence; AMY +only sees generic tagged sequence control. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 37bcb289..fc006c3a 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,13 +1,12 @@ # Reusable sequencer sequences -AMY's existing sequencer tags can also identify reusable sequences. A reusable -sequence is a collection of ordinary AMY events with local `tick` and `period` -values. It can be started from Python, from the wire protocol, or from another -sequenced event. +A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending +more than one event with the same tag accumulates those events, in the same way +that repeated `synth=` messages configure one synth. Tagged events use local +ticks and remain inactive until the sequence is started. -The ordinary three-value `ticks=(tick, period, tag)` API remains unchanged. A -legacy tagged write replaces the event at that tag. Multi-event accumulation is -always explicit. +Untagged `ticks` events keep their direct scheduling behavior on the global +sequencer clock. ## Defining a sequence @@ -20,28 +19,20 @@ amy.define_sequence(40, [ ]) ``` -Each event uses the normal AMY keyword arguments. Its `ticks` value is local to -the start of the sequence and contains `tick` plus an optional `period`. - -`define_sequence()` validates every event before sending anything. It then -performs a per-tag reset followed by explicit cumulative writes. If a sequence -may be launched while it is being rewritten, first remove or stop those future -launches. An execution which already started is safe: it retains the immutable -definition it started with, including later note-offs. - -Low-level callers can use `sequence_reset` and `sequence_event` directly: +Each event uses normal AMY keyword arguments. Its `ticks` value is local to the +start of the sequence and contains `tick` plus an optional `period`. +`define_sequence()` validates every event, resets the tag, then sends ordinary +tagged `ticks` messages: ```python amy.send(sequence_reset=40) -amy.send(sequence_event=(40, 0, 0), synth=2, note=60, vel=1) -amy.send(sequence_event=(40, 12, 0), synth=2, note=60, vel=0) +amy.send(ticks=(0, 0, 40), synth=2, note=60, vel=1) +amy.send(ticks=(12, 0, 40), synth=2, note=60, vel=0) ``` -The sequence tag and legacy root tag are one identity space. Writing a legacy -tagged `ticks` event replaces the future reusable definition at that tag; -explicitly appending a reusable event removes the future legacy root event at -that tag. Applications should assign distinct tags to stored phrases and root -launch events. +Repeating tag `40` accumulates both events. `sequence_reset=40` explicitly +replaces the definition; the empty wire form `H0,0,40Z` is an equivalent reset. +With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping @@ -52,14 +43,13 @@ amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) The optional final value is `alignment_period`. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next -tick divisible by that period. When a root sequencer event fires a start on a -tick, the child sequence's local tick zero participates in that same tick. +global tick divisible by that period. When a sequenced parent starts a child, +the child's local tick zero participates in the same tick. -A start creates a bounded execution. More than one execution of a finite -sequence may overlap; no caller-generated execution ID is required. Stop -targets every active execution of the tag. Stopping a parent prevents its -future child starts but does not stop child sequences which already started. -This lets a note-on/note-off child own its complete lifetime. +A start creates a bounded execution. Finite executions of one tag may overlap, +so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets +all active executions of that tag. Stopping a parent prevents future child +starts, while children already started retain their own event pairs. ## Finite and repeating lifetime @@ -67,10 +57,9 @@ No explicit sequence length or publish action is needed: - a definition containing only `period=0` events is finite and retires after its last event; -- an event with nonzero `period` repeats on its local period, and keeps that - execution alive until it is stopped; -- a controlling finite sequence can start a periodic child at local tick zero - and stop it after a chosen number of periods. +- an event with nonzero `period` repeats on its local period until stopped; +- a finite controller sequence can start a periodic child and stop it after a + chosen number of periods. ## Temporary event gating @@ -79,34 +68,26 @@ amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) ``` This suppresses ordinary event dispatch from active executions of tag `40` for -24 ticks. Their local phase continues and dispatch resumes on the original -phase. Audio which is already ringing is not cut off. Nested sequence controls -remain active while ordinary payload events are gated, so controller sequences -can still complete their lifecycle. - -Gate duration `0` removes a gate at the selected alignment boundary. +24 ticks. Local phase continues, and dispatch resumes on the original phase. +Audio already ringing is not cut off. Nested sequence controls remain active, +so a controller sequence can still complete its lifecycle. Duration zero +removes a gate at the selected boundary. ## Reset behavior -- `amy.send(sequence_reset=tag)` removes the future legacy/root event and the - future reusable definition for that tag. Active immutable executions finish. -- `RESET_TIMEBASE` discards active/pending executions because their absolute +- `amy.send(sequence_reset=tag)` removes the future definition. Active + executions retain the snapshot they started with and may finish. +- `RESET_TIMEBASE` discards active or pending executions because their absolute activation ticks cannot be rebased, but retains stored definitions. -- `RESET_SEQUENCER` retains its global meaning: it clears root events, reusable - definitions, and active/pending executions. +- `RESET_SEQUENCER` clears untagged events, tagged definitions, and executions. ## Capacity and realtime behavior -`max_sequencer_tags` bounds the shared tag space. `max_sequence_events` bounds -the number of events in one reusable definition, and -`max_sequence_executions` independently bounds active or alignment-pending -executions. Definitions are allocated only for tags which use them, and +`max_sequencer_tags` bounds public tag identities. `max_sequence_events` bounds +the number of events in one definition, and `max_sequence_executions` bounds +active or alignment-pending executions. Definitions allocate only when used; inactive definitions are not scanned on each tick. -Starts fail clearly when the execution pool is full. Cyclic sequence launches -cannot allocate beyond that fixed pool and can be recovered with targeted stop -commands or `RESET_SEQUENCER`. - See the [implementation model](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and [step-by-step examples](sequencer-sequences-howto.md). diff --git a/docs/synth.md b/docs/synth.md index 6f2ec886..9b999f27 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -225,8 +225,6 @@ You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All thr ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now -amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7 -amy.send(osc=0, ticks="0,0,7") # cancel tag 7 amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable amy.reset() # Stop everything ``` @@ -237,18 +235,22 @@ You can schedule repeating events (like a step sequencer or drum machine) with ` For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on. -`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. +`tag` is optional. Without one, an event is scheduled directly on the global +sequencer clock and cannot be addressed individually. With a tag, the event is +added to a reusable sequence and its tick becomes local to each start of that +sequence. Repeating a tag accumulates events; reset the tag explicitly before +replacing its contents. If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. ### Reusable tagged sequences -An existing sequencer tag can explicitly hold several ordinary events with -local tick values. `amy.define_sequence(tag, events)` replaces that reusable -definition, while legacy three-value `ticks=(tick, period, tag)` retains its -single-event replace behavior. `sequence_control` starts, stops, aligns, or -temporarily gates an active tagged sequence. Component periods define looping; -a definition containing only period-zero events finishes after its last event. +A sequencer tag holds one or more ordinary events with local tick values. +Repeated three-value `ticks=(tick, period, tag)` messages cumulate behind the +same tag. `amy.define_sequence(tag, events)` is the convenient replace-as-a-list +operation. `sequence_control` starts, stops, aligns, or temporarily gates an +active tagged sequence. Component periods define looping; a definition +containing only period-zero events finishes after its last event. See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API and lifecycle reference. The accompanying guides explain the @@ -490,4 +492,3 @@ amy.start_sample(preset=1024, source=amy.SAMPLE_FROM_OUTPUT, max_frames=11025, m amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play back AUDIO_IN sample an octave higher amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - diff --git a/docs/tutorial.html b/docs/tutorial.html index 6f0bdcae..eb6df9cf 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,16 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence_control=(2, amy.SEQUENCE_CONTROL_START, 1))
-

You can remove or update sequence events by addressing their tag number

+

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(ticks=",,1") # remove the eighth note sequence -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, note=70, ticks=",48,2") # change the quarter note event +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence_reset=1) +amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 1))

For patterns you want to also address their "slots", which is the offset within the pattern, like this

+amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,2") # ninth slot of a 16 1/8th note drum machine +amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 384))
@@ -287,5 +294,3 @@ < - - From 21395160b76c224d11d0e6c3c9d2e2adef443235 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:35:17 +0200 Subject: [PATCH 032/112] Align sequence test terminology --- tests/test_sequencer_sequences.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 644d2cfd..816edffa 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -92,8 +92,8 @@ static void test_legacy_c_event_wire_is_unchanged(void) { "C ticks serialization remains three values: %s", wire); } -static void test_explicit_append_and_one_shot_lifetime(void) { - printf("explicit sequence events accumulate and finite events retire\n"); +static void test_repeated_tag_and_one_shot_lifetime(void) { + printf("repeated tagged events accumulate and finite events retire\n"); sequencer_reset(); clear_marks(); amy_add_message("H0,0,10zPzeroZ"); @@ -346,7 +346,7 @@ int main(void) { test_untagged_ticks_and_cumulative_tags(); test_legacy_c_event_wire_is_unchanged(); - test_explicit_append_and_one_shot_lifetime(); + test_repeated_tag_and_one_shot_lifetime(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_root_launches_local_zero_on_same_tick(); From fca1579591cb4301d0f0583cc5ae8d8d2cb531aa Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:39:42 +0200 Subject: [PATCH 033/112] Remove retired sequence append from Godot --- godot/amy.gd | 150 +++++++++++++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 76 deletions(-) diff --git a/godot/amy.gd b/godot/amy.gd index 618d6cb4..165a0d31 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -292,7 +292,6 @@ func _format_ctrl(val: Variant) -> String: # ============================================================ # BEGIN GENERATED - scripts/gen_amy_gd_api.py var _KW_MAP: Dictionary = { - "sequence_event": ["HA", "L"], "ticks": ["H", "L"], "osc": ["v", "I"], "wave": ["w", "I"], @@ -370,81 +369,80 @@ var _KW_MAP: Dictionary = { } var _KW_PRIORITY: Dictionary = { - "sequence_event": 0, - "ticks": 1, - "osc": 2, - "wave": 3, - "note": 4, - "vel": 5, - "amp": 6, - "freq": 7, - "duty": 8, - "feedback": 9, - "reset": 10, - "phase": 11, - "sample_offset": 12, - "fit": 13, - "fit_search": 14, - "pan": 15, - "client": 16, - "volume": 17, - "pitch_bend": 18, - "filter_freq": 19, - "resonance": 20, - "bp0": 21, - "bp1": 22, - "eg0": 23, - "eg1": 24, - "eg0_type": 25, - "eg1_type": 26, - "debug": 27, - "chained_osc": 28, - "mod_source": 29, - "eq": 30, - "filter_type": 31, - "ratio": 32, - "latency_ms": 33, - "dist_clip": 34, - "dist_fold": 35, - "dist_crush": 36, - "dist_drive": 37, - "dist_mix": 38, - "algo_source": 39, - "load_sample": 40, - "transfer_file": 41, - "disk_sample": 42, - "algorithm": 43, - "chorus": 44, - "reverb": 45, - "echo": 46, - "patch": 47, - "sequence_reset": 48, - "sequence_control": 49, - "external_channel": 50, - "portamento": 51, - "tempo": 52, - "sequencer_run": 53, - "external_midi_sync": 54, - "synth": 55, - "pedal": 56, - "synth_flags": 57, - "num_voices": 58, - "oscs_per_voice": 59, - "synth_level": 60, - "to_synth": 61, - "grab_midi_notes": 62, - "note_source_channel": 63, - "synth_delay": 64, - "preset": 65, - "num_partials": 66, - "start_sample": 67, - "stop_sample": 68, - "bus": 69, - "mode": 70, - "midi_cc": 71, - "midi_note_cmd": 72, - "cv_trigger": 73, - "patch_string": 74, + "ticks": 0, + "osc": 1, + "wave": 2, + "note": 3, + "vel": 4, + "amp": 5, + "freq": 6, + "duty": 7, + "feedback": 8, + "reset": 9, + "phase": 10, + "sample_offset": 11, + "fit": 12, + "fit_search": 13, + "pan": 14, + "client": 15, + "volume": 16, + "pitch_bend": 17, + "filter_freq": 18, + "resonance": 19, + "bp0": 20, + "bp1": 21, + "eg0": 22, + "eg1": 23, + "eg0_type": 24, + "eg1_type": 25, + "debug": 26, + "chained_osc": 27, + "mod_source": 28, + "eq": 29, + "filter_type": 30, + "ratio": 31, + "latency_ms": 32, + "dist_clip": 33, + "dist_fold": 34, + "dist_crush": 35, + "dist_drive": 36, + "dist_mix": 37, + "algo_source": 38, + "load_sample": 39, + "transfer_file": 40, + "disk_sample": 41, + "algorithm": 42, + "chorus": 43, + "reverb": 44, + "echo": 45, + "patch": 46, + "sequence_reset": 47, + "sequence_control": 48, + "external_channel": 49, + "portamento": 50, + "tempo": 51, + "sequencer_run": 52, + "external_midi_sync": 53, + "synth": 54, + "pedal": 55, + "synth_flags": 56, + "num_voices": 57, + "oscs_per_voice": 58, + "synth_level": 59, + "to_synth": 60, + "grab_midi_notes": 61, + "note_source_channel": 62, + "synth_delay": 63, + "preset": 64, + "num_partials": 65, + "start_sample": 66, + "stop_sample": 67, + "bus": 68, + "mode": 69, + "midi_cc": 70, + "midi_note_cmd": 71, + "cv_trigger": 72, + "patch_string": 73, } ## The control coefficient inputs, in wire order. Prefer naming these in a From 2669c3aecd7a762763a5c45e66c41b64fd1dde48 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:30:24 +0200 Subject: [PATCH 034/112] Cover concurrent sequence definition generations --- tests/test_sequencer_sequences.c | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 816edffa..0d465f02 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -148,6 +148,68 @@ static void test_active_definition_is_immutable(void) { "a later start uses only the replacement definition"); } +static void test_append_while_active_uses_copy_on_write(void) { + printf("appending while active publishes a future definition\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPbaseZ"); + amy_add_message("H6,0,11zPold-tailZ"); + amy_add_message("HC11,1,0Z"); + uint32_t old_start = sequencer_ticks() + 1; + clock_to(old_start + 1); + + amy_add_message("H2,0,11zPappendedZ"); + clock_to(old_start + 6); + CHECK(mark_at("base", old_start) && mark_at("old-tail", old_start + 6), + "the active execution retains its original events"); + CHECK(!mark_at("appended", old_start + 2), + "an append cannot enter an already-running snapshot"); + + clear_marks(); + amy_add_message("HC11,1,0Z"); + uint32_t new_start = sequencer_ticks() + 1; + clock_to(new_start + 6); + CHECK(mark_at("base", new_start) + && mark_at("appended", new_start + 2) + && mark_at("old-tail", new_start + 6), + "a later execution sees the cumulative appended definition"); +} + +static void test_three_definition_generations_overlap(void) { + printf("three immutable definition generations can overlap\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPbaseZ"); + amy_add_message("H12,0,12zPtailZ"); + + amy_add_message("HC12,1,0Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + + amy_add_message("H2,0,12zPsecondZ"); + amy_add_message("HC12,1,0Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(second_start); + + amy_add_message("H4,0,12zPthirdZ"); + amy_add_message("HC12,1,0Z"); + uint32_t third_start = sequencer_ticks() + 1; + clock_to(third_start + 12); + + CHECK(mark_at("base", first_start) + && !mark_at("second", first_start + 2) + && !mark_at("third", first_start + 4), + "the first execution keeps generation one"); + CHECK(mark_at("base", second_start) + && mark_at("second", second_start + 2) + && !mark_at("third", second_start + 4), + "the second execution keeps generation two"); + CHECK(mark_at("base", third_start) + && mark_at("second", third_start + 2) + && mark_at("third", third_start + 4), + "the third execution sees generation three"); +} + static void test_root_launches_local_zero_on_same_tick(void) { printf("root events can launch stored sequences\n"); sequencer_reset(); @@ -349,6 +411,8 @@ int main(void) { test_repeated_tag_and_one_shot_lifetime(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); + test_append_while_active_uses_copy_on_write(); + test_three_definition_generations_overlap(); test_root_launches_local_zero_on_same_tick(); test_overlapping_executions_need_no_host_identity(); test_parent_stop_leaves_started_child_to_finish(); From b2a88659ac683cef58a38316c86a216c4ae40687 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:35:15 +0200 Subject: [PATCH 035/112] Move sequence version reclamation off render path --- docs/sequencer-sequences-abstractions.md | 15 ++ src/sequencer.c | 215 ++++++++++++++++++----- 2 files changed, 186 insertions(+), 44 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 266dbd70..0e5101ea 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -30,6 +30,21 @@ definitions. Editing a definition used by an execution clones it. The active execution keeps its old snapshot; later starts see the new contents. No revision number or execution ID is exposed. +The copy is constructed while the old definition is pinned, but outside the +queue lock also used by rendering. Publication is a short checked pointer swap. +When the last execution releases an obsolete definition, the render path links +it onto an intrusive retirement list; a later non-rendering control call +detaches that list and performs the variable-time string and heap frees. The +audio path therefore neither copies nor frees a definition. + +This is reference-counted deferred reclamation, not a tracing garbage +collector. A fixed two-buffer ping-pong is insufficient because overlapping or +repeating executions can retain more than two generations at once. Allocating +versions only when an active definition is edited keeps the normal preload path +linear and bounds retained generations through the configured execution pool. +This matters in particular on embedded targets, where allocator and external- +memory/cache latency must not extend a render-thread critical section. + Finite executions of one tag may overlap. This supports phrases whose note gate exceeds their trigger interval without transferring note state to the caller. diff --git a/src/sequencer.c b/src/sequencer.c index ffde3932..e7ad12ed 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -1,6 +1,8 @@ #include "sequencer.h" #include "amy.h" +#include + #ifdef __EMSCRIPTEN__ #include #endif @@ -59,6 +61,10 @@ typedef struct stored_sequence_definition_t { uint32_t last_one_shot_tick; bool has_periodic_event; uint32_t refs; + // Zero-reference definitions are linked here by the render path. A + // non-rendering sequence API call detaches the complete list under the + // queue lock and performs the variable-time frees after releasing it. + struct stored_sequence_definition_t *next_retired; } stored_sequence_definition_t; typedef struct stored_sequence_slot_t { @@ -85,6 +91,7 @@ static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; static volatile bool stored_sequence_wire_firing = false; +static stored_sequence_definition_t *retired_sequence_definitions = NULL; static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { @@ -93,17 +100,57 @@ static bool checked_array_size(uint32_t count, size_t element_size, return true; } -static void stored_sequence_definition_release( +static void stored_sequence_definition_destroy( stored_sequence_definition_t *definition) { - if (definition == NULL || definition->refs == 0) return; - definition->refs--; - if (definition->refs != 0) return; + if (definition == NULL) return; for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); free(definition); } +// References are changed only while amy_queue_lock is held. Return the object +// which reached zero so the caller can either retire it (render path) or free +// it after dropping the lock (control path). +static stored_sequence_definition_t *stored_sequence_definition_unref_locked( + stored_sequence_definition_t *definition) { + if (definition == NULL) return NULL; + assert(definition->refs != 0); + definition->refs--; + return definition->refs == 0 ? definition : NULL; +} + +static void stored_sequence_definition_retire_locked( + stored_sequence_definition_t *definition) { + stored_sequence_definition_t *retired = + stored_sequence_definition_unref_locked(definition); + if (retired == NULL) return; + retired->next_retired = retired_sequence_definitions; + retired_sequence_definitions = retired; +} + +static void stored_sequence_definition_destroy_list( + stored_sequence_definition_t *definition) { + while (definition != NULL) { + stored_sequence_definition_t *next = definition->next_retired; + stored_sequence_definition_destroy(definition); + definition = next; + } +} + +// This may be called from sequence API entry points which can also be fired by +// the render thread through a stored HC payload. Reclaim only when this is an +// external/control-side call. Keeping retired objects until the next such call +// is bounded by the tag and execution pools and never delays audio rendering. +static void stored_sequence_reclaim_retired(void) { + if (wire_firing || stored_sequence_wire_firing) return; + amy_grab_lock(); + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + amy_release_lock(); + stored_sequence_definition_destroy_list(retired); +} + static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = (stored_sequence_definition_t *)malloc_caps( @@ -121,6 +168,7 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { definition->last_one_shot_tick = 0; definition->has_periodic_event = false; definition->refs = 1; + definition->next_retired = NULL; return definition; } @@ -143,7 +191,7 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( const stored_sequence_event_t *from = &source->events[i]; copy->events[i].wire = stored_sequence_wire_copy(from->wire); if (copy->events[i].wire == NULL) { - stored_sequence_definition_release(copy); + stored_sequence_definition_destroy(copy); return NULL; } copy->events[i].tick = from->tick; @@ -152,24 +200,25 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( return copy; } -static void stored_sequence_execution_release( +static void stored_sequence_execution_release_deferred( stored_sequence_execution_t *execution) { if (!execution->occupied) return; stored_sequence_definition_t *definition = execution->definition; memset(execution, 0, sizeof(*execution)); - stored_sequence_definition_release(definition); + stored_sequence_definition_retire_locked(definition); } static void stored_sequence_executions_reset(void) { if (sequence_executions == NULL) return; for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) - stored_sequence_execution_release(&sequence_executions[i]); + stored_sequence_execution_release_deferred(&sequence_executions[i]); } static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; for (int32_t i = 0; i < max_sequences; ++i) { - stored_sequence_definition_release(stored_sequences[i].definition); + stored_sequence_definition_retire_locked( + stored_sequences[i].definition); stored_sequences[i].definition = NULL; } } @@ -188,6 +237,9 @@ static void stored_sequences_deinit(void) { max_stored_sequence_events = 0; max_stored_sequence_executions = 0; stored_sequence_event_bytes = 0; + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + stored_sequence_definition_destroy_list(retired); } static void stored_sequences_init(uint32_t events, uint32_t executions) { @@ -450,6 +502,35 @@ static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { return &stored_sequences[tag]; } +static void stored_sequence_definition_append_owned( + stored_sequence_definition_t *definition, uint32_t tick, + uint32_t period, char *wire) { + stored_sequence_event_t *event = + &definition->events[definition->event_count++]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (period != 0) definition->has_periodic_event = true; + else if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; +} + +// A candidate owns the incoming wire in its final event. If publication loses +// a race, detach that event before destroying the private candidate so the +// same caller-owned wire can be retried against the newly published version. +static void stored_sequence_candidate_discard( + stored_sequence_definition_t *candidate, char *wire) { + if (candidate != NULL && candidate->event_count != 0) { + stored_sequence_event_t *event = + &candidate->events[candidate->event_count - 1]; + if (event->wire == wire) { + event->wire = NULL; + candidate->event_count--; + } + } + stored_sequence_definition_destroy(candidate); +} + uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire) { stored_sequence_slot_t *slot = stored_sequence_slot(tag); @@ -484,41 +565,80 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, return 0; } - amy_grab_lock(); - stored_sequence_definition_t *definition = slot->definition; - if (definition == NULL) { - definition = stored_sequence_definition_new(); - } else if (definition->refs > 1) { - definition = stored_sequence_definition_clone(definition); - } - if (definition == NULL) { + stored_sequence_reclaim_retired(); + for (;;) { + amy_grab_lock(); + stored_sequence_definition_t *source = slot->definition; + if (source != NULL + && source->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + + // No execution or other writer can observe a refs==1 definition, so + // appending the already-allocated incoming wire is a bounded mutation. + // This keeps bulk preload O(n) instead of cloning on every event. + if (source != NULL && source->refs == 1) { + stored_sequence_definition_append_owned(source, tick, period, + wire); + amy_release_lock(); + stored_sequence_reclaim_retired(); + return 1; + } + + // Pin a shared source before leaving the lock. From this point it is + // immutable, so allocation and all copying can happen without holding + // up the render thread. + if (source != NULL) source->refs++; amy_release_lock(); - amy_oom("stored sequence edit"); - free(wire); - return 0; - } - if (definition != slot->definition) { - stored_sequence_definition_release(slot->definition); - slot->definition = definition; - } - if (definition->event_count >= max_stored_sequence_events) { - fprintf(stderr, "cannot append event to sequence %" PRIu32 - ": configured limit of %" PRIu32 " events is full\n", - tag, max_stored_sequence_events); + + stored_sequence_definition_t *candidate = source == NULL + ? stored_sequence_definition_new() + : stored_sequence_definition_clone(source); + if (candidate == NULL) { + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + amy_grab_lock(); + dead = stored_sequence_definition_unref_locked(source); + amy_release_lock(); + } + stored_sequence_definition_destroy(dead); + amy_oom("stored sequence edit"); + free(wire); + return 0; + } + stored_sequence_definition_append_owned(candidate, tick, period, wire); + + amy_grab_lock(); + if (slot->definition == source) { + slot->definition = candidate; + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + // Drop the old slot ownership and our temporary writer pin. + dead = stored_sequence_definition_unref_locked(source); + stored_sequence_definition_t *after_pin = + stored_sequence_definition_unref_locked(source); + if (after_pin != NULL) dead = after_pin; + } + amy_release_lock(); + stored_sequence_definition_destroy(dead); + stored_sequence_reclaim_retired(); + return 1; + } + + // Another writer published first. Keep the caller's wire, release our + // source pin, discard the private candidate outside the lock, and retry + // against the new cumulative definition. + stored_sequence_definition_t *dead = source == NULL ? NULL + : stored_sequence_definition_unref_locked(source); amy_release_lock(); - free(wire); - return 0; + stored_sequence_candidate_discard(candidate, wire); + stored_sequence_definition_destroy(dead); } - stored_sequence_event_t *event = - &definition->events[definition->event_count++]; - event->wire = wire; - event->tick = tick; - event->period = period; - if (period != 0) definition->has_periodic_event = true; - else if (tick > definition->last_one_shot_tick) - definition->last_one_shot_tick = tick; - amy_release_lock(); - return 1; } uint8_t sequencer_sequence_reset(uint32_t tag) { @@ -540,10 +660,15 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { return 0; } + stored_sequence_reclaim_retired(); amy_grab_lock(); - stored_sequence_definition_release(slot->definition); + stored_sequence_definition_t *definition = slot->definition; slot->definition = NULL; + stored_sequence_definition_t *dead = + stored_sequence_definition_unref_locked(definition); amy_release_lock(); + stored_sequence_definition_destroy(dead); + stored_sequence_reclaim_retired(); return 1; } @@ -574,6 +699,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, return 0; } + stored_sequence_reclaim_retired(); uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { @@ -624,6 +750,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, "stop=0, start=1, gate=2\n", tag, action); } amy_release_lock(); + stored_sequence_reclaim_retired(); return result; } @@ -658,7 +785,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) || (!definition->has_periodic_event && elapsed > definition->last_one_shot_tick)) { - stored_sequence_execution_release(execution); + stored_sequence_execution_release_deferred(execution); amy_release_lock(); continue; } @@ -689,7 +816,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { } amy_grab_lock(); - stored_sequence_definition_release(definition); + stored_sequence_definition_retire_locked(definition); amy_release_lock(); } } From 7c98ad2bae4d6934bd097de9052a55a47d2ee9b6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:39:53 +0200 Subject: [PATCH 036/112] Reclaim sequence versions at control boundary --- docs/sequencer-sequences-abstractions.md | 5 ++++- src/api.c | 6 ++++++ src/sequencer.c | 25 ++++++++++++++++-------- src/sequencer.h | 3 +++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 0e5101ea..da4eec73 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -35,7 +35,10 @@ queue lock also used by rendering. Publication is a short checked pointer swap. When the last execution releases an obsolete definition, the render path links it onto an intrusive retirement list; a later non-rendering control call detaches that list and performs the variable-time string and heap frees. The -audio path therefore neither copies nor frees a definition. +audio path therefore neither copies nor frees a definition. Internally fired +wire payloads bypass the public wire-ingest boundary, while that public boundary +drains the retirement list after parsing. This makes reclamation a structural +control-path property rather than a best-effort test of concurrent render state. This is reference-counted deferred reclamation, not a tracing garbage collector. A fixed two-buffer ping-pong is insufficient because overlapping or diff --git a/src/api.c b/src/api.c index 978112f5..60f9dd0f 100644 --- a/src/api.c +++ b/src/api.c @@ -2,6 +2,7 @@ // C callable entry points to amy #include "amy.h" +#include "sequencer.h" amy_config_t amy_default_config() { amy_config_t c; @@ -293,6 +294,7 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Transfer status can't change mid-message, so the whole string is // one chunk of transfer payload. parse_transfer_message(message, (uint16_t)strlen(message)); + sequencer_reclaim_retired(); return; } // Fast pre-check of this message for a leading 'H' (ticks) scheduling @@ -303,6 +305,10 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Not scheduled: parse and play every command in the message now. amy_play_message(message); } + // Public wire ingestion is a control-side boundary. Sequence playback uses + // amy_play_message()/handle_ticks_message() directly, so it can never enter + // this reclamation path from the render thread. + sequencer_reclaim_retired(); } // given a wire message string play / schedule the event directly (WIRE API) diff --git a/src/sequencer.c b/src/sequencer.c index e7ad12ed..381aa487 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -138,12 +138,11 @@ static void stored_sequence_definition_destroy_list( } } -// This may be called from sequence API entry points which can also be fired by -// the render thread through a stored HC payload. Reclaim only when this is an -// external/control-side call. Keeping retired objects until the next such call -// is bounded by the tag and execution pools and never delays audio rendering. -static void stored_sequence_reclaim_retired(void) { - if (wire_firing || stored_sequence_wire_firing) return; +// The public wire boundary calls this unconditionally after parsing. Sequence +// entry points also use it opportunistically, except while a render-fired wire +// is active. Keeping the actual destruction here makes that distinction +// explicit instead of trying to infer the caller from concurrent global state. +void sequencer_reclaim_retired(void) { amy_grab_lock(); stored_sequence_definition_t *retired = retired_sequence_definitions; retired_sequence_definitions = NULL; @@ -151,6 +150,11 @@ static void stored_sequence_reclaim_retired(void) { stored_sequence_definition_destroy_list(retired); } +static void stored_sequence_reclaim_retired(void) { + if (wire_firing || stored_sequence_wire_firing) return; + sequencer_reclaim_retired(); +} + static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = (stored_sequence_definition_t *)malloc_caps( @@ -765,10 +769,15 @@ static bool stored_sequence_event_is_control( return strncmp(event->wire, "HC", 2) == 0; } +static void sequence_play_wire_now(char *wire) { + if (wire[0] == 'H') handle_ticks_message(wire); + else amy_play_message(wire); +} + static void stored_sequence_play_wire(const char *wire) { bool previous = stored_sequence_wire_firing; stored_sequence_wire_firing = true; - amy_add_message((char *)wire); + sequence_play_wire_now((char *)wire); stored_sequence_wire_firing = previous; } @@ -874,7 +883,7 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_add_message(wire); + sequence_play_wire_now(wire); free(wire); } } diff --git a/src/sequencer.h b/src/sequencer.h index 37a40c4e..efc5309a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -13,6 +13,9 @@ void sequencer_debug(); void sequencer_recompute(); void sequencer_check_and_fill(); // called once per block from amy_execute_deltas() +// Destroy zero-reference immutable sequence definitions retired by the render +// path. The caller must be a control/non-render thread. +void sequencer_reclaim_retired(); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif From b6f559a55d67f2d1dc4509a3e59fabca4fbfbd70 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:52:48 +0200 Subject: [PATCH 037/112] Defer render-fired sequence reset cleanup --- src/sequencer.c | 5 +++-- tests/test_sequencer_sequences.c | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 381aa487..b0d97987 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -668,8 +668,9 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { amy_grab_lock(); stored_sequence_definition_t *definition = slot->definition; slot->definition = NULL; - stored_sequence_definition_t *dead = - stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_t *dead = NULL; + if (wire_firing) stored_sequence_definition_retire_locked(definition); + else dead = stored_sequence_definition_unref_locked(definition); amy_release_lock(); stored_sequence_definition_destroy(dead); stored_sequence_reclaim_retired(); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 0d465f02..de3a04c0 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -224,6 +224,19 @@ static void test_root_launches_local_zero_on_same_tick(void) { "a root launch includes the child's local tick zero"); } +static void test_root_can_reset_a_future_definition(void) { + printf("root events can reset future stored definitions\n"); + sequencer_reset(); + amy_add_message("H0,0,12zPfutureZ"); + uint32_t reset_tick = sequencer_ticks() + 2; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HR12Z", reset_tick); + amy_add_message(wire); + clock_to(reset_tick); + CHECK(!sequencer_sequence_control(12, SEQUENCE_CONTROL_START, 0, 0), + "a render-fired reset removes the future definition"); +} + static void test_overlapping_executions_need_no_host_identity(void) { printf("one sequence tag supports bounded overlapping executions\n"); sequencer_reset(); @@ -414,6 +427,7 @@ int main(void) { test_append_while_active_uses_copy_on_write(); test_three_definition_generations_overlap(); test_root_launches_local_zero_on_same_tick(); + test_root_can_reset_a_future_definition(); test_overlapping_executions_need_no_host_identity(); test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); From 0198b50e64a79ac8bcda21d79ecbb988399fbe70 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:23:22 +0200 Subject: [PATCH 038/112] Make sequence triggers note-like and strict --- amy/__init__.py | 76 ++++++++++++++++++++++++++++++-- src/parse.c | 33 ++++++++++---- tests/test_sequence_api.py | 17 +++++++ tests/test_sequencer_sequences.c | 26 +++++++++++ 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 7ae89f94..cb714867 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -240,6 +240,74 @@ def str_of_int(arg): return str(int(arg)) +def _list_values(value): + """Return a wire-list argument as individual values for validation.""" + if isinstance(value, str): + return value.split(',') + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def _sequence_control_values(value): + """Validate the low-level ``HC`` payload without blocking templates.""" + values = _list_values(value) + if len(values) < 2: + raise ValueError('sequence_control needs at least tag and action.') + try: + action = int(values[1]) + except (TypeError, ValueError): + # Command templates substitute tokens such as %v before AMY parses HC. + if not (isinstance(values[1], str) and values[1].startswith('%')): + raise ValueError('sequence_control action must be stop=0, start=1, gate=2, or a template token.') + if len(values) not in (2, 3): + raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') + return values + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if len(values) not in (2, 3): + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + elif action == SEQUENCE_CONTROL_GATE: + if len(values) not in (3, 4): + raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') + else: + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + return values + + +def _normalize_sequence_note(kwargs): + """Translate note-like sequence control into the existing HC primitive.""" + if 'sequence' not in kwargs: + if 'alignment_period' in kwargs: + raise ValueError('alignment_period is only valid with sequence.') + return kwargs + if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: + raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') + extra = set(kwargs) - {'sequence', 'vel', 'alignment_period', 'ticks'} + if extra: + raise ValueError('sequence can only be combined with vel, alignment_period, and ticks.') + if 'vel' not in kwargs: + raise ValueError('sequence needs vel: use a value above zero to start and zero to stop.') + tag = int(kwargs['sequence']) + if tag < 0: + raise ValueError('Sequence tag must be non-negative.') + alignment = int(kwargs.get('alignment_period', 0)) + if alignment < 0: + raise ValueError('Sequence alignment_period must be non-negative.') + velocity = kwargs['vel'] + if isinstance(velocity, str) and velocity.startswith('%'): + action = velocity + else: + velocity = float(velocity) + if velocity < 0: + raise ValueError('Sequence vel must be non-negative.') + action = SEQUENCE_CONTROL_START if velocity > 0 else SEQUENCE_CONTROL_STOP + normalized = {} + if 'ticks' in kwargs: + normalized['ticks'] = kwargs['ticks'] + normalized['sequence_control'] = (tag, action, alignment) + return normalized + + _KW_MAP_LIST = [ # Order matters because patch_string must come last. # Sequence/ticks headers must come first: 'H' is only recognized as the # first wire character. sequence_control follows a ticks @@ -281,6 +349,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS + kwargs = _normalize_sequence_note(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: @@ -305,9 +374,10 @@ def message(**kwargs): raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') - if ('sequence_control' in kwargs and len(kwargs) != 1 - and 'ticks' not in kwargs): - raise ValueError('sequence_control can only be combined with ticks.') + if 'sequence_control' in kwargs: + if set(kwargs) - {'sequence_control', 'ticks'}: + raise ValueError('sequence_control can only be combined with ticks.') + _sequence_control_values(kwargs['sequence_control']) # Validity check all the passed args. prioritized_keys = [] diff --git a/src/parse.c b/src/parse.c index f5cfb6d1..7f5f11da 100644 --- a/src/parse.c +++ b/src/parse.c @@ -719,16 +719,32 @@ void handle_ticks_message(char *message) { if (message[1] == 'C') { // HCtag,start_or_stop[,alignment_period] // HCtag,gate,duration[,alignment_period] - uint32_t values[4] = {0, 0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 4, 0); - if (count < 2) { + uint32_t values[5] = {0, 0, 0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 5, 0); + char terminator = message[2 + _next_alpha(message + 2)]; + if (terminator != '\0' && terminator != 'Z') { + fprintf(stderr, + "invalid sequence_control: HC must not contain an " + "ordinary AMY payload\n"); + } else if (count < 2) { fprintf(stderr, "invalid sequence_control: expected " "HCtag,start_or_stop[,alignment_period] or " "HCtag,gate,duration[,alignment_period]\n"); - } else if (values[1] == SEQUENCE_CONTROL_GATE && count < 3) { + } else if ((values[1] == SEQUENCE_CONTROL_START + || values[1] == SEQUENCE_CONTROL_STOP) + && count != 2 && count != 3) { + fprintf(stderr, + "invalid sequence_control start/stop: expected " + "HCtag,start_or_stop[,alignment_period]\n"); + } else if (values[1] == SEQUENCE_CONTROL_GATE + && count != 3 && count != 4) { + fprintf(stderr, + "invalid sequence_control gate: expected " + "HCtag,gate,duration[,alignment_period]\n"); + } else if (count > 4) { fprintf(stderr, - "invalid sequence_control gate: duration is required\n"); + "invalid sequence_control: expected at most four values\n"); } else { uint32_t value = values[1] == SEQUENCE_CONTROL_GATE ? values[2] : 0; @@ -741,9 +757,10 @@ void handle_ticks_message(char *message) { if (message[1] == 'R') { // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. - uint32_t values[1] = {0}; - int count = parse_list_uint32_t(message + 2, values, 1, 0); - if (count != 1) + uint32_t values[2] = {0, 0}; + int count = parse_list_uint32_t(message + 2, values, 2, 0); + char terminator = message[2 + _next_alpha(message + 2)]; + if ((terminator != '\0' && terminator != 'Z') || count != 1) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else sequencer_sequence_reset(values[0]); diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 7230994d..aa2c7735 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -25,6 +25,13 @@ def main(): assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" + assert amy.message(sequence=7, vel=0, alignment_period=48) \ + == "HC7,0,48Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, + alignment_period=1) == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, vel="%v", alignment_period=1) \ + == "HC7,%v,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" @@ -48,6 +55,16 @@ def main(): expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) + expect_error("only be combined", lambda: amy.message( + ticks=(0,), sequence_control=(2, 1), synth=1)) + expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) + expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) + expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("needs vel", lambda: amy.message(sequence=2)) + expect_error("can only be combined", lambda: amy.message( + sequence=2, vel=1, synth=1)) + expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) + expect_error("non-negative", lambda: amy.message(sequence=2, vel=-1)) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index de3a04c0..b6c28d29 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -375,6 +375,31 @@ static void test_bounds_and_validation(void) { "unknown control action is rejected"); } +static void test_wire_control_shape_is_strict(void) { + printf("sequence control and reset wire shapes are strict\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,3zPdefinedZ"); + + amy_add_message("HC3,1,0,99Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined"), + "a start with an extra field is rejected"); + + amy_add_message("HC3,2Z"); + amy_add_message("HC3,1,0zPignoredZ"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined") && !marks_named("ignored"), + "a missing gate duration and trailing payload are rejected"); + + amy_add_message("HR3,4Z"); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "a reset with an extra field leaves the definition intact"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("defined", start), "the intact definition still starts"); +} + static void test_start_crosses_clock_rollover(void) { printf("relative sequence phase crosses uint32 clock rollover\n"); sequencer_reset(); @@ -436,6 +461,7 @@ int main(void) { test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); test_bounds_and_validation(); + test_wire_control_shape_is_strict(); amy_stop(); test_disabled_configuration(); From 361ac4049df07802901a3a8d26caaf4a6dbf255f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:26:23 +0200 Subject: [PATCH 039/112] Migrate first-party sequencer callers --- amy/examples.py | 20 ++++++++------------ amy/test.py | 11 +++++------ docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 4 +++- docs/sequencer-sequences-howto.md | 18 +++++++----------- docs/sequencer-sequences.md | 10 +++++++--- docs/tutorial.html | 12 ++++++------ experiments/sampler/play_cleanbreaks.py | 4 +--- experiments/sampler/play_sampler.py | 4 ++-- 9 files changed, 40 insertions(+), 45 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index 3cf35772..81896a5e 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,18 +257,14 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Add patterns - # Hi hat every 1/8th note - amy.send(ticks=[0, 24, 0], osc=2, vel=2.0) - - # Bass drum every quarter note - amy.send(ticks=[0, 96, 1], osc=0, vel=1.0) - - # Snare every quarter note, counterphase to BD - amy.send(ticks=[24, 96, 2], osc=1, vel=1.0) - - # Cow once every other cycle - amy.send(ticks=[0, 192, 3], osc=3, vel=1.0) + # Store all parts as one reusable pattern, then start it like a note. + amy.define_sequence(0, [ + dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note + dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter + dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare + dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle + ]) + amy.send(sequence=0, vel=1, alignment_period=1) def example_fm(): amy.reset() diff --git a/amy/test.py b/amy/test.py index 2539b32e..116440ff 100644 --- a/amy/test.py +++ b/amy/test.py @@ -2034,7 +2034,7 @@ def __init__(self): self.default_synths = True def run(self): - amy_send_at(time=100, ticks='20,24,0', synth=1, note=64, vel=1) + amy_send_at(time=100, ticks='20,24', synth=1, note=64, vel=1) class TestSequencedSynthDrums(AmyTest): @@ -2046,7 +2046,7 @@ def __init__(self): def run(self): # The sequencer working on the SYNTH_FLAGS_NOTES_VIA_MIDI synth 10 (38 = Acoustic Snare). - amy_send_at(time=100, ticks='20,24,0', synth=10, note=38, vel=1) + amy_send_at(time=100, ticks='20,24', synth=10, note=38, vel=1) class TestSequencerOsc(AmyTest): @@ -2058,10 +2058,10 @@ class TestSequencerOsc(AmyTest): def run(self): amy_send_at(time=0, osc=0, wave=amy.SINE, freq=1000) # Absolute-tick events: note on at tick 20 (~231 ms), off at tick 40 (~463 ms). - amy.send(osc=0, vel=1, ticks="20,0,1") - amy.send(osc=0, vel=0, ticks="40,0,2") + amy.send(osc=0, vel=1, ticks="20") + amy.send(osc=0, vel=0, ticks="40") # Periodic event: a lower note every 60 ticks, lands once at ~694 ms. - amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60,3") + amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60") amy_send_at(time=900, osc=1, vel=0) @@ -2341,4 +2341,3 @@ def main(argv): if __name__ == "__main__": main(sys.argv) - diff --git a/docs/midi.md b/docs/midi.md index 0dcd6643..7cfe0727 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=1, vel=1, alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index da4eec73..9776531d 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,7 +8,9 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -`sequence_control` provides: +At the Python API, `amy.send(sequence=tag, vel=...)` makes start and stop look +like note-on and note-off. Internally its compact `sequence_control` operation +provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 88c49dab..af9acfa1 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,17 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), - sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), - dict(ticks=(24, 48), - sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(0, 48), sequence=20, vel=1, alignment_period=1), + dict(ticks=(24, 48), sequence=21, vel=1, alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), - sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), - dict(ticks=(12, 24), - sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(0, 24), sequence=20, vel=1, alignment_period=1), + dict(ticks=(12, 24), sequence=21, vel=1, alignment_period=1), ]) ``` @@ -39,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=30, vel=1, alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) -amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=30, vel=0, alignment_period=48) +amy.send(sequence=31, vel=1, alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index fc006c3a..b6c68268 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,11 +37,15 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_START, 1)) -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) +amy.send(sequence=40, vel=1, alignment_period=1) +amy.send(sequence=40, vel=0, alignment_period=48) ``` -The optional final value is `alignment_period`. `0` or `1` acts at the next +This deliberately resembles note-on/note-off: positive `vel` starts the +sequence and zero stops it. The wire representation remains the lower-level +`sequence_control` operation, so existing command templates can substitute +their value into `HCtag,%v,alignment`. The optional `alignment_period` is the +alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, the child's local tick zero participates in the same tick. diff --git a/docs/tutorial.html b/docs/tutorial.html index eb6df9cf..12dbfabc 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 1)) -amy.send(sequence_control=(2, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence=1, vel=1, alignment_period=1) +amy.send(sequence=2, vel=1, alignment_period=1)

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence=1, vel=0, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence=1, vel=1, alignment_period=1)

For patterns you want to also address their "slots", which is the offset within the pattern, like this

-amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence=1, vel=0, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 384)) +amy.send(sequence=1, vel=1, alignment_period=384)
diff --git a/experiments/sampler/play_cleanbreaks.py b/experiments/sampler/play_cleanbreaks.py index e094b0b4..34f170de 100644 --- a/experiments/sampler/play_cleanbreaks.py +++ b/experiments/sampler/play_cleanbreaks.py @@ -73,7 +73,6 @@ def main(): # Line tick 0 up with the first note-on (loading above consumed time). amy.send(reset=amy.RESET_TIMEBASE) t = 0 # ticks - tag = 1 print(f"\n when bars native break") for i, e in enumerate(picks): fit = e['bars'] * BAR_TICKS @@ -85,8 +84,7 @@ def main(): # past by the first render); play the opener directly. amy.send(**kw) else: - amy.send(ticks=[t, 0, tag], **kw) - tag += 1 + amy.send(ticks=[t], **kw) t += fit us_per_tick = int(60000000.0 / (args.bpm * PPQ)) # matches sequencer.c total = int(t * us_per_tick / 1e6 * SR) diff --git a/experiments/sampler/play_sampler.py b/experiments/sampler/play_sampler.py index a5e189dc..6cf4f527 100644 --- a/experiments/sampler/play_sampler.py +++ b/experiments/sampler/play_sampler.py @@ -187,7 +187,7 @@ def demo_hits(args): # Quantized to sequencer ticks live (PPQ/4 ticks per 16th). amy.send(tempo=args.bpm) for i, k in enumerate(order): - amy.send(ticks=[int(i * PPQ / 4), 0, i + 1], osc=(i % 24) + 1, + amy.send(ticks=[int(i * PPQ / 4)], osc=(i % 24) + 1, wave=amy.PCM, preset=presets[k], vel=1) time.sleep(len(order) * step + 2) return @@ -233,7 +233,7 @@ def demo_loops(args): amy.send(**kw) # ...and let the sequencer re-trigger every `fit` ticks after that. if args.loops > 1: - amy.send(ticks=[0, fit, i + 1], **kw) + amy.send(ticks=[0, fit], **kw) # "N loops" = N cycles of the longest break. total_ticks = max(l[4] for l in loops) * args.loops total = int(total_ticks * tick_samples(args.bpm)) From 3060cc0b48ddf65a21e1f862ab422de8db35d327 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:28:50 +0200 Subject: [PATCH 040/112] Test sequence publication allocation failures --- Makefile | 18 ++++++-- src/sequencer.c | 26 +++++++++-- src/sequencer.h | 3 ++ tests/test_sequencer_oom.c | 89 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/test_sequencer_oom.c diff --git a/Makefile b/Makefile index 47cac0b5..a673b960 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,8 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ - tests/test_sequencer_sequences \ + tests/test_sequencer_sequences \ + tests/test_sequencer_oom \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ @@ -132,12 +133,23 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h +$(addsuffix .o,$(filter-out tests/test_sequencer_oom,$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(CTESTS): %: %.o $(OBJECTS) +$(filter-out tests/test_sequencer_oom,$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ +# Build only the sequencer and its OOM test with the test-only allocation hook; +# every other test and every production target uses the ordinary object. +tests/sequencer_oom_impl.o: src/sequencer.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ + +tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +tests/test_sequencer_oom: tests/test_sequencer_oom.o tests/sequencer_oom_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_oom_impl.o $< -Wall $(LIBS) -o $@ + ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done diff --git a/src/sequencer.c b/src/sequencer.c index b0d97987..4fcc489b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -93,6 +93,23 @@ static size_t stored_sequence_event_bytes = 0; static volatile bool stored_sequence_wire_firing = false; static stored_sequence_definition_t *retired_sequence_definitions = NULL; +#ifdef AMY_SEQUENCE_TESTING +static int32_t stored_sequence_allocations_before_failure = -1; + +void sequencer_test_fail_allocation_after(int32_t successful_allocations) { + stored_sequence_allocations_before_failure = successful_allocations; +} +#endif + +static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { +#ifdef AMY_SEQUENCE_TESTING + if (stored_sequence_allocations_before_failure == 0) return NULL; + if (stored_sequence_allocations_before_failure > 0) + stored_sequence_allocations_before_failure--; +#endif + return malloc_caps(size, caps); +} + static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { if (count > SIZE_MAX / element_size) return false; @@ -157,11 +174,11 @@ static void stored_sequence_reclaim_retired(void) { static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = - (stored_sequence_definition_t *)malloc_caps( + (stored_sequence_definition_t *)stored_sequence_allocate( sizeof(stored_sequence_definition_t), amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; - definition->events = (stored_sequence_event_t *)malloc_caps( + definition->events = (stored_sequence_event_t *)stored_sequence_allocate( stored_sequence_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); @@ -178,7 +195,8 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { static char *stored_sequence_wire_copy(const char *wire) { size_t len = strlen(wire); - char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + char *copy = (char *)stored_sequence_allocate( + len + 1, amy_global.config.ram_caps_events); if (copy != NULL) memcpy(copy, wire, len + 1); return copy; } @@ -611,7 +629,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, amy_release_lock(); } stored_sequence_definition_destroy(dead); - amy_oom("stored sequence edit"); + amy_oom("stored sequence edit: out of memory\n"); free(wire); return 0; } diff --git a/src/sequencer.h b/src/sequencer.h index efc5309a..018f92d5 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -40,6 +40,9 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period); void sequencer_sequence_reset_timebase(); +#ifdef AMY_SEQUENCE_TESTING +void sequencer_test_fail_allocation_after(int32_t successful_allocations); +#endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c new file mode 100644 index 00000000..0fed5c32 --- /dev/null +++ b/tests/test_sequencer_oom.c @@ -0,0 +1,89 @@ +// Allocation-failure regression tests for immutable sequence publication. + +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static int base_hits = 0; +static int unexpected_hits = 0; + +static void mark_hook(const char *code) { + if (!strcmp(code, "base-head") || !strcmp(code, "base-tail")) + base_hits++; + if (!strcmp(code, "must-not-publish")) unexpected_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void define_base(void) { + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbase-headZ")), + "base head is defined"); + CHECK(sequencer_sequence_add_wire(1, 4, 0, strdup("zPbase-tailZ")), + "base tail is defined"); +} + +static void test_clone_allocation_failures_preserve_source(void) { + printf("every clone allocation failure preserves the published definition\n"); + // Clone allocation order: definition, event array, then two wire strings. + for (int32_t fail_after = 0; fail_after < 4; ++fail_after) { + sequencer_reset(); + define_base(); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "source execution pins the definition (failure %" PRIi32 ")", + fail_after); + + char *incoming = strdup("zPmust-not-publishZ"); + sequencer_test_fail_allocation_after(fail_after); + uint8_t appended = sequencer_sequence_add_wire(1, 2, 0, incoming); + sequencer_test_fail_allocation_after(-1); + CHECK(!appended, "allocation failure %" PRIi32 " rejects the edit", + fail_after); + + base_hits = 0; + unexpected_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "old definition remains startable"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(base_hits >= 2 && unexpected_hits == 0, + "failure %" PRIi32 " publishes neither a partial nor corrupt edit", + fail_after); + } +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_clone_allocation_failures_preserve_source(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequence allocation-failure checks passed\n"); + return 0; +} From f22307a3803f40af6aeba2194aaa6a6d8ce2f726 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:30:25 +0200 Subject: [PATCH 041/112] Cover concurrent sequence publication retry --- Makefile | 16 ++-- src/sequencer.c | 17 +++++ src/sequencer.h | 1 + tests/test_sequencer_concurrency.c | 117 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 tests/test_sequencer_concurrency.c diff --git a/Makefile b/Makefile index a673b960..d351750e 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,7 @@ amy-message: $(OBJECTS) src/amy-message.o CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ tests/test_sequencer_sequences \ tests/test_sequencer_oom \ + tests/test_sequencer_concurrency \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ @@ -133,22 +134,27 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(filter-out tests/test_sequencer_oom,$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h +SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency + +$(addsuffix .o,$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(filter-out tests/test_sequencer_oom,$(CTESTS)): %: %.o $(OBJECTS) +$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ # Build only the sequencer and its OOM test with the test-only allocation hook; # every other test and every production target uses the ordinary object. -tests/sequencer_oom_impl.o: src/sequencer.c $(HEADERS) src/patches.h +tests/sequencer_testing_impl.o: src/sequencer.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ -tests/test_sequencer_oom: tests/test_sequencer_oom.o tests/sequencer_oom_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) - $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_oom_impl.o $< -Wall $(LIBS) -o $@ +tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +$(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done diff --git a/src/sequencer.c b/src/sequencer.c index 4fcc489b..7557d198 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -95,10 +95,15 @@ static stored_sequence_definition_t *retired_sequence_definitions = NULL; #ifdef AMY_SEQUENCE_TESTING static int32_t stored_sequence_allocations_before_failure = -1; +static void (*stored_sequence_after_pin_hook)(void) = NULL; void sequencer_test_fail_allocation_after(int32_t successful_allocations) { stored_sequence_allocations_before_failure = successful_allocations; } + +void sequencer_test_set_after_pin_hook(void (*hook)(void)) { + stored_sequence_after_pin_hook = hook; +} #endif static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { @@ -588,6 +593,9 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } stored_sequence_reclaim_retired(); +#ifdef AMY_SEQUENCE_TESTING + bool test_pin_hook_called = false; +#endif for (;;) { amy_grab_lock(); stored_sequence_definition_t *source = slot->definition; @@ -618,6 +626,15 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, if (source != NULL) source->refs++; amy_release_lock(); +#ifdef AMY_SEQUENCE_TESTING + // Tests use this one-shot rendezvous to make two writers clone the + // same pinned generation. It is absent from production builds. + if (!test_pin_hook_called && stored_sequence_after_pin_hook != NULL) { + test_pin_hook_called = true; + stored_sequence_after_pin_hook(); + } +#endif + stored_sequence_definition_t *candidate = source == NULL ? stored_sequence_definition_new() : stored_sequence_definition_clone(source); diff --git a/src/sequencer.h b/src/sequencer.h index 018f92d5..91834aa0 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -42,6 +42,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, void sequencer_sequence_reset_timebase(); #ifdef AMY_SEQUENCE_TESTING void sequencer_test_fail_allocation_after(int32_t successful_allocations); +void sequencer_test_set_after_pin_hook(void (*hook)(void)); #endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c new file mode 100644 index 00000000..2fa696ea --- /dev/null +++ b/tests/test_sequencer_concurrency.c @@ -0,0 +1,117 @@ +// Deterministic two-writer publication/retry regression test. + +#include +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static pthread_mutex_t rendezvous_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t rendezvous_changed = PTHREAD_COND_INITIALIZER; +static int writers_at_pin = 0; +static int release_writers = 0; +static int a_hits = 0; +static int b_hits = 0; + +static void after_source_pin(void) { + pthread_mutex_lock(&rendezvous_lock); + writers_at_pin++; + if (writers_at_pin == 2) { + release_writers = 1; + pthread_cond_broadcast(&rendezvous_changed); + } else { + while (!release_writers) + pthread_cond_wait(&rendezvous_changed, &rendezvous_lock); + } + pthread_mutex_unlock(&rendezvous_lock); +} + +typedef struct writer_args_t { + uint32_t tick; + const char *wire; + uint8_t result; +} writer_args_t; + +static void *append_event(void *opaque) { + writer_args_t *args = (writer_args_t *)opaque; + args->result = sequencer_sequence_add_wire( + 1, args->tick, 0, strdup(args->wire)); + return NULL; +} + +static void mark_hook(const char *code) { + if (!strcmp(code, "writer-a")) a_hits++; + if (!strcmp(code, "writer-b")) b_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void test_losing_writer_retries_cumulatively(void) { + printf("two writers publishing from one generation both survive\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbaseZ")), + "base definition exists"); + CHECK(sequencer_sequence_add_wire(1, 6, 0, strdup("zPtailZ")), + "base definition has a finite tail"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "an execution pins the shared source generation"); + + writer_args_t a = {2, "zPwriter-aZ", 0}; + writer_args_t b = {4, "zPwriter-bZ", 0}; + pthread_t a_thread; + pthread_t b_thread; + sequencer_test_set_after_pin_hook(after_source_pin); + CHECK(pthread_create(&a_thread, NULL, append_event, &a) == 0, + "writer A starts"); + CHECK(pthread_create(&b_thread, NULL, append_event, &b) == 0, + "writer B starts"); + pthread_join(a_thread, NULL); + pthread_join(b_thread, NULL); + sequencer_test_set_after_pin_hook(NULL); + CHECK(a.result && b.result, "both competing edits report success"); + + a_hits = 0; + b_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "the cumulatively published generation starts"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 6); + CHECK(a_hits == 1 && b_hits == 1, + "the losing compare/retry path loses and duplicates no event"); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_losing_writer_retries_cumulatively(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall concurrent sequence publication checks passed\n"); + return 0; +} From dcbd2842be2f67f828f1ca1636519700a3c3ea6f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:36:19 +0200 Subject: [PATCH 042/112] Define bounded sequence composition semantics --- docs/sequencer-sequences-abstractions.md | 5 ++ docs/sequencer-sequences.md | 7 ++- tests/test_sequencer_sequences.c | 73 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 9776531d..18ab3a00 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -75,6 +75,11 @@ executions. A cyclic control graph may fill the execution pool, but cannot grow beyond it; later starts fail clearly and the caller can stop a tag or reset the sequencer. +Aligned stop and gate commands capture the executions active when the command +is sent. An execution started later does not inherit previously pending control +state merely because its tag matches. This keeps control ownership on explicit +executions rather than creating a hidden per-tag automation timeline. + The ordinary three-field C event layout remains unchanged. Untagged one-off and periodic scheduling, MIDI/external-clock behavior, and global reset retain their existing behavior. The intentional API change is that a supplied tag now diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index b6c68268..332a5ab0 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -52,8 +52,11 @@ the child's local tick zero participates in the same tick. A start creates a bounded execution. Finite executions of one tag may overlap, so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets -all active executions of that tag. Stopping a parent prevents future child -starts, while children already started retain their own event pairs. +all executions of that tag which are active when the command is sent. If the +stop is aligned to a future boundary, a separate execution started after that +command does not inherit its pending stop. This avoids hidden per-tag control +state. Stopping a parent prevents future child starts, while children already +started retain their own event pairs. ## Finite and repeating lifetime diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index b6c28d29..25e45427 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -309,6 +309,50 @@ static void test_finite_gate_preserves_phase(void) { "event resumes on the original phase after gate expiry"); } +static void test_quantized_stop_targets_current_executions(void) { + printf("quantized controls capture the current execution set\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPpulseZ"); + amy_add_message("HC6,1,1Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + CHECK(mark_at("pulse", first_start), "first execution begins"); + + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_STOP, 0, 8), + "first execution accepts a future aligned stop"); + uint32_t stop_boundary = next_boundary(sequencer_ticks(), 8); + amy_add_message("HC6,1,1Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(stop_boundary + 4); + CHECK(!mark_at("pulse", stop_boundary), + "the captured execution stops before its boundary event"); + CHECK(mark_at("pulse", second_start) + && mark_at("pulse", second_start + 4), + "a later start does not inherit an earlier pending stop"); +} + +static void test_cyclic_controls_are_bounded_and_recoverable(void) { + printf("cyclic sequence controls remain bounded and recoverable\n"); + sequencer_reset(); + amy_add_message("H0,1,1HC2,1,0Z"); + amy_add_message("H0,1,2HC1,1,0Z"); + amy_add_message("H0,0,3zPrecoveryZ"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "cycle root starts"); + clock_to(sequencer_ticks() + 1); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "the cycle fills but cannot exceed the execution pool"); + + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_STOP, 0, 0), + "all active A executions accept stop"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 0), + "all active B executions accept stop"); + clock_to(sequencer_ticks() + 1); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "stopping both cycle tags makes the pool reusable"); +} + static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); @@ -413,6 +457,32 @@ static void test_start_crosses_clock_rollover(void) { CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); } +static void test_gate_and_stop_cross_clock_rollover(void) { + printf("pending gate and stop controls cross uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,2zPwrap-pulseZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 4; + amy_add_message("HC2,1,2Z"); + uint32_t start = UINT32_MAX - 3; + clock_to(start); + CHECK(mark_at("wrap-pulse", start), "loop starts before rollover"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_GATE, 4, 1), + "gate spanning rollover is accepted"); + clock_to(2); + CHECK(!mark_at("wrap-pulse", UINT32_MAX - 1) + && !mark_at("wrap-pulse", 0), + "events remain gated on both sides of rollover"); + CHECK(mark_at("wrap-pulse", 2), "gate expires at its wrapped end tick"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 4), + "stop aligns to a post-rollover boundary"); + clock_to(4); + CHECK(mark_at("wrap-pulse", 2) && !mark_at("wrap-pulse", 4), + "stop suppresses the event on its aligned boundary"); +} + static void test_disabled_configuration(void) { printf("zero reusable-sequence capacities disable the feature safely\n"); const uint32_t capacities[][2] = {{0, 8}, {8, 0}}; @@ -457,9 +527,12 @@ int main(void) { test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); test_finite_gate_preserves_phase(); + test_quantized_stop_targets_current_executions(); + test_cyclic_controls_are_bounded_and_recoverable(); test_per_tag_and_global_reset_semantics(); test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); + test_gate_and_stop_cross_clock_rollover(); test_bounds_and_validation(); test_wire_control_shape_is_strict(); From 092941ca4c38fdcf381fc134ce65e6f6333f7670 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:45:17 +0200 Subject: [PATCH 043/112] Accept note-like sequence control velocity --- amy/__init__.py | 8 +-- docs/sequencer-sequences.md | 9 +-- src/parse.c | 96 ++++++++++++++++++++++---------- src/sequencer.h | 2 +- tests/test_sequence_api.py | 3 + tests/test_sequencer_sequences.c | 24 ++++++++ 6 files changed, 105 insertions(+), 37 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index cb714867..1f73de2a 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -255,7 +255,7 @@ def _sequence_control_values(value): if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') try: - action = int(values[1]) + action = float(values[1]) except (TypeError, ValueError): # Command templates substitute tokens such as %v before AMY parses HC. if not (isinstance(values[1], str) and values[1].startswith('%')): @@ -263,14 +263,14 @@ def _sequence_control_values(value): if len(values) not in (2, 3): raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') return values - if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if 0 <= action <= 1: if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, velocity, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: - raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + raise ValueError('sequence_control velocity/action must be stop=0, start=(0,1], or gate=2.') return values diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 332a5ab0..768811f1 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -41,10 +41,11 @@ amy.send(sequence=40, vel=1, alignment_period=1) amy.send(sequence=40, vel=0, alignment_period=48) ``` -This deliberately resembles note-on/note-off: positive `vel` starts the -sequence and zero stops it. The wire representation remains the lower-level -`sequence_control` operation, so existing command templates can substitute -their value into `HCtag,%v,alignment`. The optional `alignment_period` is the +This deliberately resembles note-on/note-off: `vel` in the range `(0, 1]` +starts the sequence and zero stops it. The wire representation remains the +lower-level `sequence_control` operation, where that same field has velocity +semantics. Existing command templates can therefore substitute their value +directly into `HCtag,%v,alignment`. The optional `alignment_period` is the alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, diff --git a/src/parse.c b/src/parse.c index 7f5f11da..aae53b8d 100644 --- a/src/parse.c +++ b/src/parse.c @@ -5,6 +5,7 @@ #include "transfer.h" // for amy_dump_state_to_sysex, amy_dump_file_to_sysex #include // for isalpha(). #include +#include #if defined(TULIP) || defined(AMYBOARD) #include "py/runtime.h" #endif @@ -704,6 +705,25 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static int sequence_control_uint_tail(const char *cursor, uint32_t *values, + int capacity) { + int count = 0; + while (*cursor == ',') { + ++cursor; + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor) || count == capacity) return -1; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(cursor, &end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return -1; + while (*end == ' ') ++end; + values[count++] = (uint32_t)parsed; + cursor = end; + } + if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command @@ -717,40 +737,60 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,start_or_stop[,alignment_period] + // HCtag,velocity[,alignment_period] // HCtag,gate,duration[,alignment_period] - uint32_t values[5] = {0, 0, 0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 5, 0); - char terminator = message[2 + _next_alpha(message + 2)]; - if (terminator != '\0' && terminator != 'Z') { - fprintf(stderr, - "invalid sequence_control: HC must not contain an " - "ordinary AMY payload\n"); - } else if (count < 2) { + const char *tag_start = message + 2; + while (*tag_start == ' ') ++tag_start; + errno = 0; + char *tag_end = NULL; + unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); + while (*tag_end == ' ') ++tag_end; + const char *velocity_start = tag_end + 1; + errno = 0; + char *velocity_end = NULL; + float velocity = strtof(velocity_start, &velocity_end); + bool velocity_valid = velocity_end != velocity_start + && errno != ERANGE && isfinite(velocity); + const char *tail = velocity_end; + while (*tail == ' ') ++tail; + uint32_t rest[2] = {0, 0}; + int rest_count = sequence_control_uint_tail(tail, rest, 2); + if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start + || parsed_tag > UINT32_MAX || *tag_end != ',' + || !velocity_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,start_or_stop[,alignment_period] or " - "HCtag,gate,duration[,alignment_period]\n"); - } else if ((values[1] == SEQUENCE_CONTROL_START - || values[1] == SEQUENCE_CONTROL_STOP) - && count != 2 && count != 3) { - fprintf(stderr, - "invalid sequence_control start/stop: expected " - "HCtag,start_or_stop[,alignment_period]\n"); - } else if (values[1] == SEQUENCE_CONTROL_GATE - && count != 3 && count != 4) { - fprintf(stderr, - "invalid sequence_control gate: expected " + "HCtag,velocity[,alignment_period] or " "HCtag,gate,duration[,alignment_period]\n"); - } else if (count > 4) { + return; + } + + uint32_t action = 0; + uint32_t value = 0; + uint32_t alignment = 0; + bool shape_valid = false; + if (velocity >= 0 && velocity <= 1) { + action = velocity > 0 ? SEQUENCE_CONTROL_START + : SEQUENCE_CONTROL_STOP; + shape_valid = rest_count <= 1; + if (rest_count == 1) alignment = rest[0]; + } else if (velocity == SEQUENCE_CONTROL_GATE) { + action = SEQUENCE_CONTROL_GATE; + shape_valid = rest_count >= 1 && rest_count <= 2; + value = rest[0]; + if (rest_count == 2) alignment = rest[1]; + } else { + shape_valid = false; + } + + if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: expected at most four values\n"); + "invalid sequence_control: velocity must be in [0,1], " + "or use gate=2 with a duration; tag, duration, and " + "alignment must be non-negative integers\n"); } else { - uint32_t value = values[1] == SEQUENCE_CONTROL_GATE - ? values[2] : 0; - uint32_t alignment = values[1] == SEQUENCE_CONTROL_GATE - ? values[3] : values[2]; - sequencer_sequence_control(values[0], values[1], value, alignment); + sequencer_sequence_control((uint32_t)parsed_tag, action, value, + alignment); } return; } diff --git a/src/sequencer.h b/src/sequencer.h index 91834aa0..1a674029 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, start_or_stop, alignment_period] or +// sequence_control is [tag, velocity, alignment_period] or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index aa2c7735..72aa51a2 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,10 +22,12 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" + assert amy.message(sequence_control=(7, 0.625, 48)) == "HC7,0.625,48Z" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" + assert amy.message(sequence=7, vel=0.625) == "HC7,1,0Z" assert amy.message(sequence=7, vel=0, alignment_period=48) \ == "HC7,0,48Z" assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, @@ -60,6 +62,7 @@ def main(): expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("needs vel", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, vel=1, synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 25e45427..243db417 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -442,6 +442,30 @@ static void test_wire_control_shape_is_strict(void) { uint32_t start = sequencer_ticks() + 1; clock_to(start); CHECK(mark_at("defined", start), "the intact definition still starts"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,4zPvelocity-startZ"); + amy_add_message("HC4,0.625,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("velocity-start", start), + "a positive fractional template velocity starts a sequence"); + amy_add_message("HC4,0,1Z"); + clock_to(sequencer_ticks() + 1); + CHECK(!mark_at("velocity-start", sequencer_ticks()), + "zero template velocity stops a sequence"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,5zPmalformed-startZ"); + amy_add_message("HC5,-0.1,1Z"); + amy_add_message("HC5,0.5,1.5Z"); + amy_add_message("HC5,1,Z"); + amy_add_message("HC4294967296,1Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("malformed-start"), + "invalid velocity, integer, empty, and overflowing fields are rejected"); } static void test_start_crosses_clock_rollover(void) { From 33f4c01cb0aeafa8583315fd81a6d61f802096a1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:46:20 +0200 Subject: [PATCH 044/112] Preserve existing config member offsets --- docs/upgrading.md | 35 ++++++++++++++++++++++++++++++++++- src/amy.h | 7 +++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/upgrading.md b/docs/upgrading.md index 6701af81..0b8a0946 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -3,6 +3,40 @@ Here we will post breaking APIs between releases of AMY and tips on porting. +## Reusable sequencer sequences (unreleased) + +Supplying the same tag in more than one three-value `ticks=` message now +accumulates all those events into a stopped reusable sequence. Previously, a +later event replaced the earlier event at that tag. This intentional change +makes a tag behave like a synth identity: repeated messages build it up. + +Code which only needs direct one-off or periodic scheduling should omit the +tag and keep using one- or two-value `ticks`: + +```python +amy.send(ticks=(20,), synth=1, note=60, vel=1) +amy.send(ticks=(24,), synth=1, note=60, vel=0) +``` + +To replace a tagged definition, reset it explicitly before appending its new +events. The Python helper validates the complete replacement before sending +anything: + +```python +amy.define_sequence(7, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(12,), synth=1, note=60, vel=0), +]) +amy.send(sequence=7, vel=1, alignment_period=1) +``` + +The C configuration adds `max_sequence_events` and +`max_sequence_executions`. They are appended to `amy_config_t`; initialize the +structure with `amy_default_config()` and then override named fields, as in all +current AMY examples. Recompile applications together with the updated AMY +headers and library whenever the public configuration structure changes. + + ## 1.0.X -> 1.1.X This is a big change that moves a lot of stuff you used to have to do yourself into AMY itself -- voice and synth handling, note stealing, MIDI, I2S, sequencer. @@ -70,4 +104,3 @@ void loop() { patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - diff --git a/src/amy.h b/src/amy.h index 44803f57..6de9e5fd 100644 --- a/src/amy.h +++ b/src/amy.h @@ -891,8 +891,6 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; - uint32_t max_sequence_events; - uint32_t max_sequence_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; @@ -960,6 +958,11 @@ typedef struct { int8_t capture_device_id; int8_t playback_device_id; + // Append new configuration fields here so existing members retain their + // offsets for callers compiled against an earlier amy_config_t layout. + uint32_t max_sequence_events; + uint32_t max_sequence_executions; + } amy_config_t; typedef struct eq_state { From ab5f302017aa0c89fc9e5d2101cf605b26e23fa2 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:48:20 +0200 Subject: [PATCH 045/112] Document and test sequence API migration --- Makefile | 6 +++++- docs/upgrading.md | 8 +++++++- tests/test_js_api.js | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/test_js_api.js diff --git a/Makefile b/Makefile index d351750e..dbac073c 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \ -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000 PYTHON = python3 -.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api +.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api js-api-test default: $(TARGET) all: default @@ -82,6 +82,10 @@ check-c-api: $(PYTHON) scripts/gen_amy_js_api.py --check $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check + node tests/test_js_api.js + +js-api-test: + node tests/test_js_api.js SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \ src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \ diff --git a/docs/upgrading.md b/docs/upgrading.md index 0b8a0946..c301d4f8 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -10,6 +10,13 @@ accumulates all those events into a stopped reusable sequence. Previously, a later event replaced the earlier event at that tag. This intentional change makes a tag behave like a synth identity: repeated messages build it up. +Tagged events therefore no longer begin repeating merely because they were +defined. Callers which used a unique tag as the replace/remove identity of one +automatically active event must either use tagless direct scheduling or adapt +their wrapper to reset, define, and explicitly start that tag. Updating such a +wrapper should stop the old execution, reset the future definition, append the +replacement events, and start it at the required alignment boundary. + Code which only needs direct one-off or periodic scheduling should omit the tag and keep using one- or two-value `ticks`: @@ -103,4 +110,3 @@ void loop() { e.patch_number = 1024; patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - diff --git a/tests/test_js_api.js b/tests/test_js_api.js new file mode 100644 index 00000000..aaee955c --- /dev/null +++ b/tests/test_js_api.js @@ -0,0 +1,22 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); + +require(path.join(__dirname, "..", "src", "amy_api.generated.js")); + +assert.equal( + amy_message({sequence_control: [7, 0.625, 48]}), + "HC7,0.625,48Z" +); +assert.equal( + amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), + "H0,48,3HC7,1,1Z" +); +assert.equal(amy_message({sequence_reset: 7}), "HR7Z"); +assert.equal( + amy_message({sequence_control: [7, AMY.SEQUENCE_CONTROL_GATE, 24, 1]}), + "HC7,2,24,1Z" +); + +console.log("JavaScript reusable-sequence API checks passed"); From 36aa150ecc66668dc687db3185210fdc6d814ad1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:49:37 +0200 Subject: [PATCH 046/112] Simplify stored sequence slots --- src/sequencer.c | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 7557d198..54206bd0 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -67,10 +67,6 @@ typedef struct stored_sequence_definition_t { struct stored_sequence_definition_t *next_retired; } stored_sequence_definition_t; -typedef struct stored_sequence_slot_t { - stored_sequence_definition_t *definition; -} stored_sequence_slot_t; - typedef struct stored_sequence_execution_t { stored_sequence_definition_t *definition; uint32_t tag; @@ -85,7 +81,7 @@ typedef struct stored_sequence_execution_t { bool gated; } stored_sequence_execution_t; -static stored_sequence_slot_t *stored_sequences = NULL; +static stored_sequence_definition_t **stored_sequences = NULL; static stored_sequence_execution_t *sequence_executions = NULL; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; @@ -245,8 +241,8 @@ static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; for (int32_t i = 0; i < max_sequences; ++i) { stored_sequence_definition_retire_locked( - stored_sequences[i].definition); - stored_sequences[i].definition = NULL; + stored_sequences[i]); + stored_sequences[i] = NULL; } } @@ -278,7 +274,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { size_t slot_bytes = 0; size_t execution_bytes = 0; if (!checked_array_size((uint32_t)max_sequences, - sizeof(stored_sequence_slot_t), &slot_bytes) + sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), &stored_sequence_event_bytes) || !checked_array_size(executions, @@ -292,7 +288,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { stored_sequences_deinit(); return; } - stored_sequences = (stored_sequence_slot_t *)malloc_caps( + stored_sequences = (stored_sequence_definition_t **)malloc_caps( slot_bytes, amy_global.config.ram_caps_synth); if (stored_sequences != NULL) memset(stored_sequences, 0, slot_bytes); @@ -524,7 +520,7 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } -static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { +static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; return &stored_sequences[tag]; } @@ -560,7 +556,7 @@ static void stored_sequence_candidate_discard( uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot append event to sequence %" PRIu32 @@ -598,7 +594,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, #endif for (;;) { amy_grab_lock(); - stored_sequence_definition_t *source = slot->definition; + stored_sequence_definition_t *source = *slot; if (source != NULL && source->event_count >= max_stored_sequence_events) { fprintf(stderr, "cannot append event to sequence %" PRIu32 @@ -653,8 +649,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_append_owned(candidate, tick, period, wire); amy_grab_lock(); - if (slot->definition == source) { - slot->definition = candidate; + if (*slot == source) { + *slot = candidate; stored_sequence_definition_t *dead = NULL; if (source != NULL) { // Drop the old slot ownership and our temporary writer pin. @@ -681,7 +677,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } uint8_t sequencer_sequence_reset(uint32_t tag) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot reset sequence %" PRIu32 @@ -701,8 +697,8 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { stored_sequence_reclaim_retired(); amy_grab_lock(); - stored_sequence_definition_t *definition = slot->definition; - slot->definition = NULL; + stored_sequence_definition_t *definition = *slot; + *slot = NULL; stored_sequence_definition_t *dead = NULL; if (wire_firing) stored_sequence_definition_retire_locked(definition); else dead = stored_sequence_definition_unref_locked(definition); @@ -727,7 +723,7 @@ static uint32_t sequence_control_tick(uint32_t alignment_period) { uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot control sequence %" PRIu32 @@ -743,7 +739,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { - if (slot->definition == NULL || slot->definition->event_count == 0) { + if (*slot == NULL || (*slot)->event_count == 0) { fprintf(stderr, "cannot start sequence %" PRIu32 ": its definition is empty\n", tag); } else { @@ -759,7 +755,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, tag, max_stored_sequence_executions); } else { memset(available, 0, sizeof(*available)); - available->definition = slot->definition; + available->definition = *slot; available->definition->refs++; available->tag = tag; available->start_tick = start_tick; From 380f20e15260b100ffd788b8467e740f04532e9d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:52:04 +0200 Subject: [PATCH 047/112] Ignore generated sequence test binaries --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index db16b158..77fa66f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ tests/tst tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds +tests/test_sequencer_sequences +tests/test_sequencer_oom +tests/test_sequencer_concurrency tests/test_bus_config tests/test_patch_slots tests/test_synth_readout From 4aab0fcb0f35d661999f42a36b536e4ba5cfb7f0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:35:10 +0200 Subject: [PATCH 048/112] Use explicit run state for sequence control --- amy/__init__.py | 54 +++++++++++++++++--------------- amy/examples.py | 2 +- src/parse.c | 35 +++++++++++---------- src/sequencer.h | 2 +- tests/test_js_api.js | 4 +-- tests/test_sequence_api.py | 24 ++++++++------ tests/test_sequencer_sequences.c | 16 +++++----- 7 files changed, 73 insertions(+), 64 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 1f73de2a..d9efe123 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -254,53 +254,57 @@ def _sequence_control_values(value): values = _list_values(value) if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') - try: - action = float(values[1]) - except (TypeError, ValueError): - # Command templates substitute tokens such as %v before AMY parses HC. - if not (isinstance(values[1], str) and values[1].startswith('%')): - raise ValueError('sequence_control action must be stop=0, start=1, gate=2, or a template token.') + raw_action = values[1] + if isinstance(raw_action, str) and raw_action.startswith('%'): + # Command templates substitute the token before AMY parses HC. The + # resulting wire value must still be the integer 0, 1, or 2. if len(values) not in (2, 3): raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') return values - if 0 <= action <= 1: + if isinstance(raw_action, int) and not isinstance(raw_action, bool): + action = raw_action + elif isinstance(raw_action, str) and raw_action.isdigit(): + action = int(raw_action) + else: + raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, velocity, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, run, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: - raise ValueError('sequence_control velocity/action must be stop=0, start=(0,1], or gate=2.') + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') return values -def _normalize_sequence_note(kwargs): - """Translate note-like sequence control into the existing HC primitive.""" +def _normalize_sequence_run(kwargs): + """Translate boolean sequence control into the existing HC primitive.""" if 'sequence' not in kwargs: - if 'alignment_period' in kwargs: - raise ValueError('alignment_period is only valid with sequence.') + for key in ('run', 'alignment_period'): + if key in kwargs: + raise ValueError('%s is only valid with sequence.' % key) return kwargs if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') - extra = set(kwargs) - {'sequence', 'vel', 'alignment_period', 'ticks'} + extra = set(kwargs) - {'sequence', 'run', 'alignment_period', 'ticks'} if extra: - raise ValueError('sequence can only be combined with vel, alignment_period, and ticks.') - if 'vel' not in kwargs: - raise ValueError('sequence needs vel: use a value above zero to start and zero to stop.') + raise ValueError('sequence can only be combined with run, alignment_period, and ticks.') + if 'run' not in kwargs: + raise ValueError('sequence needs run=True to start or run=False to stop.') tag = int(kwargs['sequence']) if tag < 0: raise ValueError('Sequence tag must be non-negative.') alignment = int(kwargs.get('alignment_period', 0)) if alignment < 0: raise ValueError('Sequence alignment_period must be non-negative.') - velocity = kwargs['vel'] - if isinstance(velocity, str) and velocity.startswith('%'): - action = velocity + run = kwargs['run'] + if isinstance(run, bool): + action = SEQUENCE_CONTROL_START if run else SEQUENCE_CONTROL_STOP + elif isinstance(run, int) and run in (0, 1): + action = run else: - velocity = float(velocity) - if velocity < 0: - raise ValueError('Sequence vel must be non-negative.') - action = SEQUENCE_CONTROL_START if velocity > 0 else SEQUENCE_CONTROL_STOP + raise ValueError('Sequence run must be True/False or numeric 1/0.') normalized = {} if 'ticks' in kwargs: normalized['ticks'] = kwargs['ticks'] @@ -349,7 +353,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS - kwargs = _normalize_sequence_note(kwargs) + kwargs = _normalize_sequence_run(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/amy/examples.py b/amy/examples.py index 81896a5e..66ed537e 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -264,7 +264,7 @@ def example_sequencer_drums(): dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle ]) - amy.send(sequence=0, vel=1, alignment_period=1) + amy.send(sequence=0, run=True, alignment_period=1) def example_fm(): amy.reset() diff --git a/src/parse.c b/src/parse.c index aae53b8d..5abe4cd0 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,7 +737,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,velocity[,alignment_period] + // HCtag,run[,alignment_period], where run is exactly 0 or 1. // HCtag,gate,duration[,alignment_period] const char *tag_start = message + 2; while (*tag_start == ' ') ++tag_start; @@ -745,37 +745,38 @@ void handle_ticks_message(char *message) { char *tag_end = NULL; unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); while (*tag_end == ' ') ++tag_end; - const char *velocity_start = tag_end + 1; + const char *action_start = *tag_end == ',' ? tag_end + 1 : tag_end; + while (*action_start == ' ') ++action_start; errno = 0; - char *velocity_end = NULL; - float velocity = strtof(velocity_start, &velocity_end); - bool velocity_valid = velocity_end != velocity_start - && errno != ERANGE && isfinite(velocity); - const char *tail = velocity_end; + char *action_end = NULL; + unsigned long long parsed_action = strtoull(action_start, &action_end, + 10); + bool action_valid = isdigit((unsigned char)*action_start) + && action_end != action_start && errno != ERANGE + && parsed_action <= UINT32_MAX; + const char *tail = action_end; while (*tail == ' ') ++tail; uint32_t rest[2] = {0, 0}; int rest_count = sequence_control_uint_tail(tail, rest, 2); if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start || parsed_tag > UINT32_MAX || *tag_end != ',' - || !velocity_valid || rest_count < 0) { + || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,velocity[,alignment_period] or " + "HCtag,run[,alignment_period] (run is 0 or 1) or " "HCtag,gate,duration[,alignment_period]\n"); return; } - uint32_t action = 0; + uint32_t action = (uint32_t)parsed_action; uint32_t value = 0; uint32_t alignment = 0; bool shape_valid = false; - if (velocity >= 0 && velocity <= 1) { - action = velocity > 0 ? SEQUENCE_CONTROL_START - : SEQUENCE_CONTROL_STOP; + if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_START) { shape_valid = rest_count <= 1; if (rest_count == 1) alignment = rest[0]; - } else if (velocity == SEQUENCE_CONTROL_GATE) { - action = SEQUENCE_CONTROL_GATE; + } else if (action == SEQUENCE_CONTROL_GATE) { shape_valid = rest_count >= 1 && rest_count <= 2; value = rest[0]; if (rest_count == 2) alignment = rest[1]; @@ -785,8 +786,8 @@ void handle_ticks_message(char *message) { if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: velocity must be in [0,1], " - "or use gate=2 with a duration; tag, duration, and " + "invalid sequence_control: run must be 0 or 1, or use " + "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { sequencer_sequence_control((uint32_t)parsed_tag, action, value, diff --git a/src/sequencer.h b/src/sequencer.h index 1a674029..83923fb7 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, velocity, alignment_period] or +// sequence_control is [tag, run, alignment_period] (run is 0 or 1) or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_js_api.js b/tests/test_js_api.js index aaee955c..8382b2c0 100644 --- a/tests/test_js_api.js +++ b/tests/test_js_api.js @@ -6,8 +6,8 @@ const path = require("node:path"); require(path.join(__dirname, "..", "src", "amy_api.generated.js")); assert.equal( - amy_message({sequence_control: [7, 0.625, 48]}), - "HC7,0.625,48Z" + amy_message({sequence_control: [7, 1, 48]}), + "HC7,1,48Z" ); assert.equal( amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 72aa51a2..ffa46dcb 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,18 +22,16 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" - assert amy.message(sequence_control=(7, 0.625, 48)) == "HC7,0.625,48Z" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" - assert amy.message(sequence=7, vel=0.625) == "HC7,1,0Z" - assert amy.message(sequence=7, vel=0, alignment_period=48) \ + assert amy.message(sequence=7, run=True) == "HC7,1,0Z" + assert amy.message(sequence=7, run=1) == "HC7,1,0Z" + assert amy.message(sequence=7, run=False, alignment_period=48) \ == "HC7,0,48Z" - assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, + assert amy.message(sequence=7, run=0) == "HC7,0,0Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, run=True, alignment_period=1) == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, vel="%v", alignment_period=1) \ - == "HC7,%v,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" @@ -63,11 +61,17 @@ def main(): expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) expect_error("action", lambda: amy.message(sequence_control=(2, 99))) expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) - expect_error("needs vel", lambda: amy.message(sequence=2)) + expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) + expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("needs run", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( - sequence=2, vel=1, synth=1)) + sequence=2, run=True, synth=1)) expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) - expect_error("non-negative", lambda: amy.message(sequence=2, vel=-1)) + expect_error("only valid", lambda: amy.message(run=True, synth=1)) + expect_error("True/False", lambda: amy.message(sequence=2, run=0.625)) + expect_error("True/False", lambda: amy.message(sequence=2, run=1.0)) + expect_error("True/False", lambda: amy.message(sequence=2, run=2)) + expect_error("True/False", lambda: amy.message(sequence=2, run="%v")) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 243db417..f7daab00 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -445,27 +445,27 @@ static void test_wire_control_shape_is_strict(void) { sequencer_reset(); clear_marks(); - amy_add_message("H0,1,4zPvelocity-startZ"); - amy_add_message("HC4,0.625,1Z"); + amy_add_message("H0,1,4zPrun-startZ"); + amy_add_message("HC4,1,1Z"); start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("velocity-start", start), - "a positive fractional template velocity starts a sequence"); + CHECK(mark_at("run-start", start), "run=1 starts a sequence"); amy_add_message("HC4,0,1Z"); clock_to(sequencer_ticks() + 1); - CHECK(!mark_at("velocity-start", sequencer_ticks()), - "zero template velocity stops a sequence"); + CHECK(!mark_at("run-start", sequencer_ticks()), + "run=0 stops a sequence"); sequencer_reset(); clear_marks(); amy_add_message("H0,1,5zPmalformed-startZ"); - amy_add_message("HC5,-0.1,1Z"); + amy_add_message("HC5,-1,1Z"); + amy_add_message("HC5,0.5,1Z"); amy_add_message("HC5,0.5,1.5Z"); amy_add_message("HC5,1,Z"); amy_add_message("HC4294967296,1Z"); clock_to(sequencer_ticks() + 2); CHECK(!marks_named("malformed-start"), - "invalid velocity, integer, empty, and overflowing fields are rejected"); + "invalid action, fractional, empty, and overflowing fields are rejected"); } static void test_start_crosses_clock_rollover(void) { From cc2407ff8898e42694c0dee9b4b67444659fdede Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:36:30 +0200 Subject: [PATCH 049/112] Document boolean sequence run control --- amy/examples.py | 2 +- docs/api.md | 2 +- docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 6 +++--- docs/sequencer-sequences-howto.md | 14 +++++++------- docs/sequencer-sequences.md | 14 +++++++------- docs/tutorial.html | 12 ++++++------ docs/upgrading.md | 6 +++++- 8 files changed, 31 insertions(+), 27 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index 66ed537e..b880af8d 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,7 +257,7 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Store all parts as one reusable pattern, then start it like a note. + # Store all parts as one reusable pattern, then start it explicitly. amy.define_sequence(0, [ dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter diff --git a/docs/api.md b/docs/api.md index b59b8e9c..c3283901 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,7 +507,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | -| `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | +| `HC` | — | `sequence_control` | tag,run[,alignment] or tag,gate,duration[,alignment] | Start (`run=1`), stop (`run=0`), align, or temporarily gate (`gate=2`) a reusable tagged sequence. Run is strictly `0` or `1`, not a velocity or fractional value. Python callers can use `amy.send(sequence=tag, run=True/False, alignment_period=...)`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/midi.md b/docs/midi.md index 7cfe0727..19927fd2 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence=1, vel=1, alignment_period=48) +amy.send(sequence=1, run=True, alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 18ab3a00..36a2441d 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,9 +8,9 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -At the Python API, `amy.send(sequence=tag, vel=...)` makes start and stop look -like note-on and note-off. Internally its compact `sequence_control` operation -provides: +At the Python API, `amy.send(sequence=tag, run=True)` starts and +`run=False` stops. This boolean deliberately does not reuse note velocity. +Internally its compact `sequence_control` operation provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index af9acfa1..ad543933 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,13 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, vel=1, alignment_period=1), - dict(ticks=(24, 48), sequence=21, vel=1, alignment_period=1), + dict(ticks=(0, 48), sequence=20, run=True, alignment_period=1), + dict(ticks=(24, 48), sequence=21, run=True, alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, vel=1, alignment_period=1), - dict(ticks=(12, 24), sequence=21, vel=1, alignment_period=1), + dict(ticks=(0, 24), sequence=20, run=True, alignment_period=1), + dict(ticks=(12, 24), sequence=21, run=True, alignment_period=1), ]) ``` @@ -35,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence=30, vel=1, alignment_period=48) +amy.send(sequence=30, run=True, alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence=30, vel=0, alignment_period=48) -amy.send(sequence=31, vel=1, alignment_period=48) +amy.send(sequence=30, run=False, alignment_period=48) +amy.send(sequence=31, run=True, alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 768811f1..b2a3856a 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,15 +37,15 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence=40, vel=1, alignment_period=1) -amy.send(sequence=40, vel=0, alignment_period=48) +amy.send(sequence=40, run=True, alignment_period=1) +amy.send(sequence=40, run=False, alignment_period=48) ``` -This deliberately resembles note-on/note-off: `vel` in the range `(0, 1]` -starts the sequence and zero stops it. The wire representation remains the -lower-level `sequence_control` operation, where that same field has velocity -semantics. Existing command templates can therefore substitute their value -directly into `HCtag,%v,alignment`. The optional `alignment_period` is the +`run` is a boolean: true starts the sequence and false stops it. It is separate +from `vel`, which keeps its usual meaning of note velocity. At the lower-level +`sequence_control` API and on the wire, run is represented by the integer `1` +or `0`: `HCtag,run,alignment`. Fractional values are invalid rather than being +interpreted as a sequence state. The optional `alignment_period` is the alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, diff --git a/docs/tutorial.html b/docs/tutorial.html index 12dbfabc..25805863 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. -amy.send(sequence=1, vel=1, alignment_period=1) -amy.send(sequence=2, vel=1, alignment_period=1) +amy.send(sequence=1, run=True, alignment_period=1) +amy.send(sequence=2, run=True, alignment_period=1)

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(sequence=1, vel=0, alignment_period=1) +amy.send(sequence=1, run=False, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence=1, vel=1, alignment_period=1) +amy.send(sequence=1, run=True, alignment_period=1)

For patterns you want to also address their "slots", which is the offset within the pattern, like this

-amy.send(sequence=1, vel=0, alignment_period=1) +amy.send(sequence=1, run=False, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence=1, vel=1, alignment_period=384) +amy.send(sequence=1, run=True, alignment_period=384)
diff --git a/docs/upgrading.md b/docs/upgrading.md index c301d4f8..91e861e5 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -34,9 +34,13 @@ amy.define_sequence(7, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(12,), synth=1, note=60, vel=0), ]) -amy.send(sequence=7, vel=1, alignment_period=1) +amy.send(sequence=7, run=True, alignment_period=1) ``` +Sequence execution is a boolean state, not a note velocity. Use `run=True` or +`run=False` in the Python convenience API. The corresponding low-level and +wire values are the integers `1` and `0`; fractional values are rejected. + The C configuration adds `max_sequence_events` and `max_sequence_executions`. They are appended to `amy_config_t`; initialize the structure with `amy_default_config()` and then override named fields, as in all From 841ccd29e92ff7ed820ff998735f93fd785a97dc Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:51:56 +0200 Subject: [PATCH 050/112] Expose named sequence control actions --- amy/__init__.py | 47 +++++++++++++++++++++----------- src/parse.c | 7 +++-- src/sequencer.h | 2 +- tests/test_sequence_api.py | 27 ++++++++++-------- tests/test_sequencer_sequences.c | 8 +++--- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index d9efe123..106cfe5e 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -269,7 +269,7 @@ def _sequence_control_values(value): raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, run, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') @@ -278,37 +278,52 @@ def _sequence_control_values(value): return values -def _normalize_sequence_run(kwargs): - """Translate boolean sequence control into the existing HC primitive.""" +def _normalize_sequence_action(kwargs): + """Translate a named sequence action into the existing HC primitive.""" if 'sequence' not in kwargs: - for key in ('run', 'alignment_period'): + for key in ('action', 'duration', 'alignment_period'): if key in kwargs: raise ValueError('%s is only valid with sequence.' % key) return kwargs if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') - extra = set(kwargs) - {'sequence', 'run', 'alignment_period', 'ticks'} + extra = set(kwargs) - { + 'sequence', 'action', 'duration', 'alignment_period', 'ticks' + } if extra: - raise ValueError('sequence can only be combined with run, alignment_period, and ticks.') - if 'run' not in kwargs: - raise ValueError('sequence needs run=True to start or run=False to stop.') + raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') + if 'action' not in kwargs: + raise ValueError("sequence needs action='start', 'stop', or 'gate'.") tag = int(kwargs['sequence']) if tag < 0: raise ValueError('Sequence tag must be non-negative.') alignment = int(kwargs.get('alignment_period', 0)) if alignment < 0: raise ValueError('Sequence alignment_period must be non-negative.') - run = kwargs['run'] - if isinstance(run, bool): - action = SEQUENCE_CONTROL_START if run else SEQUENCE_CONTROL_STOP - elif isinstance(run, int) and run in (0, 1): - action = run + action_name = kwargs['action'] + actions = { + 'stop': SEQUENCE_CONTROL_STOP, + 'start': SEQUENCE_CONTROL_START, + 'gate': SEQUENCE_CONTROL_GATE, + } + if not isinstance(action_name, str) or action_name not in actions: + raise ValueError("Sequence action must be 'start', 'stop', or 'gate'.") + action = actions[action_name] + if action == SEQUENCE_CONTROL_GATE: + if 'duration' not in kwargs: + raise ValueError("Sequence action='gate' needs a duration in ticks.") + duration = int(kwargs['duration']) + if duration < 0: + raise ValueError('Sequence gate duration must be non-negative.') + control = (tag, action, duration, alignment) else: - raise ValueError('Sequence run must be True/False or numeric 1/0.') + if 'duration' in kwargs: + raise ValueError('Sequence duration is only valid with action=\'gate\'.') + control = (tag, action, alignment) normalized = {} if 'ticks' in kwargs: normalized['ticks'] = kwargs['ticks'] - normalized['sequence_control'] = (tag, action, alignment) + normalized['sequence_control'] = control return normalized @@ -353,7 +368,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS - kwargs = _normalize_sequence_run(kwargs) + kwargs = _normalize_sequence_action(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/src/parse.c b/src/parse.c index 5abe4cd0..6f971d03 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,7 +737,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,run[,alignment_period], where run is exactly 0 or 1. + // HCtag,action[,alignment_period], for stop=0 or start=1. // HCtag,gate,duration[,alignment_period] const char *tag_start = message + 2; while (*tag_start == ' ') ++tag_start; @@ -763,7 +763,7 @@ void handle_ticks_message(char *message) { || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,run[,alignment_period] (run is 0 or 1) or " + "HCtag,action[,alignment_period] (stop=0, start=1) or " "HCtag,gate,duration[,alignment_period]\n"); return; } @@ -786,7 +786,8 @@ void handle_ticks_message(char *message) { if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: run must be 0 or 1, or use " + "invalid sequence_control: action must be stop=0, " + "start=1, or use " "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { diff --git a/src/sequencer.h b/src/sequencer.h index 83923fb7..e837d0c8 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, run, alignment_period] (run is 0 or 1) or +// sequence_control is [tag, action, alignment_period] for stop/start or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index ffa46dcb..393d8a15 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -25,12 +25,12 @@ def main(): assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, run=True) == "HC7,1,0Z" - assert amy.message(sequence=7, run=1) == "HC7,1,0Z" - assert amy.message(sequence=7, run=False, alignment_period=48) \ + assert amy.message(sequence=7, action="start") == "HC7,1,0Z" + assert amy.message(sequence=7, action="stop", alignment_period=48) \ == "HC7,0,48Z" - assert amy.message(sequence=7, run=0) == "HC7,0,0Z" - assert amy.message(ticks=(0, 48, 3), sequence=7, run=True, + assert amy.message(sequence=7, action="gate", duration=24, + alignment_period=1) == "HC7,2,24,1Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, action="start", alignment_period=1) == "H0,48,3HC7,1,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ @@ -63,15 +63,18 @@ def main(): expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) expect_error("integer", lambda: amy.message(sequence_control=(2, True))) - expect_error("needs run", lambda: amy.message(sequence=2)) + expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( - sequence=2, run=True, synth=1)) + sequence=2, action="start", synth=1)) expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) - expect_error("only valid", lambda: amy.message(run=True, synth=1)) - expect_error("True/False", lambda: amy.message(sequence=2, run=0.625)) - expect_error("True/False", lambda: amy.message(sequence=2, run=1.0)) - expect_error("True/False", lambda: amy.message(sequence=2, run=2)) - expect_error("True/False", lambda: amy.message(sequence=2, run="%v")) + expect_error("only valid", lambda: amy.message(action="start", synth=1)) + expect_error("start", lambda: amy.message(sequence=2, action=True)) + expect_error("start", lambda: amy.message(sequence=2, action=1)) + expect_error("duration", lambda: amy.message(sequence=2, action="gate")) + expect_error("only valid", lambda: amy.message( + sequence=2, action="start", duration=1)) + expect_error("non-negative", lambda: amy.message( + sequence=2, action="gate", duration=-1)) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index f7daab00..6d4eac8d 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -445,15 +445,15 @@ static void test_wire_control_shape_is_strict(void) { sequencer_reset(); clear_marks(); - amy_add_message("H0,1,4zPrun-startZ"); + amy_add_message("H0,1,4zPaction-startZ"); amy_add_message("HC4,1,1Z"); start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("run-start", start), "run=1 starts a sequence"); + CHECK(mark_at("action-start", start), "action start=1 starts a sequence"); amy_add_message("HC4,0,1Z"); clock_to(sequencer_ticks() + 1); - CHECK(!mark_at("run-start", sequencer_ticks()), - "run=0 stops a sequence"); + CHECK(!mark_at("action-start", sequencer_ticks()), + "action stop=0 stops a sequence"); sequencer_reset(); clear_marks(); From f03875f239d85f44d7d3d4115e0b51bf6482318d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:51:56 +0200 Subject: [PATCH 051/112] Document start stop and gate actions --- amy/examples.py | 2 +- docs/api.md | 2 +- docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 7 ++++--- docs/sequencer-sequences-howto.md | 18 +++++++++--------- docs/sequencer-sequences.md | 20 +++++++++++--------- docs/tutorial.html | 12 ++++++------ docs/upgrading.md | 10 ++++++---- 8 files changed, 39 insertions(+), 34 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index b880af8d..309516ec 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -264,7 +264,7 @@ def example_sequencer_drums(): dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle ]) - amy.send(sequence=0, run=True, alignment_period=1) + amy.send(sequence=0, action='start', alignment_period=1) def example_fm(): amy.reset() diff --git a/docs/api.md b/docs/api.md index c3283901..750a7893 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,7 +507,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | -| `HC` | — | `sequence_control` | tag,run[,alignment] or tag,gate,duration[,alignment] | Start (`run=1`), stop (`run=0`), align, or temporarily gate (`gate=2`) a reusable tagged sequence. Run is strictly `0` or `1`, not a velocity or fractional value. Python callers can use `amy.send(sequence=tag, run=True/False, alignment_period=...)`. | +| `HC` | — | `sequence_control` | tag,action[,alignment] or tag,gate,duration[,alignment] | Stop (`action=0`), start (`action=1`), align, or temporarily gate (`action=2`) a reusable tagged sequence. Actions are integers, not velocity or fractional values. Python callers use the named `action='stop'`, `'start'`, or `'gate'`; gate also requires `duration`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/midi.md b/docs/midi.md index 19927fd2..96f0f6e3 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence=1, run=True, alignment_period=48) +amy.send(sequence=1, action='start', alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 36a2441d..80480d4a 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,9 +8,10 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -At the Python API, `amy.send(sequence=tag, run=True)` starts and -`run=False` stops. This boolean deliberately does not reuse note velocity. -Internally its compact `sequence_control` operation provides: +At the Python API, `amy.send(sequence=tag, action='start')`, `action='stop'`, +and `action='gate'` expose the full operation rather than presenting part of it +as a boolean. These named actions do not reuse note velocity. Internally the +compact `sequence_control` operation provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index ad543933..3c1d57a0 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,13 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, run=True, alignment_period=1), - dict(ticks=(24, 48), sequence=21, run=True, alignment_period=1), + dict(ticks=(0, 48), sequence=20, action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, action='start', alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, run=True, alignment_period=1), - dict(ticks=(12, 24), sequence=21, run=True, alignment_period=1), + dict(ticks=(0, 24), sequence=20, action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, action='start', alignment_period=1), ]) ``` @@ -35,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence=30, run=True, alignment_period=48) +amy.send(sequence=30, action='start', alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence=30, run=False, alignment_period=48) -amy.send(sequence=31, run=True, alignment_period=48) +amy.send(sequence=30, action='stop', alignment_period=48) +amy.send(sequence=31, action='start', alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child @@ -78,14 +78,14 @@ can suppress its events for one quarter note at 48 PPQ without stopping its clock: ```python -amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) +amy.send(sequence=50, action='gate', duration=48, alignment_period=1) ``` After 48 ticks the gate expires and events resume on their original phase. Duration zero removes a current gate explicitly: ```python -amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) +amy.send(sequence=50, action='gate', duration=0, alignment_period=1) ``` The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index b2a3856a..92b17179 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,16 +37,18 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence=40, run=True, alignment_period=1) -amy.send(sequence=40, run=False, alignment_period=48) +amy.send(sequence=40, action='start', alignment_period=1) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) ``` -`run` is a boolean: true starts the sequence and false stops it. It is separate -from `vel`, which keeps its usual meaning of note velocity. At the lower-level -`sequence_control` API and on the wire, run is represented by the integer `1` -or `0`: `HCtag,run,alignment`. Fractional values are invalid rather than being -interpreted as a sequence state. The optional `alignment_period` is the -alignment quantum. `0` or `1` acts at the next +The named actions expose the complete control model: `start` creates an +execution, `stop` terminates the selected executions, and `gate` temporarily +suppresses their ordinary events for the required `duration`. `vel` keeps its +usual meaning of note velocity. At the lower-level `sequence_control` API and +on the wire, actions use integers: stop `0`, start `1`, and gate `2`. +Fractional values are invalid. The optional `alignment_period` is the alignment +quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, the child's local tick zero participates in the same tick. @@ -72,7 +74,7 @@ No explicit sequence length or publish action is needed: ## Temporary event gating ```python -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) ``` This suppresses ordinary event dispatch from active executions of tag `40` for diff --git a/docs/tutorial.html b/docs/tutorial.html index 25805863..035cdf50 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. -amy.send(sequence=1, run=True, alignment_period=1) -amy.send(sequence=2, run=True, alignment_period=1) +amy.send(sequence=1, action='start', alignment_period=1) +amy.send(sequence=2, action='start', alignment_period=1)

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(sequence=1, run=False, alignment_period=1) +amy.send(sequence=1, action='stop', alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence=1, run=True, alignment_period=1) +amy.send(sequence=1, action='start', alignment_period=1)

For patterns you want to also address their "slots", which is the offset within the pattern, like this

-amy.send(sequence=1, run=False, alignment_period=1) +amy.send(sequence=1, action='stop', alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence=1, run=True, alignment_period=384) +amy.send(sequence=1, action='start', alignment_period=384)
diff --git a/docs/upgrading.md b/docs/upgrading.md index 91e861e5..a4a16385 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -34,12 +34,14 @@ amy.define_sequence(7, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(12,), synth=1, note=60, vel=0), ]) -amy.send(sequence=7, run=True, alignment_period=1) +amy.send(sequence=7, action='start', alignment_period=1) ``` -Sequence execution is a boolean state, not a note velocity. Use `run=True` or -`run=False` in the Python convenience API. The corresponding low-level and -wire values are the integers `1` and `0`; fractional values are rejected. +Sequence control is an explicit action, not a note velocity. Use +`action='start'`, `action='stop'`, or `action='gate'` in the Python convenience +API; gate additionally requires `duration`. The corresponding low-level and +wire action values are the integers `1`, `0`, and `2`; fractional values are +rejected. The C configuration adds `max_sequence_events` and `max_sequence_executions`. They are appended to `amy_config_t`; initialize the From 596047be3d5a9013822660f996279356e2492abe Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:12:12 +0200 Subject: [PATCH 052/112] Document reusable sequence model and compatibility --- docs/sequencer-sequences-abstractions.md | 222 +++++++++++------- docs/sequencer-sequences-howto.md | 119 ++++++++-- docs/sequencer-sequences-musical-use-cases.md | 99 ++++---- docs/sequencer-sequences-status.md | 154 ++++++++++++ docs/sequencer-sequences.md | 19 +- docs/synth.md | 6 +- docs/upgrading.md | 4 + 7 files changed, 458 insertions(+), 165 deletions(-) create mode 100644 docs/sequencer-sequences-status.md diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 80480d4a..b5422873 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -1,88 +1,138 @@ # Reusable sequence abstractions and implementation -## Public model - -The existing sequencer tag is the sequence identity. Every ordinary -`ticks=(tick, period, tag)` message appends an event to that tag. The tag is -reset explicitly and controlled with one start/stop operation. There is no -second group namespace, separate append command, fourth `ticks` field, -explicit length, or publish/revision operation. - -At the Python API, `amy.send(sequence=tag, action='start')`, `action='stop'`, -and `action='gate'` expose the full operation rather than presenting part of it -as a boolean. These named actions do not reuse note velocity. Internally the -compact `sequence_control` operation provides: - -- start, optionally aligned to an AMY sequencer period; -- stop all active executions of a tag at an optional boundary; -- gate ordinary events for a finite duration without resetting local phase. - -Sequences may start or stop other sequences. A finite controller can therefore -express a fixed repeat count, and a parent can stop launching new note-pair -children while children already in progress deliver their note-offs. - -## Why executions exist internally - -A stored definition and an active execution have different lifetimes without -being different public abstractions. An execution needs a local start tick and -must retain the event data it began with. Otherwise editing a future phrase -could remove a note-off or alter a fill already sounding. - -AMY therefore uses a bounded execution pool and reference-counted copy-on-write -definitions. Editing a definition used by an execution clones it. The active -execution keeps its old snapshot; later starts see the new contents. No -revision number or execution ID is exposed. - -The copy is constructed while the old definition is pinned, but outside the -queue lock also used by rendering. Publication is a short checked pointer swap. -When the last execution releases an obsolete definition, the render path links -it onto an intrusive retirement list; a later non-rendering control call -detaches that list and performs the variable-time string and heap frees. The -audio path therefore neither copies nor frees a definition. Internally fired -wire payloads bypass the public wire-ingest boundary, while that public boundary -drains the retirement list after parsing. This makes reclamation a structural -control-path property rather than a best-effort test of concurrent render state. - -This is reference-counted deferred reclamation, not a tracing garbage -collector. A fixed two-buffer ping-pong is insufficient because overlapping or -repeating executions can retain more than two generations at once. Allocating -versions only when an active definition is edited keeps the normal preload path -linear and bounds retained generations through the configured execution pool. -This matters in particular on embedded targets, where allocator and external- -memory/cache latency must not extend a render-thread critical section. - -Finite executions of one tag may overlap. This supports phrases whose note -gate exceeds their trigger interval without transferring note state to the -caller. - -## Lifetime inference and tick processing - -If every event has `period=0`, the execution retires after its greatest local -tick. If any event has a nonzero period, it remains active until stopped. - -Only untagged scheduled entries and active sequence executions are visited per -tick. Stored inactive definitions have no per-tick cost. Sequence controls are -processed before ordinary events, so a boundary stop prevents an event on that -boundary and a child start can include local tick zero on the same tick. - -Gating suppresses ordinary payload dispatch while elapsed local time advances. -Control events are not gated, preventing a controller from muting its own -recovery operation. - -## Bounds and recovery - -Startup configuration bounds tags, events per definition, and simultaneous -executions. A cyclic control graph may fill the execution pool, but cannot grow -beyond it; later starts fail clearly and the caller can stop a tag or reset the -sequencer. - -Aligned stop and gate commands capture the executions active when the command -is sent. An execution started later does not inherit previously pending control -state merely because its tag matches. This keeps control ownership on explicit -executions rather than creating a hidden per-tag automation timeline. - -The ordinary three-field C event layout remains unchanged. Untagged one-off -and periodic scheduling, MIDI/external-clock behavior, and global reset retain -their existing behavior. The intentional API change is that a supplied tag now -creates a stopped reusable sequence and repeated writes cumulate instead of -replacing one scheduled event. +## Public abstractions + +### Definition + +A three-value `ticks=(tick, period, tag)` event contributes one ordinary AMY +event to the reusable definition identified by `tag`. Repeating the tag +accumulates events. Ticks in a definition are local to each execution. + +`amy.define_sequence(tag, events)` is a Python replace-as-a-list convenience: +it validates every event, resets the future definition, and then sends the +tagged events. `sequence_reset=tag` resets only the definition used by future +starts. It does not rewrite an execution which already started. + +### Execution + +The action `start` creates an execution with its own local start tick. Several +finite executions of the same definition may overlap. The action `stop` +selects all executions of the tag which are active when the action is issued. +If the stop is aligned to a later boundary, an execution started after the +stop request is not implicitly captured by it. + +An execution containing only period-zero events is finite and retires after +its greatest local tick. If any event has a nonzero period, the execution +repeats until stopped. + +### Gate + +The action `gate` suppresses ordinary event dispatch for a duration while +local phase advances. It does not stop audio which is already ringing. +Sequence-control events continue to run while gated, allowing a finite +controller sequence to restore or change another sequence without being +blocked by its own gate. + +### Composition + +A stored payload may be an ordinary AMY event or a control for another +sequence. A finite sequence can therefore launch note gestures, control a +periodic sequence for a fixed number of repeats, or coordinate several +independent phrases. Cycles are not recursively expanded through C call +frames: each successful start occupies a slot in the bounded execution pool, +so a cyclic graph fails further starts once that pool is full and remains +recoverable through stop or reset. + +## Event ordering and ownership + +For a given tick, sequence controls are processed before ordinary events. A +stop on a boundary therefore prevents the ordinary event on that boundary, +and a child start can include the child's local tick-zero event on the same +tick. + +Stopping an execution cancels its future payloads. AMY cannot synthesize a +generic inverse for arbitrary events: a payload may change a filter, load a +patch, start another sequence, or send a note. If a phrase must complete a +release, store that release in a finite child and stop the parent which creates +future children. If the caller intentionally stops the child itself, its +remaining payloads are intentionally cancelled. + +## Immutable snapshots + +A definition and an execution have different lifetimes. Once an execution +starts, it holds a reference to the exact definition version it observed. +Changing the tag publishes a new version for future starts; existing +executions continue to read their old versions. This prevents a live edit from +removing a pending note-off or changing another payload halfway through a +phrase. + +The implementation uses copy-on-write snapshot semantics. A definition owned +only by its tag can be appended in place. If an execution or competing writer +also holds it, an editor pins that source and constructs a complete candidate +copy. This is the data-versioning rule; it is not by itself sufficient for a +real-time audio thread because copying and freeing are variable-time work. + +## RCU-like publication and deferred reclamation + +Candidate construction happens outside `amy_queue_lock`. After cloning the +events and their wire strings, the editor briefly reacquires the lock and +publishes the candidate only if the tag still points to the source it cloned. +Publication is therefore a checked pointer swap. If another writer won the +race, the losing writer discards its private candidate outside the lock and +retries from the newly published definition. Concurrent cumulative writers do +not silently lose one another's events. + +Executions act as readers by retaining references to their immutable versions. +When the render path releases the last reference, it does not free the event +array or its strings. It links the definition onto an intrusive retired list, +which requires no allocation. A later non-rendering command boundary detaches +that list under the lock and performs destruction after releasing the lock. +Internally fired sequence payloads bypass the public command boundary so they +cannot accidentally reclaim memory from the render path. + +This is an RCU-like publication and reclamation scheme with explicit reference +counts, not a tracing garbage collector. Copy-on-write still describes how a +new immutable version is created; RCU-like publication describes how readers +continue safely and how old versions are retired without waiting or freeing on +the audio path. + +Two fixed ping-pong buffers are insufficient. Multiple overlapping or +indefinitely repeating executions may retain more than two historical +generations while additional edits are published. Explicit references allow +exactly the generations which remain in use to survive. A general garbage +collector would add machinery without improving that already-known ownership. + +## Why this matters on ESP32 + +At 48 kHz with 128-sample render blocks, one block represents approximately +2.67 ms. Heap allocation, copying many variable-length wire strings, heap +coalescing, PSRAM/cache latency, and destruction of an entire definition are +not usefully bounded operations within that deadline. Performing them while +holding the lock shared with sequence rendering can turn an infrequent live +edit into an audio dropout. + +The current design limits the shared-lock publication step to reference +updates, validation, and a pointer swap. The render path releases references +and links retired objects without allocating or freeing. This removes the +known variable-time definition work from the render critical section. + +That architecture reduces and bounds the source-level risk; it is not a claim +that every ESP32 configuration is proven hard real-time. Final assurance still +requires measurement on the target board with the intended sample rate, block +size, memory capabilities, effects load, concurrent authoring traffic, heap +low-water mark, and worst observed render deadline. + +## Capacity and per-tick cost + +`max_sequencer_tags` bounds definition identities. `max_sequence_events` +bounds events in one definition, and `max_sequence_executions` bounds active +or alignment-pending executions. Definitions allocate lazily. The tick loop +visits active executions and directly scheduled entries, not every inactive +definition. + +Allocation failure, a full definition, an unavailable execution slot, an +invalid tag, and malformed action shapes fail with diagnostics. A failed +publication leaves the previously published definition intact. + +See [Status and compatibility](sequencer-sequences-status.md) for validated +behavior, platform limits, and migration guidance. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 3c1d57a0..c3931b7a 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,93 +1,160 @@ # Reusable sequence how-to -This example preloads two arpeggios and switches between them without cutting -short a note which already started. +This example preloads two arpeggios, starts one, and switches to the other on a +musical boundary. Python is the primary interface; the equivalent wire +messages are collected afterward. -## 1. Define note-pair sequences +AMY's sequencer uses 48 ticks per quarter note. The example gives every note +an 18-tick gate and uses 48 ticks as its switching boundary. + +## 1. Define complete note gestures + +Store each note-on together with its note-off in a finite sequence: ```python +import amy + amy.define_sequence(20, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(18,), synth=1, note=60, vel=0), ]) + amy.define_sequence(21, [ dict(ticks=(0,), synth=1, note=64, vel=1), dict(ticks=(18,), synth=1, note=64, vel=0), ]) ``` -## 2. Define two arpeggio parents +Both definitions contain only period-zero events. Each start therefore creates +a finite execution which retires after its tick-18 note-off. + +## 2. Define two arpeggios + +The slower arpeggio starts the two note gestures half a quarter note apart. +The faster one starts them an eighth note apart: ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, action='start', alignment_period=1), - dict(ticks=(24, 48), sequence=21, action='start', alignment_period=1), + dict(ticks=(0, 48), sequence=20, + action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, + action='start', alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, action='start', alignment_period=1), - dict(ticks=(12, 24), sequence=21, action='start', alignment_period=1), + dict(ticks=(0, 24), sequence=20, + action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, + action='start', alignment_period=1), ]) ``` -The parents contain periodic events and run until stopped. +The nonzero periods make these parent executions repeat until explicitly +stopped. A stored sequence may contain ordinary AMY events or controls for +other sequences. ## 3. Start and switch ```python amy.send(sequence=30, action='start', alignment_period=48) -# Later, switch both parents at the same boundary. +# Later: stop the old parent and start the new one at the same boundary. amy.send(sequence=30, action='stop', alignment_period=48) amy.send(sequence=31, action='start', alignment_period=48) ``` -The old parent starts no more children at that boundary. A note-pair child -started earlier remains independent and still sends its tick-18 note-off. +The stop prevents sequence 30 from launching another child at the selected +boundary. A note gesture launched before that boundary is an independent +execution, so it still sends its original note-off. The caller does not need +to mirror AMY's tick count or remember pending releases. + +Start may be sent again while an earlier finite execution of the same tag is +active. Each execution has its own local start tick and immutable definition +snapshot. + +## 4. Stop playback + +```python +amy.send(sequence=31, action='stop', alignment_period=48) +``` + +Stopping a parent cancels its future child launches. Stopping a leaf such as +sequence 20 instead deliberately cancels the future events of every selected +active leaf execution, including any pending note-off. This lets the caller +choose between a graceful parent stop and explicit truncation.
-Equivalent low-level wire messages +Equivalent wire messages + +`H,,Z` appends a normal event to a reusable +definition. `HRZ` resets future contents. `HC` uses action `0` for stop, +`1` for start, and `2` for gate. ```text HR20Z H0,0,20n60l1i1Z H18,0,20n60l0i1Z + HR21Z H0,0,21n64l1i1Z H18,0,21n64l0i1Z + HR30Z H0,48,30HC20,1,1Z H24,48,30HC21,1,1Z + HR31Z H0,24,31HC20,1,1Z H12,24,31HC21,1,1Z + HC30,1,48Z HC30,0,48Z HC31,1,48Z +HC31,0,48Z ``` -Ordinary `Htick,period,tag...` messages cumulate behind the tag. `HR` resets -one definition and `HC` controls its executions. +The final field of each `HC` message is the alignment period. Direct controls +with alignment `0` or `1` act on the next available sequencer tick; a larger +value selects the next global tick divisible by that value.
-## Temporarily gate one percussion layer +## Temporarily gate one layer -Suppose tag `50` is already running a periodic percussion sequence. A caller -can suppress its events for one quarter note at 48 PPQ without stopping its -clock: +Suppose sequence 50 is a running periodic percussion layer. Suppress its +ordinary events for one quarter note without stopping its local clock: ```python -amy.send(sequence=50, action='gate', duration=48, alignment_period=1) +amy.send( + sequence=50, + action='gate', + duration=48, + alignment_period=1, +) ``` -After 48 ticks the gate expires and events resume on their original phase. -Duration zero removes a current gate explicitly: +After 48 ticks, ordinary event dispatch resumes on the original phase. Audio +which was already ringing is not cut off. A zero-duration gate removes the +current gate at the selected boundary: ```python -amy.send(sequence=50, action='gate', duration=0, alignment_period=1) +amy.send( + sequence=50, + action='gate', + duration=0, + alignment_period=1, +) +``` + +
+Equivalent gate wire messages + +```text +HC50,2,48,1Z +HC50,2,0,1Z ``` -The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their -source may be a foot pedal, UI, network controller, or another sequence; AMY -only sees generic tagged sequence control. +
+ +For the complete lifecycle and reset rules, see +[Reusable sequences](sequencer-sequences.md). diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 4f1f087a..06ad3cc9 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -1,62 +1,75 @@ # Musical use cases for reusable sequences -Reusable sequences reduce controller complexity when a musical phrase contains -several events but should be launched as one unit. The examples below describe -generic rhythm-engine behavior; AMY assigns no musical meaning to a tag. +Reusable sequences let a caller define a collection of ordinary AMY events +once and launch that collection as one musical unit. AMY gives no musical +meaning to a sequence tag: a sequence may contain notes, parameter changes, or +controls for other sequences. -## Preloaded fills +## Preloaded fills and phrases -A rhythm engine can preload each fill once as a finite tagged sequence. Its -root schedule then stores only sequence starts. Selecting or deselecting a fill -changes future root launches, not the complete fill body. +A rhythm engine can preload each fill or phrase as a finite sequence. Its live +schedule then needs only a sequence start instead of another copy of every +event in the phrase. This keeps controller traffic and controller code small +even when the phrase catalogue is large. -An already-started fill holds its immutable definition and finishes even if its -future launches are removed. The controller does not calculate an end time, -stream the phrase repeatedly, or maintain an active-fill state machine. +An execution retains the definition with which it started. Rebuilding the +stored definition affects later starts but does not alter a phrase already in +progress. The caller therefore does not need to stream the phrase repeatedly, +calculate when it ends, or track which definition version is sounding. -## Arpeggios and note lifetime +## Arpeggios with complete note ownership -A short child sequence can contain one note-on and its matching note-off. A -parent sequence starts these children in an arpeggio pattern. Stopping or -replacing the parent prevents future child starts; children which already -started keep their scheduled release. +A short finite sequence can hold a note-on together with its matching +note-off. A periodic parent sequence can start these note-pair sequences in an +arpeggio pattern. -This makes live rate, direction, voicing, or chord changes predictable without -requiring the controller to mirror AMY's clock or remember which note-offs are -still pending. Starting the same finite child again may overlap with an older -execution; each execution retains its own event snapshot. +Stopping or replacing the parent prevents later child starts. Children which +already started remain independent and deliver their original note-offs. A +live change of rate, direction, voicing, or harmony can therefore be expressed +without mirroring AMY's clock or maintaining pending-note state in the caller. -An explicit stop of the child tag has the different, generic meaning of -terminating every active execution of that child. A caller can therefore choose -between stopping future launches at a parent and deliberately truncating the -leaf itself. +Starting the same child again while an older execution is active is valid. +This permits note gates to overlap their trigger interval. If a caller instead +wants to truncate every active instance of the child, it can explicitly stop +the child's tag. -## Temporarily reducing a rhythm +## Temporarily thinning a rhythm -A repeating percussion layer can be represented by a periodic sequence. A -finite gate suppresses its ordinary events for a chosen number of ticks while -its local phase keeps advancing. Once the gate expires, it resumes at the point -it would otherwise have reached; already-ringing audio is unaffected. +A repeating percussion layer can be stored as a periodic sequence. The `gate` +action suppresses its ordinary event dispatch for a chosen number of ticks +while local phase continues. When the gate expires, the layer resumes where it +would otherwise have been. -The controller decides which musical layer a tag represents and which layers -to gate. AMY implements only generic event dispatch, duration, and phase. +This action does not silence audio which is already ringing. It controls +future event dispatch and continues to process sequence-control events, so a +controller sequence cannot gate away its own recovery. The caller decides +which tags represent musical layers; AMY implements only generic action, +duration, and phase behavior. -## Fixed repeat counts +## A fixed number of repeats -Component periods define looping. When a phrase should repeat exactly `N` -times, a finite controller sequence can start the periodic phrase at tick zero -and stop it at `N * period`. Control processing precedes ordinary events, so the -event on the stop boundary is not dispatched. +An event with a nonzero period repeats until its execution is stopped. To play +it exactly `N` times, a finite controller sequence can start the periodic +sequence at local tick zero and stop it at `N * period`. -This composes existing concepts instead of adding a separate repeat-mode or -published-length state. +Sequence controls are processed before ordinary events on the same tick, so +the event at the stop boundary is not dispatched. This composes finite and +periodic sequences without adding a separate repeat-counter state. + +## Parameter automation and compound gestures + +Stored events are not limited to notes. A finite sequence can apply filter, +amplitude, pan, effects, patch, or other AMY changes at local ticks. This can +represent a reusable automation curve or a compound control gesture. AMY does +not invent inverse events when such an execution is stopped; the definition +must contain any restoration required by the caller's musical intent. ## Live definition changes -A controller can remove future launches, reset and append the replacement -definition, then install new launches. Executions which started before the -change keep the old snapshot. Future starts use the new contents. +A controller can stop future launches, reset a tag, append a replacement +definition, and start it at a selected alignment. Executions which began before +the change keep their immutable snapshots; later starts use the replacement. -The controller still owns musical policy and transaction ordering, but it does -not own active execution revisions, note lifetime, phrase completion, or the -sequencer clock. +The controller continues to own musical policy and the ordering of the edit. +It does not need to own definition versions, phrase completion, sequence phase, +or note-release bookkeeping. diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md new file mode 100644 index 00000000..3386f375 --- /dev/null +++ b/docs/sequencer-sequences-status.md @@ -0,0 +1,154 @@ +# Reusable sequence status and compatibility + +This document records the implemented interface, the compatibility boundary, +and the validation which still depends on a particular target or downstream +application. It describes the reusable-sequence model in this source tree. + +## Implemented interface + +Python callers normally use named actions: + +```python +amy.send(sequence=40, action='start', alignment_period=48) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +`amy.define_sequence(tag, events)` is the validated replace-as-a-list helper. +The corresponding lower-level fields are `sequence_reset` and +`sequence_control`. JavaScript and Godot bindings expose those lower-level +fields through the generated API. + +The wire protocol uses: + +| Operation | Wire shape | Meaning | +| --- | --- | --- | +| append | `Htick,period,tagZ` | Add an ordinary event to a definition | +| reset | `HRtagZ` | Clear the definition used by future starts | +| stop | `HCtag,0,alignmentZ` | Stop the selected executions | +| start | `HCtag,1,alignmentZ` | Create an execution | +| gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | + +The numeric action is deliberately a three-value action rather than a boolean +or a note velocity. Fractional action values are rejected. + +## Compatibility summary + +| Existing use | Status | Required action | +| --- | --- | --- | +| Untagged `ticks=(tick,)` | Compatible | None | +| Untagged `ticks=(tick, period)` | Compatible | None | +| Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | +| A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | +| C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | +| Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | + +The intentional breaking change is limited to tagged scheduling. A tag now +identifies a stopped, cumulative definition: repeated tagged writes append, +and playback begins only after an explicit start. This replaces two properties +of the earlier tagged-event behavior, where a later write replaced the event +and the tagged event was active immediately. + +## Migrating a replaceable tagged event + +If the tag was only being used as a replace/remove handle, the smallest +migration is to omit it and keep using direct one-off or periodic scheduling. + +If the contents need to remain addressable as a reusable sequence, replace +them explicitly: + +```python +amy.send(sequence=tag, action='stop', alignment_period=period) +amy.define_sequence(tag, events) +amy.send(sequence=tag, action='start', alignment_period=period) +``` + +The low-level wire equivalent is: + +```text +HC,0,Z +HRZ +H,,Z +... +HC,1,Z +``` + +An aligned stop captures the executions which exist when the command is sent. +Replacing the definition changes future starts, while an execution which +already began retains its immutable snapshot. This lets a wrapper migrate +without tracking AMY's current tick, active note state, or definition version. +The wrapper must still choose its musical update boundary: replacing on the +next full period is simple and phase-stable, but may have more latency than an +application-specific mid-cycle update. + +One known first-party consumer of the replace-on-tag behavior is Tulip's +`AMYSequenceEvent` wrapper. Its `update()` and `remove()` operations need the +explicit stop/reset/append/start lifecycle above. That migration is localized, +but its live-edit boundary is a product choice and should be tested together +with the consumers of that wrapper. + +## Other source-compatibility details + +`amy_config_t` appends `max_sequence_events` and +`max_sequence_executions`. Appending preserves the offsets of existing +members, but changing the size of a public C structure is not a binary ABI +promise. Applications should be recompiled against the matching header and +library. As with other AMY configuration, begin with `amy_default_config()` so +new fields receive supported defaults. + +Limits are explicit. `max_sequencer_tags` bounds identities, +`max_sequence_events` bounds one definition, and +`max_sequence_executions` bounds active or alignment-pending executions. +Exhaustion, invalid tags, malformed actions, publication allocation failure, +and cyclic start graphs fail without publishing a partial definition. Callers +which deliberately choose small limits should treat a rejected operation as a +normal bounded-resource failure. + +Resetting a definition does not stop an execution which already holds a +snapshot. `RESET_TIMEBASE` removes active and pending executions while +retaining definitions. `RESET_SEQUENCER` clears direct events, definitions, +and executions. + +## Automated validation + +The host test suite covers: + +- unchanged one- and two-value direct scheduling; +- cumulative definitions, explicit reset, finite and repeating executions; +- overlapping executions and more than two simultaneously retained snapshot + generations; +- same-tick control ordering, alignment, tick rollover, gate phase, and global + reset behavior; +- current-execution capture for aligned stop and gate; +- arbitrary payloads, sequence composition, bounded cycles, and exhausted + execution pools; +- allocation failure at candidate-construction stages and recovery without a + partial publication; +- two competing writers, including checked publication and retry; +- Python validation and exact wire serialization; +- executable JavaScript serialization and generated binding freshness. + +The reusable-sequence C tests run as part of `make ctest`. Python API coverage +is in `tests/test_sequence_api.py`, and generated API checks are included in +`make check-c-api`. + +## Target-dependent validation still required + +The ownership design keeps definition allocation, cloning, string copying, +and destruction off the render path and outside the shared render-lock +critical section. That is a source-level real-time property, not a substitute +for measuring a complete device. + +On an ESP32 target, validate the intended sample rate, block and DMA sizes, +memory capabilities, effects load, and authoring traffic. Record maximum +render time, missed DMA deadlines, publication critical-section time, heap +low-water mark, largest free block, and maximum retired-list depth. At 48 kHz +and 128 samples, the block deadline is approximately 2.67 ms. + +Generated Godot source is checked for freshness and syntax when the parser is +available. An executable Godot runtime behavior test remains target-dependent; +the sequence behavior itself is implemented in the common C core. + +See [Abstractions and implementation](sequencer-sequences-abstractions.md) for +the snapshot publication and deferred-reclamation design. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 92b17179..9f211f46 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,4 +1,4 @@ -# Reusable sequencer sequences +# Reusable sequences A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending more than one event with the same tag accumulates those events, in the same way @@ -48,10 +48,10 @@ suppresses their ordinary events for the required `duration`. `vel` keeps its usual meaning of note velocity. At the lower-level `sequence_control` API and on the wire, actions use integers: stop `0`, start `1`, and gate `2`. Fractional values are invalid. The optional `alignment_period` is the alignment -quantum. `0` or `1` acts at the next -available sequencer tick for a direct command. A larger value selects the next -global tick divisible by that period. When a sequenced parent starts a child, -the child's local tick zero participates in the same tick. +quantum. `0` or `1` acts at the next available sequencer tick for a direct +command. A larger value selects the next global tick divisible by that period. +When a sequenced parent starts a child, the child's local tick zero participates +in the same tick. A start creates a bounded execution. Finite executions of one tag may overlap, so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets @@ -63,7 +63,7 @@ started retain their own event pairs. ## Finite and repeating lifetime -No explicit sequence length or publish action is needed: +Lifetime follows directly from the periods of the stored events: - a definition containing only `period=0` events is finite and retires after its last event; @@ -79,7 +79,7 @@ amy.send(sequence=40, action='gate', duration=24, alignment_period=1) This suppresses ordinary event dispatch from active executions of tag `40` for 24 ticks. Local phase continues, and dispatch resumes on the original phase. -Audio already ringing is not cut off. Nested sequence controls remain active, +Audio already ringing is not cut off. Sequence-control payloads remain active, so a controller sequence can still complete its lifecycle. Duration zero removes a gate at the selected boundary. @@ -100,4 +100,7 @@ inactive definitions are not scanned on each tick. See the [implementation model](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and -[step-by-step examples](sequencer-sequences-howto.md). +[step-by-step examples](sequencer-sequences-howto.md). The +[status and compatibility guide](sequencer-sequences-status.md) records the +intentional tagged-scheduling change, migration path, test coverage, and +target-dependent validation boundary. diff --git a/docs/synth.md b/docs/synth.md index 9b999f27..06a4aa96 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -252,11 +252,13 @@ operation. `sequence_control` starts, stops, aligns, or temporarily gates an active tagged sequence. Component periods define looping; a definition containing only period-zero events finishes after its last event. -See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API +See [Reusable sequences](sequencer-sequences.md) for the concise API and lifecycle reference. The accompanying guides explain the [abstractions and implementation](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and a -[step-by-step Python example](sequencer-sequences-howto.md). +[step-by-step Python example](sequencer-sequences-howto.md). See +[status and compatibility](sequencer-sequences-status.md) when migrating +existing tagged scheduling or configuring a target build. ## Core oscillators diff --git a/docs/upgrading.md b/docs/upgrading.md index a4a16385..ed09f90a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -5,6 +5,10 @@ Here we will post breaking APIs between releases of AMY and tips on porting. ## Reusable sequencer sequences (unreleased) +For the complete compatibility matrix, migration lifecycle, validation status, +and target-dependent checks, see +[Reusable sequence status and compatibility](sequencer-sequences-status.md). + Supplying the same tag in more than one three-value `ticks=` message now accumulates all those events into a stopped reusable sequence. Previously, a later event replaced the earlier event at that tag. This intentional change From 065a2299f51e7210c1dd5e260d7be1472c7ed0e6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:38:07 +0200 Subject: [PATCH 053/112] Separate render and external sequence dispatch --- src/amy.h | 4 ++ src/api.c | 10 +++ src/cv_trigger.c | 2 +- src/parse.c | 20 ++++-- src/sequencer.c | 173 +++++++++++++++++++++++++++++++---------------- src/sequencer.h | 29 +++++++- 6 files changed, 170 insertions(+), 68 deletions(-) diff --git a/src/amy.h b/src/amy.h index 6de9e5fd..a37ae701 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1169,6 +1169,10 @@ uint32_t ms_to_samples(uint32_t ms) ; // API void amy_add_message(char *message); +// Internal render-side ingress, used by CV triggers. It deliberately avoids +// variable-time sequence reclamation and gives sequence controls the current +// render tick rather than pretending they came from an external caller. +void amy_add_message_from_render(char *message); // Parse and play a stored wire message now (a fired sequencer entry). void amy_play_message(char *message); // Like amy_add_message but the data is treated as coming from an external diff --git a/src/api.c b/src/api.c index 60f9dd0f..daa68ee3 100644 --- a/src/api.c +++ b/src/api.c @@ -316,6 +316,16 @@ void amy_add_message(char *message) { amy_add_message_with_sysex_flag(message, /* sysex */ false); } +void amy_add_message_from_render(char *message) { + if (message[0] == 'H') { + handle_ticks_message_with_origin( + message, SEQUENCER_ORIGIN_RENDER, + amy_global.sequencer_tick_count); + } else { + amy_play_message(message); + } +} + // Like amy_add_message but marks the message as coming from an external // sysex source so the transfer routing in amy_message_is_transfer_chunk() // applies. diff --git a/src/cv_trigger.c b/src/cv_trigger.c index 4f9bd6a4..3fd3ae13 100644 --- a/src/cv_trigger.c +++ b/src/cv_trigger.c @@ -116,7 +116,7 @@ void cv_trigger_generate_events(float *cv_inputs) { char message[AMY_WIRE_COMMAND_LEN]; substitute_midi_special_values(message, cv_trig->message_template, 0, 0, note); //fprintf(stderr, "update_external_cv_in: message %s\n", message); - amy_add_message(message); + amy_add_message_from_render(message); } } } else if ((polarity * cv_val) < (polarity * cv_trig->thresh_reset)) { diff --git a/src/parse.c b/src/parse.c index 6f971d03..be11c43c 100644 --- a/src/parse.c +++ b/src/parse.c @@ -728,7 +728,9 @@ static int sequence_control_uint_tail(const char *cursor, uint32_t *values, // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command // is only ever honored as the first command of a message. -void handle_ticks_message(char *message) { +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick) { assert(message[0] == 'H'); if (message[1] == 'A') { fprintf(stderr, @@ -791,8 +793,9 @@ void handle_ticks_message(char *message) { "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { - sequencer_sequence_control((uint32_t)parsed_tag, action, value, - alignment); + sequencer_sequence_control_with_origin( + (uint32_t)parsed_tag, action, value, alignment, origin, + current_tick); } return; } @@ -805,7 +808,7 @@ void handle_ticks_message(char *message) { if ((terminator != '\0' && terminator != 'Z') || count != 1) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else - sequencer_sequence_reset(values[0]); + sequencer_sequence_reset_with_origin(values[0], origin); return; } @@ -821,11 +824,16 @@ void handle_ticks_message(char *message) { memcpy(stripped, payload, payload_len + 1); // A root tag is only "given" if all 3 values were present; fewer // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); + sequencer_add_wire_with_origin( + ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped, origin); } } +void handle_ticks_message(char *message) { + handle_ticks_message_with_origin(message, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + // given a string return a parsed event // // Transfer payloads never reach here: amy_add_message() traps them before diff --git a/src/sequencer.c b/src/sequencer.c index 54206bd0..87bf7e01 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -24,7 +24,7 @@ typedef struct sequence_info_t { } sequence_info_t; struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. -int32_t max_sequences = 0; // Number of user-addressable tags. +uint32_t max_sequences = 0; // Number of user-addressable tags. // Head of the ascending list of occupied anonymous slots; -1 when nothing is // scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag @@ -86,7 +86,6 @@ static stored_sequence_execution_t *sequence_executions = NULL; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; -static volatile bool stored_sequence_wire_firing = false; static stored_sequence_definition_t *retired_sequence_definitions = NULL; #ifdef AMY_SEQUENCE_TESTING @@ -168,9 +167,18 @@ void sequencer_reclaim_retired(void) { stored_sequence_definition_destroy_list(retired); } -static void stored_sequence_reclaim_retired(void) { - if (wire_firing || stored_sequence_wire_firing) return; - sequencer_reclaim_retired(); +static bool sequence_origin_may_reclaim(sequencer_origin_t origin) { + return origin == SEQUENCER_ORIGIN_EXTERNAL; +} + +static stored_sequence_definition_t * +stored_sequence_definition_release_locked( + stored_sequence_definition_t *definition, + sequencer_origin_t origin) { + if (sequence_origin_may_reclaim(origin)) + return stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_retire_locked(definition); + return NULL; } static stored_sequence_definition_t *stored_sequence_definition_new(void) { @@ -239,7 +247,7 @@ static void stored_sequence_executions_reset(void) { static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; - for (int32_t i = 0; i < max_sequences; ++i) { + for (uint32_t i = 0; i < max_sequences; ++i) { stored_sequence_definition_retire_locked( stored_sequences[i]); stored_sequences[i] = NULL; @@ -268,12 +276,11 @@ static void stored_sequences_deinit(void) { static void stored_sequences_init(uint32_t events, uint32_t executions) { max_stored_sequence_events = events; max_stored_sequence_executions = executions; - stored_sequence_wire_firing = false; if (max_sequences == 0 || events == 0 || executions == 0) return; size_t slot_bytes = 0; size_t execution_bytes = 0; - if (!checked_array_size((uint32_t)max_sequences, + if (!checked_array_size(max_sequences, sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), &stored_sequence_event_bytes) @@ -282,7 +289,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { &execution_bytes)) { fprintf(stderr, "stored sequence configuration exceeds addressable memory: " - "tags=%" PRIi32 ", events=%" PRIu32 + "tags=%" PRIu32 ", events=%" PRIu32 ", executions=%" PRIu32 "\n", max_sequences, events, executions); stored_sequences_deinit(); @@ -303,7 +310,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { } } -void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, +void sequencer_init(uint32_t max_sequencer_tags, uint32_t sequence_events, uint32_t sequence_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). @@ -362,7 +369,7 @@ void sequencer_sequence_reset_timebase() { void sequencer_debug() { int32_t n_active = 0; for (int32_t t = first_active; t != -1; t = sequences[t].next_active) ++n_active; - fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); + fprintf(stderr, "sequencer: max_sequences %" PRIu32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 @@ -446,14 +453,16 @@ void sequencer_recompute() { // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. -uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire) { +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin) { if (sequences == NULL) { // sequencer_init hasn't run free(wire); return 0; } if (has_tag) { - if (tag >= (uint32_t)max_sequences) { - fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIi32"\n", + if (tag >= max_sequences) { + fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", tag, tick, period, max_sequences); free(wire); return 0; @@ -467,9 +476,10 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha if (tick == 0 && period == 0 && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { free(wire); - return sequencer_sequence_reset(tag); + return sequencer_sequence_reset_with_origin(tag, origin); } - return sequencer_sequence_add_wire(tag, tick, period, wire); + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, origin); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -520,8 +530,14 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } +uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, + bool has_tag, char *wire) { + return sequencer_add_wire_with_origin( + tick, period, tag, has_tag, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { - if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; + if (stored_sequences == NULL || tag >= max_sequences) return NULL; return &stored_sequences[tag]; } @@ -554,8 +570,9 @@ static void stored_sequence_candidate_discard( stored_sequence_definition_destroy(candidate); } -uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, - uint32_t period, char *wire) { +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -564,7 +581,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, else fprintf(stderr, "cannot append event: sequence tag %" PRIu32 " is outside the configured range [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); free(wire); return 0; } @@ -588,7 +605,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); #ifdef AMY_SEQUENCE_TESTING bool test_pin_hook_called = false; #endif @@ -612,7 +629,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_append_owned(source, tick, period, wire); amy_release_lock(); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); return 1; } @@ -638,7 +656,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_t *dead = NULL; if (source != NULL) { amy_grab_lock(); - dead = stored_sequence_definition_unref_locked(source); + dead = stored_sequence_definition_release_locked(source, + origin); amy_release_lock(); } stored_sequence_definition_destroy(dead); @@ -654,14 +673,17 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_t *dead = NULL; if (source != NULL) { // Drop the old slot ownership and our temporary writer pin. - dead = stored_sequence_definition_unref_locked(source); + dead = stored_sequence_definition_release_locked(source, + origin); stored_sequence_definition_t *after_pin = - stored_sequence_definition_unref_locked(source); + stored_sequence_definition_release_locked(source, + origin); if (after_pin != NULL) dead = after_pin; } amy_release_lock(); stored_sequence_definition_destroy(dead); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); return 1; } @@ -669,14 +691,21 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // source pin, discard the private candidate outside the lock, and retry // against the new cumulative definition. stored_sequence_definition_t *dead = source == NULL ? NULL - : stored_sequence_definition_unref_locked(source); + : stored_sequence_definition_release_locked(source, origin); amy_release_lock(); stored_sequence_candidate_discard(candidate, wire); stored_sequence_definition_destroy(dead); } } -uint8_t sequencer_sequence_reset(uint32_t tag) { +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -685,34 +714,41 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { else fprintf(stderr, "cannot reset sequence: tag %" PRIu32 " is outside the configured range [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); return 0; } - if (stored_sequence_wire_firing) { + if (origin == SEQUENCER_ORIGIN_STORED) { fprintf(stderr, "sequence %" PRIu32 " cannot reset definitions from a stored sequence event\n", tag); return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); amy_grab_lock(); stored_sequence_definition_t *definition = *slot; *slot = NULL; stored_sequence_definition_t *dead = NULL; - if (wire_firing) stored_sequence_definition_retire_locked(definition); - else dead = stored_sequence_definition_unref_locked(definition); + dead = stored_sequence_definition_release_locked(definition, origin); amy_release_lock(); stored_sequence_definition_destroy(dead); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); return 1; } -static uint32_t sequence_control_tick(uint32_t alignment_period) { +uint8_t sequencer_sequence_reset(uint32_t tag) { + return sequencer_sequence_reset_with_origin( + tag, SEQUENCER_ORIGIN_EXTERNAL); +} + +static uint32_t sequence_control_tick(uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick) { // A control fired by the root sequencer participates in this tick. A // control arriving between ticks begins no earlier than the next tick. - uint32_t tick = wire_firing ? amy_global.sequencer_tick_count - : amy_global.sequencer_tick_count + 1; + uint32_t tick = origin == SEQUENCER_ORIGIN_EXTERNAL + ? amy_global.sequencer_tick_count + 1 + : current_tick; if (alignment_period != 0) { uint32_t remainder = tick % alignment_period; if (remainder != 0) tick += alignment_period - remainder; @@ -720,9 +756,10 @@ static uint32_t sequence_control_tick(uint32_t alignment_period) { return tick; } -uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, - uint32_t value, - uint32_t alignment_period) { +uint8_t sequencer_sequence_control_with_origin( + uint32_t tag, uint32_t action, uint32_t value, + uint32_t alignment_period, sequencer_origin_t origin, + uint32_t current_tick) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -731,11 +768,11 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, else fprintf(stderr, "cannot control sequence %" PRIu32 ": valid tags are [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { @@ -743,7 +780,8 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, fprintf(stderr, "cannot start sequence %" PRIu32 ": its definition is empty\n", tag); } else { - uint32_t start_tick = sequence_control_tick(alignment_period); + uint32_t start_tick = sequence_control_tick( + alignment_period, origin, current_tick); stored_sequence_execution_t *available = NULL; for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { stored_sequence_execution_t *execution = &sequence_executions[i]; @@ -765,7 +803,8 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, } } else if (action == SEQUENCE_CONTROL_STOP || action == SEQUENCE_CONTROL_GATE) { - uint32_t control_tick = sequence_control_tick(alignment_period); + uint32_t control_tick = sequence_control_tick( + alignment_period, origin, current_tick); for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { stored_sequence_execution_t *execution = &sequence_executions[i]; if (!execution->occupied || execution->tag != tag) @@ -786,10 +825,17 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, "stop=0, start=1, gate=2\n", tag, action); } amy_release_lock(); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); return result; } +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + return sequencer_sequence_control_with_origin( + tag, action, value, alignment_period, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + static bool stored_sequence_event_hits(const stored_sequence_event_t *event, uint32_t local_tick) { return event->period != 0 ? local_tick % event->period == event->tick @@ -801,16 +847,16 @@ static bool stored_sequence_event_is_control( return strncmp(event->wire, "HC", 2) == 0; } -static void sequence_play_wire_now(char *wire) { - if (wire[0] == 'H') handle_ticks_message(wire); +static void sequence_play_wire_now(char *wire, sequencer_origin_t origin, + uint32_t current_tick) { + if (wire[0] == 'H') + handle_ticks_message_with_origin(wire, origin, current_tick); else amy_play_message(wire); } -static void stored_sequence_play_wire(const char *wire) { - bool previous = stored_sequence_wire_firing; - stored_sequence_wire_firing = true; - sequence_play_wire_now((char *)wire); - stored_sequence_wire_firing = previous; +static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { + sequence_play_wire_now( + (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); } static void stored_sequence_process_pass(uint32_t tick, bool controls) { @@ -852,7 +898,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { &definition->events[event_index]; if (stored_sequence_event_is_control(event) == controls && stored_sequence_event_hits(event, elapsed)) - stored_sequence_play_wire(event->wire); + stored_sequence_play_wire(event->wire, tick); } } @@ -863,7 +909,11 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { } static void sequencer_process_tick(void) { - amy_global.sequencer_tick_count++; + // External sequence controls take their next-tick snapshot under this same + // lock, so current-tick versus next-tick activation has one ordering point. + amy_grab_lock(); + uint32_t tick = ++amy_global.sequencer_tick_count; + amy_release_lock(); midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode // Guard nested check-and-fire calls (via a fired message's own parse) // while still processing this tick's fires; restore on the way out. @@ -879,7 +929,7 @@ static void sequencer_process_tick(void) { bool hit = false; bool delete = false; if(sequences[tag].period != 0) { // period set - uint32_t offset = amy_global.sequencer_tick_count % sequences[tag].period; + uint32_t offset = tick % sequences[tag].period; if (offset == sequences[tag].tick) hit = true; } else { // Test for absolute tick (no period set). <= rather than ==: @@ -890,7 +940,7 @@ static void sequencer_process_tick(void) { // playing. <= lets it fire on the next tick instead, matching // the play-it-late rule sequencer_add_wire() uses for a // one-off that is already due when it arrives. - if (sequences[tag].tick <= amy_global.sequencer_tick_count) { hit = true; delete = true; } + if (sequences[tag].tick <= tick) { hit = true; delete = true; } } if(hit) { // Take the message out (one-shot) or a copy of it (repeating) @@ -915,7 +965,8 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - sequence_play_wire_now(wire); + sequence_play_wire_now( + wire, SEQUENCER_ORIGIN_RENDER, tick); free(wire); } } @@ -924,11 +975,11 @@ static void sequencer_process_tick(void) { } // Nested controls take effect before ordinary stored-sequence events on // the same tick. This lets a parent stop a child without one extra onset. - stored_sequence_process_pass(amy_global.sequencer_tick_count, true); - stored_sequence_process_pass(amy_global.sequencer_tick_count, false); + stored_sequence_process_pass(tick, true); + stored_sequence_process_pass(tick, false); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { - amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); + amy_global.config.amy_external_sequencer_hook(tick); } } @@ -964,7 +1015,9 @@ void sequencer_midi_start() { // If external clock was not previously enabled, keep using internal clock // so the sequencer advances on its own without needing F8 ticks. if (sequencer_external_clock) { + amy_grab_lock(); amy_global.sequencer_tick_count = 0; + amy_release_lock(); } // Reset the tick timer to now so sequencer_check_and_fill doesn't try to // catch up all the ticks that elapsed while stopped. diff --git a/src/sequencer.h b/src/sequencer.h index e837d0c8..3902e13a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,7 +5,7 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences, uint32_t max_sequence_events, +void sequencer_init(uint32_t max_num_sequences, uint32_t max_sequence_events, uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); @@ -16,6 +16,20 @@ void sequencer_check_and_fill(); // called once per block from amy_execute_delt // Destroy zero-reference immutable sequence definitions retired by the render // path. The caller must be a control/non-render thread. void sequencer_reclaim_retired(); + +// Internal dispatch origin. External commands start no earlier than the next +// tick and may reclaim retired definitions. Render-originated commands use the +// supplied current tick and may only retire storage. A stored event is also +// prohibited from editing sequence definitions while they are being walked. +typedef enum sequencer_origin_t { + SEQUENCER_ORIGIN_EXTERNAL = 0, + SEQUENCER_ORIGIN_RENDER, + SEQUENCER_ORIGIN_STORED +} sequencer_origin_t; + +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif @@ -26,19 +40,32 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) for immediate sequencer // playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin); // Append one ordinary ticks event to the reusable sequence identified by tag. // Takes ownership of wire. A tick=period=0 event is a valid one-shot when its // wire payload is nonempty. uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire); +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin); // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin); // sequence_control is [tag, action, alignment_period] for stop/start or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period); +uint8_t sequencer_sequence_control_with_origin(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick); void sequencer_sequence_reset_timebase(); #ifdef AMY_SEQUENCE_TESTING void sequencer_test_fail_allocation_after(int32_t successful_allocations); From 98873a9e8b1f524aaa7405f094dc0ec6676ed8b6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:39:34 +0200 Subject: [PATCH 054/112] Make same-tick sequence controls slot independent --- src/sequencer.c | 119 ++++++++++++++++++++----------- tests/test_sequencer_sequences.c | 22 ++++++ 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 87bf7e01..8c7eff20 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -75,10 +75,12 @@ typedef struct stored_sequence_execution_t { uint32_t gate_change_tick; uint32_t gate_duration; uint32_t gate_end_tick; + uint32_t controls_processed_tick; bool occupied; bool stop_pending; bool gate_change_pending; bool gated; + bool controls_processed; } stored_sequence_execution_t; static stored_sequence_definition_t **stored_sequences = NULL; @@ -859,53 +861,86 @@ static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); } -static void stored_sequence_process_pass(uint32_t tick, bool controls) { - for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { - amy_grab_lock(); - stored_sequence_execution_t *execution = &sequence_executions[i]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { +static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, + bool controls) { + amy_grab_lock(); + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + uint32_t elapsed = tick - execution->start_tick; + stored_sequence_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } + if (controls) { + if (execution->controls_processed + && execution->controls_processed_tick == tick) { amy_release_lock(); - continue; + return false; } - uint32_t elapsed = tick - execution->start_tick; - stored_sequence_definition_t *definition = execution->definition; - if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) - || (!definition->has_periodic_event - && elapsed > definition->last_one_shot_tick)) { - stored_sequence_execution_release_deferred(execution); - amy_release_lock(); - continue; + // Mark before dispatch: a control graph may stop/reuse this slot, and a + // newly created execution in that slot must remain distinguishable. + execution->controls_processed = true; + execution->controls_processed_tick = tick; + } else { + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; } - if (!controls) { - if (execution->gate_change_pending - && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { - execution->gate_change_pending = false; - execution->gated = execution->gate_duration != 0; - execution->gate_end_tick = execution->gate_change_tick - + execution->gate_duration; - } - if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) - execution->gated = false; + if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) + execution->gated = false; + } + bool suppress = !controls && execution->gated; + definition->refs++; + amy_release_lock(); + + if (!suppress) { + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire, tick); } - bool suppress = !controls && execution->gated; - definition->refs++; - amy_release_lock(); + } - if (!suppress) { - for (uint32_t event_index = 0; - event_index < definition->event_count; ++event_index) { - stored_sequence_event_t *event = - &definition->events[event_index]; - if (stored_sequence_event_is_control(event) == controls - && stored_sequence_event_hits(event, elapsed)) - stored_sequence_play_wire(event->wire, tick); + amy_grab_lock(); + stored_sequence_definition_retire_locked(definition); + amy_release_lock(); + return true; +} + +static void stored_sequence_process_controls(uint32_t tick) { + // A control can start an execution in a lower-numbered slot already passed + // by this scan. Repeat until no due execution remains unvisited. At most one + // control visit per configured slot is allowed per tick; this both covers + // every simultaneously active execution and bounds stop/reuse cycles. + uint32_t visits_left = max_stored_sequence_executions; + bool progressed; + do { + progressed = false; + for (uint32_t i = 0; + i < max_stored_sequence_executions && visits_left != 0; ++i) { + if (stored_sequence_process_slot(i, tick, true)) { + visits_left--; + progressed = true; } } + } while (progressed && visits_left != 0); +} - amy_grab_lock(); - stored_sequence_definition_retire_locked(definition); - amy_release_lock(); - } +static void stored_sequence_process_events(uint32_t tick) { + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_process_slot(i, tick, false); } static void sequencer_process_tick(void) { @@ -973,10 +1008,10 @@ static void sequencer_process_tick(void) { } tag = next; } - // Nested controls take effect before ordinary stored-sequence events on + // Composed controls take effect before ordinary stored-sequence events on // the same tick. This lets a parent stop a child without one extra onset. - stored_sequence_process_pass(tick, true); - stored_sequence_process_pass(tick, false); + stored_sequence_process_controls(tick); + stored_sequence_process_events(tick); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(tick); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 6d4eac8d..96fada52 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -353,6 +353,27 @@ static void test_cyclic_controls_are_bounded_and_recoverable(void) { "stopping both cycle tags makes the pool reusable"); } +static void test_same_tick_control_is_slot_order_independent(void) { + printf("same-tick controls are independent of execution slot order\n"); + sequencer_reset(); + clear_marks(); + + // The filler occupies slot 0 for tick 1 only. The parent occupies slot 1 + // from tick 2. At tick 2 slot 0 is retired before slot 1 starts child 3, + // which therefore reuses the already-visited lower slot. Child 3 must still + // run its local-zero control and start leaf 4 on that same tick. + amy_add_message("H0,0,1zPfillerZ"); + amy_add_message("H0,0,2HC3,1,1Z"); + amy_add_message("H0,0,3HC4,1,1Z"); + amy_add_message("H0,0,4zPslot-leafZ"); + amy_add_message("HC1,1,1Z"); + amy_add_message("HC2,1,2Z"); + clock_to(sequencer_ticks() + 4); + + CHECK(marks_named("slot-leaf") == 1, + "a child in a recycled lower slot receives its tick-zero control"); +} + static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); @@ -553,6 +574,7 @@ int main(void) { test_finite_gate_preserves_phase(); test_quantized_stop_targets_current_executions(); test_cyclic_controls_are_bounded_and_recoverable(); + test_same_tick_control_is_slot_order_independent(); test_per_tag_and_global_reset_semantics(); test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); From 1b1e5279104f321e118c90b280a26e62519322ce Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:41:19 +0200 Subject: [PATCH 055/112] Test concurrent sequence render and control --- tests/test_sequencer_concurrency.c | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c index 2fa696ea..6e81a66b 100644 --- a/tests/test_sequencer_concurrency.c +++ b/tests/test_sequencer_concurrency.c @@ -22,6 +22,7 @@ static int writers_at_pin = 0; static int release_writers = 0; static int a_hits = 0; static int b_hits = 0; +static int control_failures = 0; static void after_source_pin(void) { pthread_mutex_lock(&rendezvous_lock); @@ -92,6 +93,49 @@ static void test_losing_writer_retries_cumulatively(void) { "the losing compare/retry path loses and duplicates no event"); } +static void *advance_render_ticks(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) sequencer_midi_clock_tick(); + return NULL; +} + +static void *change_sequence_gate(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) { + if (!sequencer_sequence_control( + 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) + control_failures++; + } + return NULL; +} + +static void test_render_and_control_threads_share_no_sequence_context(void) { + printf("render ticks and external controls keep separate context\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(2, 0, 1, strdup("zPthread-pulseZ")), + "periodic definition exists"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_START, 0, 0), + "periodic execution starts"); + + uint32_t iterations = 2000; + pthread_t render_thread; + pthread_t control_thread; + control_failures = 0; + CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, + &iterations) == 0, + "render thread starts"); + CHECK(pthread_create(&control_thread, NULL, change_sequence_gate, + &iterations) == 0, + "control thread starts"); + pthread_join(render_thread, NULL); + pthread_join(control_thread, NULL); + + CHECK(control_failures == 0, + "all concurrent controls target the active execution"); + CHECK(sequencer_sequence_reset(2), + "external reset is not confused with stored-event dispatch"); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -106,6 +150,7 @@ int main(void) { amy_start(config); test_losing_writer_retries_cumulatively(); + test_render_and_control_threads_share_no_sequence_context(); amy_stop(); if (failures) { From fc3c9e5b61ec42394d73bc8fea27e1dfa461f17b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:42:44 +0200 Subject: [PATCH 056/112] Reject unrepresentable sequence capacities --- src/sequencer.c | 35 ++++++++++++++++---------------- tests/test_sequencer_sequences.c | 19 +++++++++++------ 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 8c7eff20..f96d3395 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -103,18 +103,21 @@ void sequencer_test_set_after_pin_hook(void (*hook)(void)) { } #endif -static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { +static void *stored_sequence_allocate(size_t size, uint32_t caps) { #ifdef AMY_SEQUENCE_TESTING if (stored_sequence_allocations_before_failure == 0) return NULL; if (stored_sequence_allocations_before_failure > 0) stored_sequence_allocations_before_failure--; #endif - return malloc_caps(size, caps); + if (size > UINT32_MAX) return NULL; + return malloc_caps((uint32_t)size, caps); } static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { - if (count > SIZE_MAX / element_size) return false; + if (element_size == 0 || element_size > UINT32_MAX + || count > UINT32_MAX / element_size) + return false; *bytes = (size_t)count * element_size; return true; } @@ -157,10 +160,8 @@ static void stored_sequence_definition_destroy_list( } } -// The public wire boundary calls this unconditionally after parsing. Sequence -// entry points also use it opportunistically, except while a render-fired wire -// is active. Keeping the actual destruction here makes that distinction -// explicit instead of trying to infer the caller from concurrent global state. +// External API boundaries call this after parsing. Render-side dispatch only +// retires definitions; it never enters this variable-time destruction path. void sequencer_reclaim_retired(void) { amy_grab_lock(); stored_sequence_definition_t *retired = retired_sequence_definitions; @@ -297,11 +298,11 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { stored_sequences_deinit(); return; } - stored_sequences = (stored_sequence_definition_t **)malloc_caps( + stored_sequences = (stored_sequence_definition_t **)stored_sequence_allocate( slot_bytes, amy_global.config.ram_caps_synth); if (stored_sequences != NULL) memset(stored_sequences, 0, slot_bytes); - sequence_executions = (stored_sequence_execution_t *)malloc_caps( + sequence_executions = (stored_sequence_execution_t *)stored_sequence_allocate( execution_bytes, amy_global.config.ram_caps_synth); if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); @@ -498,7 +499,7 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, sequences[tag].wire = NULL; sequences[tag].tick = 0; sequences[tag].period = 0; - active_unlink(tag); // out of the list while it has nothing in it + active_unlink((int32_t)tag); // Anonymous slots are bounded to 0..255. if (tick == 0 && period == 0) { // Non-schedulable event: just clear the tag. amy_release_lock(); free(wire); @@ -527,7 +528,7 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, sequences[tag].tick = tick; sequences[tag].period = period; sequences[tag].wire = wire; - active_link(tag); // ...and back in, now that it has a message again + active_link((int32_t)tag); // ...and back in, now that it has a message again amy_release_lock(); return 1; } @@ -582,8 +583,8 @@ uint8_t sequencer_sequence_add_wire_with_origin( ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot append event: sequence tag %" PRIu32 - " is outside the configured range [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); free(wire); return 0; } @@ -715,8 +716,8 @@ uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot reset sequence: tag %" PRIu32 - " is outside the configured range [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); return 0; } if (origin == SEQUENCER_ORIGIN_STORED) { @@ -769,8 +770,8 @@ uint8_t sequencer_sequence_control_with_origin( ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot control sequence %" PRIu32 - ": valid tags are [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + ": valid tags are [0, %" PRIu32 "]\n", + tag, max_sequences - 1); return 0; } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 96fada52..a9224f95 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -529,19 +529,26 @@ static void test_gate_and_stop_cross_clock_rollover(void) { } static void test_disabled_configuration(void) { - printf("zero reusable-sequence capacities disable the feature safely\n"); - const uint32_t capacities[][2] = {{0, 8}, {8, 0}}; + printf("invalid reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][3] = { + {256, 0, 8}, + {256, 8, 0}, + {256, UINT32_MAX, 1}, + {256, 1, UINT32_MAX}, + {UINT32_MAX, 1, 1}, + }; for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { amy_config_t config = amy_default_config(); config.features.startup_bleep = 0; config.audio = AMY_AUDIO_IS_NONE; - config.max_sequence_events = capacities[i][0]; - config.max_sequence_executions = capacities[i][1]; + config.max_sequencer_tags = capacities[i][0]; + config.max_sequence_events = capacities[i][1]; + config.max_sequence_executions = capacities[i][2]; amy_start(config); CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), - "append is disabled for zero capacity set %zu", i + 1); + "append is disabled for invalid capacity set %zu", i + 1); CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), - "control is disabled for zero capacity set %zu", i + 1); + "control is disabled for invalid capacity set %zu", i + 1); amy_stop(); } } From 1fd5e22a5f7273c116486008001bd05a3048fa9d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:45:28 +0200 Subject: [PATCH 057/112] Validate sequence integers without truncation --- amy/__init__.py | 70 ++++++++++++++++++------- src/parse.c | 89 +++++++++++++++++--------------- tests/test_sequence_api.py | 17 ++++++ tests/test_sequencer_sequences.c | 6 ++- 4 files changed, 119 insertions(+), 63 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 106cfe5e..e56255fd 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -249,17 +249,45 @@ def _list_values(value): return [value] +_SEQUENCE_UINT32_MAX = (1 << 32) - 1 + + +def _sequence_uint32(value, name, allow_template=False): + """Return one exact sequence integer without lossy numeric coercion.""" + if allow_template and isinstance(value, str) and value.startswith('%'): + return value + if isinstance(value, bool): + raise ValueError('%s must be a non-negative integer.' % name) + if isinstance(value, int): + result = value + elif isinstance(value, str) and value.strip().isdigit(): + result = int(value.strip()) + else: + raise ValueError('%s must be a non-negative integer.' % name) + if result < 0: + raise ValueError('%s must be non-negative.' % name) + if result > _SEQUENCE_UINT32_MAX: + raise ValueError('%s must be in uint32 range.' % name) + return result + + def _sequence_control_values(value): """Validate the low-level ``HC`` payload without blocking templates.""" values = _list_values(value) if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') + values[0] = _sequence_uint32( + values[0], 'sequence_control tag', allow_template=True) raw_action = values[1] if isinstance(raw_action, str) and raw_action.startswith('%'): # Command templates substitute the token before AMY parses HC. The # resulting wire value must still be the integer 0, 1, or 2. - if len(values) not in (2, 3): - raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') + if not 2 <= len(values) <= 4: + raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') + for index in range(2, len(values)): + values[index] = _sequence_uint32( + values[index], 'templated sequence_control field', + allow_template=True) return values if isinstance(raw_action, int) and not isinstance(raw_action, bool): action = raw_action @@ -275,6 +303,13 @@ def _sequence_control_values(value): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + values[1] = action + field_names = ('sequence_control duration', 'sequence_control alignment_period') \ + if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) + for index, name in enumerate(field_names, start=2): + if index < len(values): + values[index] = _sequence_uint32( + values[index], name, allow_template=True) return values @@ -294,12 +329,9 @@ def _normalize_sequence_action(kwargs): raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') if 'action' not in kwargs: raise ValueError("sequence needs action='start', 'stop', or 'gate'.") - tag = int(kwargs['sequence']) - if tag < 0: - raise ValueError('Sequence tag must be non-negative.') - alignment = int(kwargs.get('alignment_period', 0)) - if alignment < 0: - raise ValueError('Sequence alignment_period must be non-negative.') + tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') + alignment = _sequence_uint32( + kwargs.get('alignment_period', 0), 'Sequence alignment_period') action_name = kwargs['action'] actions = { 'stop': SEQUENCE_CONTROL_STOP, @@ -312,9 +344,8 @@ def _normalize_sequence_action(kwargs): if action == SEQUENCE_CONTROL_GATE: if 'duration' not in kwargs: raise ValueError("Sequence action='gate' needs a duration in ticks.") - duration = int(kwargs['duration']) - if duration < 0: - raise ValueError('Sequence gate duration must be non-negative.') + duration = _sequence_uint32( + kwargs['duration'], 'Sequence gate duration') control = (tag, action, duration, alignment) else: if 'duration' in kwargs: @@ -393,10 +424,14 @@ def message(**kwargs): raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') + if 'sequence_reset' in kwargs: + kwargs['sequence_reset'] = _sequence_uint32( + kwargs['sequence_reset'], 'sequence_reset tag') if 'sequence_control' in kwargs: if set(kwargs) - {'sequence_control', 'ticks'}: raise ValueError('sequence_control can only be combined with ticks.') - _sequence_control_values(kwargs['sequence_control']) + kwargs['sequence_control'] = _sequence_control_values( + kwargs['sequence_control']) # Validity check all the passed args. prioritized_keys = [] @@ -485,10 +520,9 @@ def _sequence_ticks(value): values = [value] if not 1 <= len(values) <= 2: raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') - tick = int(values[0]) - period = int(values[1]) if len(values) == 2 else 0 - if tick < 0 or period < 0: - raise ValueError('Stored sequence tick and period must be non-negative.') + tick = _sequence_uint32(values[0], 'Stored sequence tick') + period = _sequence_uint32(values[1], 'Stored sequence period') \ + if len(values) == 2 else 0 if period and tick >= period: raise ValueError('A stored sequence tick must be below its nonzero period.') return tick, period @@ -503,9 +537,7 @@ def define_sequence(tag, events): per-tag reset followed by explicit cumulative event appends. Executions which already started keep their previous immutable definition. """ - sequence_tag = int(tag) - if sequence_tag < 0: - raise ValueError('Sequence tag must be non-negative.') + sequence_tag = _sequence_uint32(tag, 'Sequence tag') event_messages = [] for event in events: values = dict(event) diff --git a/src/parse.c b/src/parse.c index be11c43c..f806b331 100644 --- a/src/parse.c +++ b/src/parse.c @@ -705,20 +705,29 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static bool sequence_uint32(const char *cursor, const char **end, + uint32_t *value) { + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor)) return false; + errno = 0; + char *parsed_end = NULL; + unsigned long long parsed = strtoull(cursor, &parsed_end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return false; + while (*parsed_end == ' ') ++parsed_end; + *value = (uint32_t)parsed; + *end = parsed_end; + return true; +} + static int sequence_control_uint_tail(const char *cursor, uint32_t *values, int capacity) { int count = 0; while (*cursor == ',') { ++cursor; - while (*cursor == ' ') ++cursor; - if (!isdigit((unsigned char)*cursor) || count == capacity) return -1; - errno = 0; - char *end = NULL; - unsigned long long parsed = strtoull(cursor, &end, 10); - if (errno == ERANGE || parsed > UINT32_MAX) return -1; - while (*end == ' ') ++end; - values[count++] = (uint32_t)parsed; - cursor = end; + if (count == capacity + || !sequence_uint32(cursor, &cursor, &values[count])) + return -1; + count++; } if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; return count; @@ -732,36 +741,23 @@ void handle_ticks_message_with_origin(char *message, sequencer_origin_t origin, uint32_t current_tick) { assert(message[0] == 'H'); - if (message[1] == 'A') { - fprintf(stderr, - "invalid ticks command: HA is not needed; append with " - "Htick,period,tag\n"); - return; - } if (message[1] == 'C') { // HCtag,action[,alignment_period], for stop=0 or start=1. // HCtag,gate,duration[,alignment_period] - const char *tag_start = message + 2; - while (*tag_start == ' ') ++tag_start; - errno = 0; - char *tag_end = NULL; - unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); - while (*tag_end == ' ') ++tag_end; - const char *action_start = *tag_end == ',' ? tag_end + 1 : tag_end; - while (*action_start == ' ') ++action_start; - errno = 0; - char *action_end = NULL; - unsigned long long parsed_action = strtoull(action_start, &action_end, - 10); - bool action_valid = isdigit((unsigned char)*action_start) - && action_end != action_start && errno != ERANGE - && parsed_action <= UINT32_MAX; - const char *tail = action_end; - while (*tail == ' ') ++tail; + const char *tag_end = NULL; + uint32_t tag = 0; + bool tag_valid = sequence_uint32(message + 2, &tag_end, &tag); + const char *action_start = tag_valid && *tag_end == ',' + ? tag_end + 1 : ""; + const char *action_end = NULL; + uint32_t action = 0; + bool action_valid = sequence_uint32( + action_start, &action_end, &action); + const char *tail = action_valid ? action_end : ""; uint32_t rest[2] = {0, 0}; - int rest_count = sequence_control_uint_tail(tail, rest, 2); - if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start - || parsed_tag > UINT32_MAX || *tag_end != ',' + int rest_count = action_valid + ? sequence_control_uint_tail(tail, rest, 2) : -1; + if (!tag_valid || *tag_end != ',' || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " @@ -770,7 +766,6 @@ void handle_ticks_message_with_origin(char *message, return; } - uint32_t action = (uint32_t)parsed_action; uint32_t value = 0; uint32_t alignment = 0; bool shape_valid = false; @@ -794,21 +789,29 @@ void handle_ticks_message_with_origin(char *message, "alignment must be non-negative integers\n"); } else { sequencer_sequence_control_with_origin( - (uint32_t)parsed_tag, action, value, alignment, origin, - current_tick); + tag, action, value, alignment, origin, current_tick); } return; } if (message[1] == 'R') { // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. - uint32_t values[2] = {0, 0}; - int count = parse_list_uint32_t(message + 2, values, 2, 0); - char terminator = message[2 + _next_alpha(message + 2)]; - if ((terminator != '\0' && terminator != 'Z') || count != 1) + const char *end = NULL; + uint32_t tag = 0; + if (!sequence_uint32(message + 2, &end, &tag) + || (*end != '\0' && (*end != 'Z' || end[1] != '\0'))) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else - sequencer_sequence_reset_with_origin(values[0], origin); + sequencer_sequence_reset_with_origin(tag, origin); + return; + } + + const char *tick_start = message + 1; + while (*tick_start == ' ') ++tick_start; + if (!isdigit((unsigned char)*tick_start)) { + fprintf(stderr, + "invalid ticks command: expected Htick[,period[,tag]]payload, " + "HCtag,action, or HRtag\n"); return; } diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 393d8a15..93d8b527 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,6 +22,8 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" + assert amy.message(sequence_control=("%v", "%v", "%v", "%v")) \ + == "HC%v,%v,%v,%vZ" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" @@ -63,6 +65,17 @@ def main(): expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("tag", lambda: amy.message(sequence_control=(1.5, 1))) + expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) + expect_error("uint32", lambda: amy.message( + sequence_control=(2, 2, 1 << 32))) + expect_error("tag", lambda: amy.message(sequence_reset=1.5)) + expect_error("tag", lambda: amy.message(sequence=True, action="start")) + expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) + expect_error("duration", lambda: amy.message( + sequence=2, action="gate", duration=1.5)) + expect_error("alignment", lambda: amy.message( + sequence=2, action="start", alignment_period=1.5)) expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, action="start", synth=1)) @@ -78,6 +91,10 @@ def main(): expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) + expect_error("tick", lambda: amy.define_sequence( + 2, [{"ticks": (1.5,), "osc": 1}])) + expect_error("period", lambda: amy.define_sequence( + 2, [{"ticks": (1, 1 << 32), "osc": 1}])) if __name__ == "__main__": diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index a9224f95..8c8d7dc5 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -458,8 +458,12 @@ static void test_wire_control_shape_is_strict(void) { "a missing gate duration and trailing payload are rejected"); amy_add_message("HR3,4Z"); + amy_add_message("HR4294967296Z"); + amy_add_message("HR3.0Z"); + amy_add_message("HR-1Z"); + amy_add_message("HA3Z"); CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), - "a reset with an extra field leaves the definition intact"); + "malformed and overflowing resets leave the definition intact"); uint32_t start = sequencer_ticks() + 1; clock_to(start); CHECK(mark_at("defined", start), "the intact definition still starts"); From 04db76371bfc0ee9c8cf069d51098eba5045af6d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:50:05 +0200 Subject: [PATCH 058/112] Define strict wrap-safe sequence timing --- amy/__init__.py | 35 ++++++++++++++++--- docs/sequencer-sequences-abstractions.md | 12 +++++++ docs/sequencer-sequences-howto.md | 5 +++ docs/sequencer-sequences-musical-use-cases.md | 6 ++++ docs/sequencer-sequences-status.md | 5 ++- docs/sequencer-sequences.md | 6 ++++ src/parse.c | 33 +++++++++++------ src/sequencer.c | 34 +++++++++++++++++- tests/test_sequence_api.py | 7 ++++ tests/test_sequencer_sequences.c | 35 ++++++++++++++++++- 10 files changed, 161 insertions(+), 17 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index e56255fd..aac39dda 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -250,6 +250,7 @@ def _list_values(value): _SEQUENCE_UINT32_MAX = (1 << 32) - 1 +_SEQUENCE_MAX_INTERVAL = (1 << 31) - 1 def _sequence_uint32(value, name, allow_template=False): @@ -271,6 +272,30 @@ def _sequence_uint32(value, name, allow_template=False): return result +def _sequence_interval(value, name, allow_template=False): + result = _sequence_uint32(value, name, allow_template=allow_template) + if isinstance(result, str): + return result + if result > _SEQUENCE_MAX_INTERVAL: + raise ValueError('%s must not exceed 2147483647 ticks.' % name) + return result + + +def _message_ticks(value): + values = _list_values(value) + if not 1 <= len(values) <= 3: + raise ValueError('ticks needs tick, optional period, and optional tag.') + names = ('ticks tick', 'ticks period', 'ticks tag') + normalized = [ + _sequence_uint32(item, names[index]) + for index, item in enumerate(values) + ] + if (len(normalized) >= 2 and normalized[1] + and normalized[0] >= normalized[1]): + raise ValueError('ticks tick must be below its nonzero period.') + return normalized + + def _sequence_control_values(value): """Validate the low-level ``HC`` payload without blocking templates.""" values = _list_values(value) @@ -285,7 +310,7 @@ def _sequence_control_values(value): if not 2 <= len(values) <= 4: raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') for index in range(2, len(values)): - values[index] = _sequence_uint32( + values[index] = _sequence_interval( values[index], 'templated sequence_control field', allow_template=True) return values @@ -308,7 +333,7 @@ def _sequence_control_values(value): if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) for index, name in enumerate(field_names, start=2): if index < len(values): - values[index] = _sequence_uint32( + values[index] = _sequence_interval( values[index], name, allow_template=True) return values @@ -330,7 +355,7 @@ def _normalize_sequence_action(kwargs): if 'action' not in kwargs: raise ValueError("sequence needs action='start', 'stop', or 'gate'.") tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') - alignment = _sequence_uint32( + alignment = _sequence_interval( kwargs.get('alignment_period', 0), 'Sequence alignment_period') action_name = kwargs['action'] actions = { @@ -344,7 +369,7 @@ def _normalize_sequence_action(kwargs): if action == SEQUENCE_CONTROL_GATE: if 'duration' not in kwargs: raise ValueError("Sequence action='gate' needs a duration in ticks.") - duration = _sequence_uint32( + duration = _sequence_interval( kwargs['duration'], 'Sequence gate duration') control = (tag, action, duration, alignment) else: @@ -400,6 +425,8 @@ def message(**kwargs): # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS kwargs = _normalize_sequence_action(kwargs) + if kwargs.get('ticks') is not None: + kwargs['ticks'] = _message_ticks(kwargs['ticks']) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index b5422873..dc812fe8 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -33,6 +33,18 @@ Sequence-control events continue to run while gated, allowing a finite controller sequence to restore or change another sequence without being blocked by its own gate. +Suppression is deliberately event-agnostic: an ordinary event which falls in +the gated interval is skipped and is not replayed later. This includes +note-offs and parameter-restoration events. A definition which requires such +an event for cleanup should keep it outside the gated interval or put the +complete gesture in a separately started finite sequence. + +Gate duration and control alignment are limited to 2,147,483,647 ticks. This +keeps every pending boundary within the unambiguous half-range of AMY's +wrapping 32-bit tick comparisons. Once an execution has reached its start it +is latched as started, so an indefinitely running periodic sequence continues +across subsequent clock wraparounds. + ### Composition A stored payload may be an ordinary AMY event or a control for another diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index c3931b7a..348a6f07 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -137,6 +137,11 @@ After 48 ticks, ordinary event dispatch resumes on the original phase. Audio which was already ringing is not cut off. A zero-duration gate removes the current gate at the selected boundary: +Gate skips every ordinary event in the interval rather than postponing it. In +particular, a note-off or parameter reset inside the interval will not run +later. Keep state-restoring events outside the gate or package a complete +note-on/note-off gesture in its own finite sequence. + ```python amy.send( sequence=50, diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 06ad3cc9..84a6fe78 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -46,6 +46,12 @@ controller sequence cannot gate away its own recovery. The caller decides which tags represent musical layers; AMY implements only generic action, duration, and phase behavior. +Ordinary events inside the interval are skipped, not delayed. For material +with a required note-off or parameter restoration, the author must place that +cleanup outside the gate or express the complete gesture as a separate finite +sequence. This keeps gate semantics independent of any particular instrument +or application. + ## A fixed number of repeats An event with a nonzero period repeats until its execution is stopped. To play diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 3386f375..89d70425 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -30,7 +30,10 @@ The wire protocol uses: | gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | The numeric action is deliberately a three-value action rather than a boolean -or a note velocity. Fractional action values are rejected. +or a note velocity. Fractional values are rejected for every sequence tag, +tick, period, duration and alignment field. Tags, ticks and periods use uint32; +duration and alignment are capped at 2,147,483,647 ticks for wrap-safe pending +boundaries. ## Compatibility summary diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 9f211f46..843eb398 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -83,6 +83,12 @@ Audio already ringing is not cut off. Sequence-control payloads remain active, so a controller sequence can still complete its lifecycle. Duration zero removes a gate at the selected boundary. +Gated ordinary events are skipped and are not replayed. That rule also applies +to note-offs and parameter-restoration events. Keep required cleanup outside +the interval or in a separately started finite gesture. Duration and alignment +must not exceed 2,147,483,647 ticks so their boundaries remain unambiguous +across the wrapping 32-bit tick clock. + ## Reset behavior - `amy.send(sequence_reset=tag)` removes the future definition. Active diff --git a/src/parse.c b/src/parse.c index f806b331..56587c57 100644 --- a/src/parse.c +++ b/src/parse.c @@ -733,6 +733,21 @@ static int sequence_control_uint_tail(const char *cursor, uint32_t *values, return count; } +static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], + const char **payload) { + int count = 0; + while (count < 3) { + if (!sequence_uint32(cursor, &cursor, &values[count])) return -1; + count++; + if (*cursor != ',') break; + if (count == 3) return -1; + cursor++; + } + if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; + *payload = cursor; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command @@ -806,21 +821,19 @@ void handle_ticks_message_with_origin(char *message, return; } - const char *tick_start = message + 1; - while (*tick_start == ' ') ++tick_start; - if (!isdigit((unsigned char)*tick_start)) { + uint32_t ticks[3] = {0, 0, 0}; + const char *payload = NULL; + int num_vals = sequence_ticks_prefix(message + 1, ticks, &payload); + if (num_vals < 1) { fprintf(stderr, "invalid ticks command: expected Htick[,period[,tag]]payload, " "HCtag,action, or HRtag\n"); return; } - - uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); - uint16_t schedule_len = 1 + _next_alpha(message + 1); - char *payload = message + schedule_len; - uint16_t payload_len = (uint16_t)strlen(payload); - char *stripped = (char *)malloc_caps(payload_len + 1, amy_global.config.ram_caps_events); + size_t payload_len = strlen(payload); + char *stripped = payload_len >= UINT32_MAX ? NULL + : (char *)malloc_caps((uint32_t)(payload_len + 1), + amy_global.config.ram_caps_events); if (stripped == NULL) { amy_oom("ticks_message"); } else { diff --git a/src/sequencer.c b/src/sequencer.c index f96d3395..7a2d5d7b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -77,6 +77,7 @@ typedef struct stored_sequence_execution_t { uint32_t gate_end_tick; uint32_t controls_processed_tick; bool occupied; + bool started; bool stop_pending; bool gate_change_pending; bool gated; @@ -463,6 +464,12 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, free(wire); return 0; } + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot schedule event: tick %" PRIu32 + " must be below period %" PRIu32 "\n", tick, period); + free(wire); + return 0; + } if (has_tag) { if (tag >= max_sequences) { fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", @@ -774,6 +781,18 @@ uint8_t sequencer_sequence_control_with_origin( tag, max_sequences - 1); return 0; } + if (alignment_period > INT32_MAX) { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": alignment %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, alignment_period, INT32_MAX); + return 0; + } + if (action == SEQUENCE_CONTROL_GATE && value > INT32_MAX) { + fprintf(stderr, "cannot gate sequence %" PRIu32 + ": duration %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, value, INT32_MAX); + return 0; + } if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); uint8_t result = 0; @@ -866,10 +885,17 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, bool controls) { amy_grab_lock(); stored_sequence_execution_t *execution = &sequence_executions[slot]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + if (!execution->occupied) { amy_release_lock(); return false; } + if (!execution->started) { + if (!AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + execution->started = true; + } uint32_t elapsed = tick - execution->start_tick; stored_sequence_definition_t *definition = execution->definition; if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) @@ -914,8 +940,14 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, } } + bool finite_complete = !controls && !definition->has_periodic_event + && elapsed == definition->last_one_shot_tick; amy_grab_lock(); stored_sequence_definition_retire_locked(definition); + if (finite_complete && execution->occupied + && execution->definition == definition + && execution->start_tick == tick - elapsed) + stored_sequence_execution_release_deferred(execution); amy_release_lock(); return true; } diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 93d8b527..bc9eaf6c 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -55,6 +55,9 @@ def main(): ] expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4), osc=1)) + expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) expect_error("only be combined", lambda: amy.message( @@ -69,6 +72,8 @@ def main(): expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) expect_error("uint32", lambda: amy.message( sequence_control=(2, 2, 1 << 32))) + expect_error("2147483647", lambda: amy.message( + sequence_control=(2, 2, 1 << 31))) expect_error("tag", lambda: amy.message(sequence_reset=1.5)) expect_error("tag", lambda: amy.message(sequence=True, action="start")) expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) @@ -76,6 +81,8 @@ def main(): sequence=2, action="gate", duration=1.5)) expect_error("alignment", lambda: amy.message( sequence=2, action="start", alignment_period=1.5)) + expect_error("2147483647", lambda: amy.message( + sequence=2, action="start", alignment_period=1 << 31)) expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, action="start", synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 8c8d7dc5..5fbffcc3 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -309,6 +309,23 @@ static void test_finite_gate_preserves_phase(void) { "event resumes on the original phase after gate expiry"); } +static void test_gate_drops_state_restoration_without_replay(void) { + printf("gate suppression is event-agnostic and does not replay events\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPstate-onZ"); + amy_add_message("H2,0,5zPstate-offZ"); + amy_add_message("HC5,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("state-on", start), "event before gate is dispatched"); + CHECK(sequencer_sequence_control(5, SEQUENCE_CONTROL_GATE, 3, 1), + "gate covers the later state-restoring event"); + clock_to(start + 6); + CHECK(!marks_named("state-off"), + "suppressed state restoration is neither dispatched nor replayed"); +} + static void test_quantized_stop_targets_current_executions(void) { printf("quantized controls capture the current execution set\n"); sequencer_reset(); @@ -438,6 +455,12 @@ static void test_bounds_and_validation(void) { "one execution beyond configured capacity is rejected"); CHECK(!sequencer_sequence_control(3, 99, 0, 0), "unknown control action is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, (uint32_t)INT32_MAX + 1U), + "alignment beyond the wrap-safe interval is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_GATE, (uint32_t)INT32_MAX + 1U, 0), + "gate duration beyond the wrap-safe interval is rejected"); } static void test_wire_control_shape_is_strict(void) { @@ -462,11 +485,20 @@ static void test_wire_control_shape_is_strict(void) { amy_add_message("HR3.0Z"); amy_add_message("HR-1Z"); amy_add_message("HA3Z"); + amy_add_message("H4294967296,0,3zPoverflow-tickZ"); + amy_add_message("H0,4294967296,3zPoverflow-periodZ"); + amy_add_message("H0,0,4294967296zPoverflow-tagZ"); + amy_add_message("H0.5,0,3zPfractional-tickZ"); CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), "malformed and overflowing resets leave the definition intact"); uint32_t start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("defined", start), "the intact definition still starts"); + CHECK(mark_at("defined", start) + && !marks_named("overflow-tick") + && !marks_named("overflow-period") + && !marks_named("overflow-tag") + && !marks_named("fractional-tick"), + "the intact definition starts without malformed additions"); sequencer_reset(); clear_marks(); @@ -583,6 +615,7 @@ int main(void) { test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); test_finite_gate_preserves_phase(); + test_gate_drops_state_restoration_without_replay(); test_quantized_stop_targets_current_executions(); test_cyclic_controls_are_bounded_and_recoverable(); test_same_tick_control_is_slot_order_independent(); From 10d5976939e32cdc4671a977b09d67d8515c9c80 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:50:46 +0200 Subject: [PATCH 059/112] Clarify sequence checks and terminology --- .github/workflows/c-cpp.yml | 2 +- Makefile | 1 - docs/sequencer-sequences-status.md | 2 +- src/sequencer.c | 2 +- tests/test_sequencer_sequences.c | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..5322b243 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -111,7 +111,7 @@ jobs: python-version: '3.13' - name: Check generated C API bindings are in sync - run: make check-c-api + run: make check-c-api js-api-test godot-build: # Build the Godot GDExtension for Linux. amy_midi.c is excluded from the diff --git a/Makefile b/Makefile index dbac073c..45703259 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,6 @@ check-c-api: $(PYTHON) scripts/gen_amy_js_api.py --check $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check - node tests/test_js_api.js js-api-test: node tests/test_js_api.js diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 89d70425..763ac6fe 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -134,7 +134,7 @@ The host test suite covers: The reusable-sequence C tests run as part of `make ctest`. Python API coverage is in `tests/test_sequence_api.py`, and generated API checks are included in -`make check-c-api`. +`make check-c-api` and `make js-api-test`. ## Target-dependent validation still required diff --git a/src/sequencer.c b/src/sequencer.c index 7a2d5d7b..7f7474bb 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -603,7 +603,7 @@ uint8_t sequencer_sequence_add_wire_with_origin( } if (wire[0] == 'H' && wire[1] != 'C') { fprintf(stderr, "cannot append event to sequence %" PRIu32 - ": only H sequence-control payloads may be nested\n", tag); + ": only HC sequence-control payloads may be composed\n", tag); free(wire); return 0; } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 5fbffcc3..78959b20 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -438,7 +438,7 @@ static void test_bounds_and_validation(void) { CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), "empty payload is rejected"); CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), - "stored sequences cannot edit definitions recursively"); + "stored sequences cannot contain sequence authoring commands"); for (uint32_t i = 0; i < 8; ++i) { char *payload = strdup("zPfullZ"); From 83051296c415fc205de38865d8937b8198915a35 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:54:24 +0200 Subject: [PATCH 060/112] Test sequence lifetime across the uint32 clock --- tests/test_sequencer_sequences.c | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 78959b20..94405c51 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -564,6 +564,41 @@ static void test_gate_and_stop_cross_clock_rollover(void) { "stop suppresses the event on its aligned boundary"); } +static void test_execution_lifetime_beyond_half_clock_range(void) { + printf("started executions remain valid across the uint32 clock\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,2zPlong-periodicZ"); + amy_add_message("HC2,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start + (uint32_t)INT32_MAX; + sequencer_midi_clock_tick(); + CHECK(marks_named("long-periodic") == 2, + "a latched periodic execution keeps running past half-range"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H4294967295,0,3zPuint32-tailZ"); + amy_add_message("HC3,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start - 2; + sequencer_midi_clock_tick(); + CHECK(marks_named("uint32-tail") == 1, + "a finite event at UINT32_MAX fires exactly once"); + int starts = 0; + for (int i = 0; i < 8; ++i) + starts += sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, 1); + CHECK(starts == 8, + "the UINT32_MAX finite execution retires on its final event"); +} + static void test_disabled_configuration(void) { printf("invalid reusable-sequence capacities disable the feature safely\n"); const uint32_t capacities[][3] = { @@ -623,6 +658,7 @@ int main(void) { test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); test_gate_and_stop_cross_clock_rollover(); + test_execution_lifetime_beyond_half_clock_range(); test_bounds_and_validation(); test_wire_control_shape_is_strict(); From ec94860c97055ff33869353a0515d35e8788e9f0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:56:50 +0200 Subject: [PATCH 061/112] Preserve legacy omitted tick fields --- amy/__init__.py | 23 +++++++++++++++++------ src/parse.c | 18 +++++++++++++++++- src/sequencer.c | 6 ------ tests/test_sequence_api.py | 5 ++++- tests/test_sequencer_sequences.c | 24 +++++++++++++++++++----- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index aac39dda..7347dba2 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -286,12 +286,23 @@ def _message_ticks(value): if not 1 <= len(values) <= 3: raise ValueError('ticks needs tick, optional period, and optional tag.') names = ('ticks tick', 'ticks period', 'ticks tag') - normalized = [ - _sequence_uint32(item, names[index]) - for index, item in enumerate(values) - ] - if (len(normalized) >= 2 and normalized[1] - and normalized[0] >= normalized[1]): + normalized = [] + numeric = [] + for index, item in enumerate(values): + # Empty list fields have always meant zero on the AMY wire. Preserve + # that spelling as well as the meaning; the tutorial and existing + # callers use ticks=",period,tag" for a tick-zero event. + if item is None or (isinstance(item, str) and not item.strip()): + normalized.append(item) + numeric.append(0) + else: + parsed = _sequence_uint32(item, names[index]) + normalized.append(parsed) + numeric.append(parsed) + # tick < period is a reusable-sequence invariant. Legacy untagged two- + # field scheduling retains its historical wire behavior. + if (len(numeric) == 3 and numeric[1] + and numeric[0] >= numeric[1]): raise ValueError('ticks tick must be below its nonzero period.') return normalized diff --git a/src/parse.c b/src/parse.c index 56587c57..b06cb4ff 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,11 +737,27 @@ static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], const char **payload) { int count = 0; while (count < 3) { - if (!sequence_uint32(cursor, &cursor, &values[count])) return -1; + const char *field = cursor; + while (*field == ' ') ++field; + if (*field == ',') { + // The generic AMY list syntax uses an empty field for zero. Keep + // accepting H,period,tag and H,,tag legacy spellings. + values[count] = 0; + cursor = field; + } else if (!sequence_uint32(cursor, &cursor, &values[count])) { + return -1; + } count++; if (*cursor != ',') break; if (count == 3) return -1; cursor++; + const char *next = cursor; + while (*next == ' ') ++next; + // A trailing comma did not add another value in the legacy parser. + if (*next == '\0' || isalpha((unsigned char)*next)) { + cursor = next; + break; + } } if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; *payload = cursor; diff --git a/src/sequencer.c b/src/sequencer.c index 7f7474bb..20d9083f 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -464,12 +464,6 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, free(wire); return 0; } - if (period != 0 && tick >= period) { - fprintf(stderr, "cannot schedule event: tick %" PRIu32 - " must be below period %" PRIu32 "\n", tick, period); - free(wire); - return 0; - } if (has_tag) { if (tag >= max_sequences) { fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index bc9eaf6c..2b6f6ae3 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -37,6 +37,9 @@ def main(): assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" + assert amy.message(ticks=",24,2", osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(None, 24, 2), osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(4, 4), osc=1) == "H4,4v1Z" sent = [] old_override = amy.override_send @@ -56,7 +59,7 @@ def main(): expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) - expect_error("period", lambda: amy.message(ticks=(4, 4), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4, 2), osc=1)) expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 94405c51..732f80f4 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -62,16 +62,30 @@ static void test_untagged_ticks_and_cumulative_tags(void) { clear_marks(); uint32_t first = next_boundary(sequencer_ticks(), 4); - amy_add_message("H0,4zProotZ"); + amy_add_message("H,4zProotZ"); clock_to(first + 4); - CHECK(mark_at("root", first), "periodic root event fires at global modulo"); + CHECK(mark_at("root", first), + "an omitted tick remains a tick-zero legacy list field"); CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); sequencer_reset(); + CHECK(sequencer_add_wire(4, 4, 0, false, strdup("zPlegacy-periodZ")), + "untagged tick equal to period retains legacy acceptance"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H,4,8zPomitted-local-zeroZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC8,1,4Z"); + clock_to(start); + CHECK(mark_at("omitted-local-zero", start), + "H,period,tag remains a reusable tick-zero event"); + sequencer_reset(); + clear_marks(); amy_add_message("H0,0,9zPfirstZ"); amy_add_message("H2,0,9zPsecondZ"); - uint32_t start = next_boundary(sequencer_ticks(), 4); + start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC9,1,4Z"); clock_to(start + 2); CHECK(mark_at("first", start) && mark_at("second", start + 2), @@ -112,9 +126,9 @@ static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { sequencer_reset(); clear_marks(); amy_add_message("H0,0,10zPstoredZ"); - amy_add_message("H0,0,10Z"); + amy_add_message("H,,10Z"); CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), - "an empty H0,0,tag resets that tag"); + "the legacy empty H,,tag spelling resets that tag"); amy_add_message("H0,0,10zPstoredZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); From f8df2b2ceffaac557de4a91c3b6ba94046de9399 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:01:43 +0200 Subject: [PATCH 062/112] Align sequence controls correctly across clock wrap --- src/sequencer.c | 13 +++++++++++-- tests/test_sequencer_sequences.c | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 20d9083f..8948bee7 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -755,7 +755,13 @@ static uint32_t sequence_control_tick(uint32_t alignment_period, : current_tick; if (alignment_period != 0) { uint32_t remainder = tick % alignment_period; - if (remainder != 0) tick += alignment_period - remainder; + if (remainder != 0) { + uint32_t delta = alignment_period - remainder; + // The visible uint32 clock restarts at zero on rollover, and zero + // is an alignment boundary for every period. Do not carry a + // pre-rollover modulo phase into the wrapped clock. + tick = delta > UINT32_MAX - tick ? 0 : tick + delta; + } } return tick; } @@ -1019,7 +1025,10 @@ static void sequencer_process_tick(void) { active_unlink(tag); } else { size_t len = strlen(sequences[tag].wire); - wire = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + wire = len >= UINT32_MAX ? NULL + : (char *)malloc_caps( + (uint32_t)(len + 1), + amy_global.config.ram_caps_events); if (wire != NULL) memcpy(wire, sequences[tag].wire, len + 1); else amy_oom("sequencer fire"); } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 732f80f4..012f1032 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -546,9 +546,10 @@ static void test_start_crosses_clock_rollover(void) { amy_add_message("H0,0,2zPwrap-zeroZ"); amy_add_message("H2,0,2zPwrap-twoZ"); amy_global.sequencer_tick_count = UINT32_MAX - 2; - amy_add_message("HC2,1,4Z"); + amy_add_message("HC2,1,48Z"); clock_to(2); - CHECK(mark_at("wrap-zero", 0), "aligned local zero fires after rollover"); + CHECK(mark_at("wrap-zero", 0), + "non-power-of-two alignment treats wrapped tick zero as a boundary"); CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); } From 83e883d401b44cd6fc27025f6b106e04791d6aae Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:04:21 +0200 Subject: [PATCH 063/112] Cover sequence allocation failure boundaries --- docs/sequencer-sequences-status.md | 18 +++++++++++---- src/sequencer.c | 2 +- tests/test_sequencer_oom.c | 37 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 763ac6fe..c1a1c3d7 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -104,9 +104,17 @@ Limits are explicit. `max_sequencer_tags` bounds identities, `max_sequence_events` bounds one definition, and `max_sequence_executions` bounds active or alignment-pending executions. Exhaustion, invalid tags, malformed actions, publication allocation failure, -and cyclic start graphs fail without publishing a partial definition. Callers -which deliberately choose small limits should treat a rejected operation as a -normal bounded-resource failure. +and cyclic start graphs reject the affected operation without corrupting the +previously published generation. Callers which deliberately choose small +limits should treat a rejected operation as a normal bounded-resource failure. + +A multi-message upload is not a wire-level transaction. `define_sequence()` +validates every Python event before sending its reset, but a target-side +capacity or transport failure during the subsequent messages can leave the +successfully accepted prefix as the new definition. A protocol which needs +acknowledged all-or-nothing remote upload must add that acknowledgement above +AMY's one-way wire command stream; after a detected failure, reset the tag +before retrying. Resetting a definition does not stop an execution which already holds a snapshot. `RESET_TIMEBASE` removes active and pending executions while @@ -126,8 +134,8 @@ The host test suite covers: - current-execution capture for aligned stop and gate; - arbitrary payloads, sequence composition, bounded cycles, and exhausted execution pools; -- allocation failure at candidate-construction stages and recovery without a - partial publication; +- allocation failure during pool initialization, new-definition creation and + candidate cloning, with recovery and no partial single-event publication; - two competing writers, including checked publication and retry; - Python validation and exact wire serialization; - executable JavaScript serialization and generated binding freshness. diff --git a/src/sequencer.c b/src/sequencer.c index 8948bee7..198e6e08 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -308,7 +308,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); if (stored_sequences == NULL || sequence_executions == NULL) { - amy_oom("stored sequences"); + amy_oom("stored sequences: out of memory\n"); stored_sequences_deinit(); return; } diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c index 0fed5c32..ab32a993 100644 --- a/tests/test_sequencer_oom.c +++ b/tests/test_sequencer_oom.c @@ -35,6 +35,41 @@ static void define_base(void) { "base tail is defined"); } +static void test_initialization_allocation_failures(amy_config_t config) { + printf("partial sequence-pool initialization fails closed\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_test_fail_allocation_after(fail_after); + amy_start(config); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "pool allocation failure %" PRIi32 " disables definitions", + fail_after); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "pool allocation failure %" PRIi32 " disables executions", + fail_after); + amy_stop(); + } +} + +static void test_new_definition_allocation_failures(void) { + printf("new-definition allocation failure leaves an empty tag\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_reset(); + sequencer_test_fail_allocation_after(fail_after); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "definition allocation failure %" PRIi32 " rejects the append", + fail_after); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "definition allocation failure %" PRIi32 + " publishes no empty candidate", fail_after); + } +} + static void test_clone_allocation_failures_preserve_source(void) { printf("every clone allocation failure preserves the published definition\n"); // Clone allocation order: definition, event array, then two wire strings. @@ -75,8 +110,10 @@ int main(void) { config.max_sequencer_tags = 4; config.max_sequence_events = 8; config.max_sequence_executions = 8; + test_initialization_allocation_failures(config); amy_start(config); + test_new_definition_allocation_failures(); test_clone_allocation_failures_preserve_source(); amy_stop(); From e69f537caab4c457ec6bdae9a17305c30bdcacc9 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:05:31 +0200 Subject: [PATCH 064/112] Stress sequence publication during rendering --- tests/test_sequencer_concurrency.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c index 6e81a66b..45313155 100644 --- a/tests/test_sequencer_concurrency.c +++ b/tests/test_sequencer_concurrency.c @@ -23,6 +23,7 @@ static int release_writers = 0; static int a_hits = 0; static int b_hits = 0; static int control_failures = 0; +static int edit_failures = 0; static void after_source_pin(void) { pthread_mutex_lock(&rendezvous_lock); @@ -99,12 +100,19 @@ static void *advance_render_ticks(void *opaque) { return NULL; } -static void *change_sequence_gate(void *opaque) { +static void *change_sequence_gate_and_definition(void *opaque) { uint32_t count = *(uint32_t *)opaque; for (uint32_t i = 0; i < count; ++i) { if (!sequencer_sequence_control( 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) control_failures++; + // Resetting the future definition must not disturb the immutable + // snapshot currently read by the render thread. Rebuild it each time + // so publication and reclamation race with real tick processing. + if (!sequencer_sequence_reset(2) + || !sequencer_sequence_add_wire( + 2, 0, 1, strdup("zPthread-pulseZ"))) + edit_failures++; } return NULL; } @@ -121,10 +129,12 @@ static void test_render_and_control_threads_share_no_sequence_context(void) { pthread_t render_thread; pthread_t control_thread; control_failures = 0; + edit_failures = 0; CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, &iterations) == 0, "render thread starts"); - CHECK(pthread_create(&control_thread, NULL, change_sequence_gate, + CHECK(pthread_create(&control_thread, NULL, + change_sequence_gate_and_definition, &iterations) == 0, "control thread starts"); pthread_join(render_thread, NULL); @@ -132,6 +142,8 @@ static void test_render_and_control_threads_share_no_sequence_context(void) { CHECK(control_failures == 0, "all concurrent controls target the active execution"); + CHECK(edit_failures == 0, + "concurrent future-definition replacement remains available"); CHECK(sequencer_sequence_reset(2), "external reset is not confused with stored-event dispatch"); } From ab2a02ec351ca4328069955abf674bc459f7262e Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:09:38 +0200 Subject: [PATCH 065/112] Clarify sequence compatibility boundaries --- docs/sequencer-sequences-status.md | 2 ++ docs/synth.md | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index c1a1c3d7..39bd5eb1 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -41,8 +41,10 @@ boundaries. | --- | --- | --- | | Untagged `ticks=(tick,)` | Compatible | None | | Untagged `ticks=(tick, period)` | Compatible | None | +| Empty zero fields such as `ticks=",period,tag"` | Compatible | None | | Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | | A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| C `amy_event.ticks` with `TICKS_TAG` set | Changed like any tagged event | Build the definition, then issue an explicit start | | Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | | C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | | Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | diff --git a/docs/synth.md b/docs/synth.md index 06a4aa96..fbf646ab 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -221,7 +221,10 @@ AMY starts a musical sequencer that works on `ticks` from startup. You can reset Ticks run at 48 PPQ at the set tempo. The tempo defaults to 108 BPM. This means there are 108 quarter notes a minute, and `48 * 108 = 5184` ticks a minute, 86 ticks a second. The tempo can be changed with `amy.send(tempo=120)`. -You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All three values are optional past `tick`: +You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. +`period` and `tag` are optional. As in other AMY list fields, an empty numeric +field means zero, so `ticks=",24,7"` is the compact spelling for a tick-zero +event with period 24 and tag 7: ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now From 397488b33ae928877c71de964d903ad615bd1648 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 10:14:41 +0200 Subject: [PATCH 066/112] Define M_PI portably for MSVC --- src/pcm.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pcm.c b/src/pcm.c index 552085b7..c1468904 100644 --- a/src/pcm.c +++ b/src/pcm.c @@ -3,6 +3,10 @@ #include "amy.h" #include "transfer.h" +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + #ifdef __EMSCRIPTEN__ #include "emscripten.h" #endif From 3872b4be16af4f486c8f3259d44478ee7174864f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 10:37:25 +0200 Subject: [PATCH 067/112] Document Windows M_PI portability fix --- README.md | 2 + docs/godot.md | 4 ++ docs/sequencer-sequences-status.md | 5 ++ docs/windows-m-pi-portability.md | 74 ++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 docs/windows-m-pi-portability.md diff --git a/README.md b/README.md index e0dfa8df..4a7a7d1c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) + * [**Windows `M_PI` portability note**](docs/windows-m-pi-portability.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) AMY supports @@ -178,6 +179,7 @@ It's good to understand what wire messages are but you don't need to construct t * [**AMY in Arduino Getting Started**](docs/arduino.md) * [**AMY in Godot**](docs/godot.md) * [**AMY on Windows**](windows/README.md) + * [**Windows `M_PI` portability note**](docs/windows-m-pi-portability.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) [![shore pine sound systems discord](https://raw.githubusercontent.com/shorepine/tulipcc/main/docs/pics/shorepine100.png) **Chat about AMY on our Discord!**](https://discord.gg/TzBFkUb8pG) diff --git a/docs/godot.md b/docs/godot.md index 3918c4f7..ba55fff2 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -35,6 +35,10 @@ git clone --branch godot-4.4-stable https://github.com/godotengine/godot-cpp.git The script builds the native GDExtension library and copies everything into `your_project/addons/amy/`. +See the [Windows `M_PI` portability note](windows-m-pi-portability.md) for the +MSVC build failure introduced by the PCM time-stretch Hann window, why older +Windows builds were unaffected, and how the guarded fallback was validated. + If you want to point to a `godot-cpp` checkout in a different location: ```bash diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 39bd5eb1..7abee1b4 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -165,3 +165,8 @@ the sequence behavior itself is implemented in the common C core. See [Abstractions and implementation](sequencer-sequences-abstractions.md) for the snapshot publication and deferred-reclamation design. + +The independently discovered MSVC build failure and its portable correction +are documented in [Windows portability of the PCM Hann-window +constant](windows-m-pi-portability.md). That correction does not change any +reusable-sequence behavior. diff --git a/docs/windows-m-pi-portability.md b/docs/windows-m-pi-portability.md new file mode 100644 index 00000000..3dfa184e --- /dev/null +++ b/docs/windows-m-pi-portability.md @@ -0,0 +1,74 @@ +# Windows portability of the PCM Hann-window constant + +## Symptom + +The native Godot addon build on Windows failed while compiling `src/pcm.c` +with MSVC: + +```text +error C2065: 'M_PI': undeclared identifier +``` + +Linux and macOS builds of the same source succeeded. + +## Cause + +ISO C does not require `` to define `M_PI`. Many Unix toolchains +expose it as an extension, whereas MSVC exposes it only under additional +preprocessor conditions. + +AMY commit `73b6fece` added a Hann-window calculation for PCM time stretching: + +```c +cosf(2.0f * (float)M_PI * (float)i / (float)PCM_STRETCH_GRAIN) +``` + +Older Windows builds succeeded because their source predated that sampler +change. In particular, the last successful upstream three-platform Godot run, +[32322968524](https://github.com/shorepine/amy/actions/runs/32322968524), +used head `fa14fa2e`, which did not contain commit `73b6fece`. Current-main run +[33349266667](https://github.com/shorepine/amy/actions/runs/33349266667) +used head `0fb0a00b`: its Linux and macOS jobs passed, but its Windows job +failed at the new `M_PI` expression. + +The failure is therefore independent of the reusable-sequence implementation. +It became visible while that work was being validated on all three Godot +desktop targets. + +## Portable correction + +`src/pcm.c` now supplies the conventional constant only when the toolchain has +not already supplied it: + +```c +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif +``` + +The guard has no effect on platforms that already define `M_PI`. On MSVC it +provides the missing compile-time value; the existing calculation explicitly +casts it to `float`. The Hann window is initialized once by `pcm_init()`, so +the correction adds no render-path work and does not change the algorithm. + +Defining `_USE_MATH_DEFINES` instead would make the common source depend on +MSVC-specific include ordering. Computing pi through a trigonometric function +would add unnecessary runtime work. The guarded constant is the smallest +portable correction for the existing expression. + +The correction is isolated in commit `397488b3` so it remains reviewable and +revertible independently of sequencer behavior. + +## Validation + +With the guarded fallback, fork run +[33954151514](https://github.com/linuxificator/amy/actions/runs/33954151514) +successfully built both Windows Godot debug and release libraries and uploaded +the resulting artifact. Linux and macOS Godot debug and release builds had +already passed from the same AMY source before the fallback was applied; their +preprocessors already supplied `M_PI`, so the guarded definition is inactive +there. + +This is a build-portability correction. It does not alter the reusable- +sequence API, wire format, timing, publication model, or compatibility rules. + From 30ca2d6e754692c3fc0cdb6a94c03dbdd7350a14 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 17:59:11 +0200 Subject: [PATCH 068/112] Document downstream Android packaging lessons --- android/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/android/README.md b/android/README.md index e1ef05be..63c50788 100644 --- a/android/README.md +++ b/android/README.md @@ -177,6 +177,58 @@ AAR/NDK/Oboe build and emulator end-to-end test. The emulator arms its own one-shot audio-capture marker before starting the client; the hello-world application itself remains transport-only. +## Downstream PySide6 package findings + +The service AAR from this release was also packaged and released in the +downstream [LB Omnichord Android application][lb-android-package]. That client +is useful as a framework-integration reference, but its Qt and Python packaging +workarounds are not part of AMY's portable service contract. + +The successful package used Python 3.11 and the official +`pyside6-android-deploy` command with matching PySide6 and shiboken6 6.11.2 +Android wheels. The command generated the Qt deployment files and +`buildozer.spec`; the downstream build then added this AAR and its Oboe Prefab +dependency to the generated Gradle package. Qt's command uses +Buildozer/python-for-android as host-side packaging tools. Kivy is not an +application or runtime dependency and is not included in the APK. + +Those tools were reproducible only as one pinned set: Android SDK 36, NDK +27.2.12479018, python-for-android commit +`3762c88c56e3443efb8eba2a02a2604b680240fd`, and Cython 0.29.36. The build also +had to expose the modern SDK manager at Buildozer 1.5's expected legacy path +and add python-for-android's local `libs` directory to Gradle repositories so +the AAR supplied with `--add-aar` could be resolved. The package regression +checks the requested AAR and wheel ABIs, verifies that the APK contains the +AMY/Oboe and matching CPython/shiboken libraries, and rejects an accidental +in-process `c_amy` or `libamy.so` frontend binding. + +On Android the Qt client discovers the application-private files directory +with `QStandardPaths` and appends `amy.sock`; it does not hard-code an Android +user or `/data/user/...` path. The frontend and unexported `:amy` service then +remain separate processes under the same application UID. + +python-for-android extracts its private Python/Qt payload on first launch. In +an emulator that extraction can consume a measured audio window, and an +occasional Qt/JNI startup race can terminate that first process. The downstream +test therefore retries only an unmeasured extraction warm-up, force-stops the +whole package, and keeps the subsequent measured UI/audio launch single-shot. +This avoids hiding failures in the behavior under test. + +A Linux-hosted emulator may print a host PulseAudio (`pa`) warning even though +the Android application never uses PulseAudio. The downstream gate separately +requires the guest service to report Oboe/AAudio, captures the signed-16-bit +samples rendered by AMY and handed to Oboe, and requires them to match exactly. +It also checks non-silence and clipping independently. Its `-26 dBFS` floor is +specific to LB Omnichord's deliberate `V1` master limit (20 dB below AMY's raw +`V10` unity setting) plus 6 dB of patch/phase headroom; it is not a general AMY +test threshold. + +[LB Omnichord release R20260830T153747][lb-release] passed that packaged +PySide6 test with 384000 stereo frames at 48 kHz, no clipping, and zero sample +mismatches between AMY and Oboe. Its arm64 APK is CI debug-signed for sideload +and emulator testing, not for a store or stable update channel. Physical +touchscreen, speaker, route-change and latency validation remains outstanding. + ## Hardware-test items The first device tests should measure: @@ -187,3 +239,6 @@ The first device tests should measure: 4. suspend/resume and audio-device changes; 5. whether executing rare heavy AMY commands at a block boundary needs further separation from the realtime callback. + +[lb-android-package]: https://github.com/linuxificator/LB_Omnichord/blob/f8724328b2e679533c7f3b97cee939e009b7eba7/amysynth_version/qt_frontend/packaging/android/README.md +[lb-release]: https://github.com/linuxificator/LB_Omnichord/releases/tag/R20260830T153747 From c58f1511435bdad8787295cf38967fbaef260489 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 20:26:11 +0200 Subject: [PATCH 069/112] Add selectable CPython PCM bank build --- docs/lb_omnichord_release_contract.md | 85 +++++++++++++++++++++++++++ setup.py | 14 ++++- tests/test_pcm_bank_build_contract.py | 27 +++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/lb_omnichord_release_contract.md create mode 100644 tests/test_pcm_bank_build_contract.py diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md new file mode 100644 index 00000000..6b1efd22 --- /dev/null +++ b/docs/lb_omnichord_release_contract.md @@ -0,0 +1,85 @@ +# LB Omnichord release contract + +LB Omnichord consumes AMY only from a fork branch named +`releases/amy_omnichord_RT`. A consumer must pin both that +branch name and its exact commit SHA. The branch is useful context for people; +the SHA is the immutable, reproducible build input and must be recorded in the +LB Omnichord GitHub release notes. + +The fork's `main` branch is a fast-forward mirror of `shorepine/amy` `main`. +Feature work is never merged into it. Before creating a new Omnichord release, +fetch shorepine, fast-forward the fork's `main` when necessary, and incorporate +those upstream changes into the new release branch. + +The first Omnichord release branch combines the tested LB integrations. Every +later Omnichord release branch starts from the preceding release branch, then +adds the verified upstream and integration changes for that release. Release +branches also contain the internal `work/codex_info` handoff material; upstream +offer branches must remain free of that internal material. + +LB Omnichord CI must verify that a clone checked out by SHA resolves to the +declared SHA and that the declared release branch contains it. A release must +not be published unless its platform packages and regression gates all use the +declared AMY release input. + +This release line also provides the integration-only `AMY_PCM_BANK` build +selector used by LB packaging. `AMY_PCM_BANK=tiny` omits Gamma9001; both bank +choices force a fresh extension rebuild because their output filename is the +same. The default remains AMY's Gamma9001 CPython build. +This selector is wrapper/build policy and is deliberately absent from the +clean `upstream/nested_sequencer` proposal. + +## This release + +The initial experimental release combined: + +- shorepine `main` at `81cddfa8610c570a3a255a17ef5dfd81892849bb`; +- the private Unix socket API and Android AMY/Oboe service from + `integration/amy_android`; +- an experimental LB Omnichord bus-mixer implementation; and +- the internal `work/codex_info` handoff. + +The Android frontend remains a wire-protocol client. Platform integration is a +startup preamble only: it discovers the app-private socket path and connects to +the separately running AMY service. AMY audio output is Oboe/AAudio, not +PulseAudio. + +The bus-mixer experiment was subsequently abandoned. It is deliberately absent +from the current release line and from all upstream proposals. Omnichord rhythm +fills must use the nested sequencer's event gating and must not depend on a +private audio-routing extension. + +## Nested-sequencer integration workflow + +The nested-sequencer work follows two deliberately separate histories: + +1. `upstream/nested_sequencer` starts directly at Shorepine `main` + `81cddfa8610c570a3a255a17ef5dfd81892849bb`. It contains only the reusable + AMY engine/API, tests, and public documentation. It does not contain release + integrations or internal handoff material. +2. `releases/amy_omnichord_R20260830T191146` starts at the exact tip of the + preceding release, `8c74a1681fa6a3b430ddee9390294bccb8f55a86`, so it keeps + the already-tested socket, Android/Oboe, and release-contract changes. The + abandoned bus-mixer merge is explicitly reversed, and the two + nested-sequencer commits are cherry-picked from the upstream branch. +3. LB Omnichord must pin the resulting release-branch SHA, author its rhythms + as stored patterns, use loop mode for the base rhythm and one-shot mode for + fills, and pass all existing platform and release tests. Each logical + percussion role is a tagged loop instance. A fill stores generic `zQM` + events for the role instances it suppresses; deciding which musical roles + continue is exclusively LB Omnichord policy and is not AMY engine code. +4. Only after the complete Omnichord behavior is verified may the clean + `upstream/nested_sequencer` branch be proposed to Shorepine. The release +branch itself is never the source of that pull request. + +Live fill schedules use AMY's root `zQA` trigger scheduler. Replacing or +clearing those ordinary root tags changes only future fill launches: a +one-shot which is already active retains its immutable definition and finishes. +This keeps the frontend wire-only and avoids a host timer which tries to follow +AMY's musical clock. + +This ordering makes LB Omnichord the integration proof without leaking its +platform-specific code into the reusable upstream proposal. If integration +finds a generic AMY defect, fix and test it first on `upstream/nested_sequencer`, +then cherry-pick that additional commit into the current release branch and +update LB Omnichord's exact SHA pin. diff --git a/setup.py b/setup.py index d9ea96ac..50687b11 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ from distutils.core import setup, Extension from setuptools import find_packages +from setuptools.command.build_ext import build_ext import glob import os import subprocess @@ -20,13 +21,21 @@ # the web build: generate build/drums_bin.c from sounds/gamma9001/ and link it. gamma_manifest = os.path.join('sounds', 'gamma9001', 'manifest.json') gamma_drums_bin_c = os.path.join('build', 'drums_bin.c') -if os.path.exists(gamma_manifest): +use_gamma9001 = os.environ.get('AMY_PCM_BANK', 'gamma9001').lower() != 'tiny' +if use_gamma9001 and os.path.exists(gamma_manifest): if not os.path.exists(gamma_drums_bin_c) or \ os.path.getmtime(gamma_drums_bin_c) < os.path.getmtime(gamma_manifest): subprocess.check_call([sys.executable, '-m', 'amy.headers', 'gamma9001']) sources.append(gamma_drums_bin_c) comp_args.append("-DGAMMA9001") +class AmyBuildExt(build_ext): + def finalize_options(self): + super().finalize_options() + # The output filename is identical for both banks. Always rebuild so a + # preceding build of the other variant can never be reused silently. + self.force = True + if os.uname()[0] == 'Darwin': frameworks = ['CoreAudio', 'AudioToolbox', 'AudioUnit', 'CoreFoundation', 'CoreMIDI', 'Cocoa'] sources += ['src/macos_midi.m'] @@ -43,4 +52,5 @@ setup(name = "amy", packages=find_packages(include=['amy']), - ext_modules=[extension_mod]) + ext_modules=[extension_mod], + cmdclass={'build_ext': AmyBuildExt}) diff --git a/tests/test_pcm_bank_build_contract.py b/tests/test_pcm_bank_build_contract.py new file mode 100644 index 00000000..9dfb66a5 --- /dev/null +++ b/tests/test_pcm_bank_build_contract.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Static guard for LB's release-only CPython PCM-bank selector.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SETUP = (ROOT / "setup.py").read_text(encoding="utf-8") + + +def main() -> None: + required = ( + "AMY_PCM_BANK", + "use_gamma9001", + "comp_args.append(\"-DGAMMA9001\")", + "class AmyBuildExt(build_ext):", + "self.force = True", + "cmdclass={'build_ext': AmyBuildExt}", + ) + missing = [value for value in required if value not in SETUP] + if missing: + raise AssertionError(f"missing PCM-bank build contract: {missing}") + print("PCM-bank build contract OK: tiny is selectable, Gamma9001 stays default") + + +if __name__ == "__main__": + main() From e082aa13fb4718082f479a13eee623a54b177775 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 08:34:01 +0200 Subject: [PATCH 070/112] Support deterministic offline live configuration --- Makefile | 1 + README.md | 5 + docs/lb_omnichord_release_contract.md | 170 +++++++++++++------------- src/pyamy.c | 12 +- tests/test_python_offline_live.py | 49 ++++++++ 5 files changed, 150 insertions(+), 87 deletions(-) create mode 100644 tests/test_python_offline_live.py diff --git a/Makefile b/Makefile index 45703259..9745ffea 100644 --- a/Makefile +++ b/Makefile @@ -168,6 +168,7 @@ amy-module: amy-example test: amy-module ${PYTHON} tests/test_sequence_api.py ${PYTHON} -m amy.test + ${PYTHON} tests/test_python_offline_live.py qtest: amy-module ${PYTHON} -m amy.test quiet diff --git a/README.md b/README.md index 37d4c065..486599e6 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ In Python: >>> # play MIDI notes using system MIDI ``` +`amy.live(audio=False, ...)` applies the same runtime configuration without +starting a system-audio callback. This is intended for deterministic offline +rendering with `c_amy.render_to_list()`; omitting `audio` retains the existing +live-audio behavior. + In C: ```c diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index 6b1efd22..9c6b5a5e 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -1,85 +1,85 @@ -# LB Omnichord release contract - -LB Omnichord consumes AMY only from a fork branch named -`releases/amy_omnichord_RT`. A consumer must pin both that -branch name and its exact commit SHA. The branch is useful context for people; -the SHA is the immutable, reproducible build input and must be recorded in the -LB Omnichord GitHub release notes. - -The fork's `main` branch is a fast-forward mirror of `shorepine/amy` `main`. -Feature work is never merged into it. Before creating a new Omnichord release, -fetch shorepine, fast-forward the fork's `main` when necessary, and incorporate -those upstream changes into the new release branch. - -The first Omnichord release branch combines the tested LB integrations. Every -later Omnichord release branch starts from the preceding release branch, then -adds the verified upstream and integration changes for that release. Release -branches also contain the internal `work/codex_info` handoff material; upstream -offer branches must remain free of that internal material. - -LB Omnichord CI must verify that a clone checked out by SHA resolves to the -declared SHA and that the declared release branch contains it. A release must -not be published unless its platform packages and regression gates all use the -declared AMY release input. - -This release line also provides the integration-only `AMY_PCM_BANK` build -selector used by LB packaging. `AMY_PCM_BANK=tiny` omits Gamma9001; both bank -choices force a fresh extension rebuild because their output filename is the -same. The default remains AMY's Gamma9001 CPython build. -This selector is wrapper/build policy and is deliberately absent from the -clean `upstream/nested_sequencer` proposal. - -## This release - -The initial experimental release combined: - -- shorepine `main` at `81cddfa8610c570a3a255a17ef5dfd81892849bb`; -- the private Unix socket API and Android AMY/Oboe service from - `integration/amy_android`; -- an experimental LB Omnichord bus-mixer implementation; and -- the internal `work/codex_info` handoff. - -The Android frontend remains a wire-protocol client. Platform integration is a -startup preamble only: it discovers the app-private socket path and connects to -the separately running AMY service. AMY audio output is Oboe/AAudio, not -PulseAudio. - -The bus-mixer experiment was subsequently abandoned. It is deliberately absent -from the current release line and from all upstream proposals. Omnichord rhythm -fills must use the nested sequencer's event gating and must not depend on a -private audio-routing extension. - -## Nested-sequencer integration workflow - -The nested-sequencer work follows two deliberately separate histories: - -1. `upstream/nested_sequencer` starts directly at Shorepine `main` - `81cddfa8610c570a3a255a17ef5dfd81892849bb`. It contains only the reusable - AMY engine/API, tests, and public documentation. It does not contain release - integrations or internal handoff material. -2. `releases/amy_omnichord_R20260830T191146` starts at the exact tip of the - preceding release, `8c74a1681fa6a3b430ddee9390294bccb8f55a86`, so it keeps - the already-tested socket, Android/Oboe, and release-contract changes. The - abandoned bus-mixer merge is explicitly reversed, and the two - nested-sequencer commits are cherry-picked from the upstream branch. -3. LB Omnichord must pin the resulting release-branch SHA, author its rhythms - as stored patterns, use loop mode for the base rhythm and one-shot mode for - fills, and pass all existing platform and release tests. Each logical - percussion role is a tagged loop instance. A fill stores generic `zQM` - events for the role instances it suppresses; deciding which musical roles - continue is exclusively LB Omnichord policy and is not AMY engine code. -4. Only after the complete Omnichord behavior is verified may the clean - `upstream/nested_sequencer` branch be proposed to Shorepine. The release -branch itself is never the source of that pull request. - -Live fill schedules use AMY's root `zQA` trigger scheduler. Replacing or -clearing those ordinary root tags changes only future fill launches: a -one-shot which is already active retains its immutable definition and finishes. -This keeps the frontend wire-only and avoids a host timer which tries to follow -AMY's musical clock. - -This ordering makes LB Omnichord the integration proof without leaking its -platform-specific code into the reusable upstream proposal. If integration -finds a generic AMY defect, fix and test it first on `upstream/nested_sequencer`, -then cherry-pick that additional commit into the current release branch and -update LB Omnichord's exact SHA pin. +# LB Omnichord AMY release contract + +LB Omnichord consumes AMY from a fork release branch named +`releases/amy_omnichord_RT`. The consumer records both the +branch and exact commit SHA. The branch explains provenance; the SHA is the +immutable build input used by every platform package. + +The fork's `main` remains a fast-forward mirror of `shorepine/amy` `main`. +Generic changes are developed on a clean upstream-directed branch. A release +branch layers the tested platform and application profile on that clean work; +it is never itself offered upstream. + +## Current line + +`releases/amy_omnichord_R20260903T201525` starts with: + +- Shorepine main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`; +- the generic sequencer-group work from `rework/sequencer`; +- the private Unix-socket service and Android Oboe integration; +- the Gamma9001 hosted drum bank profile; +- deterministic offline CPython startup for tests; and +- the larger bounded sequencer-group capacity required by the rhythm + catalogue. + +The abandoned bus-mixer experiment is not part of this line. AMY's generic +bus support remains whatever is present in Shorepine main; no private mixer +module or routing policy is restored. + +## Sequencer boundary + +The clean `rework/sequencer` branch contains only generic AMY behavior: + +- grouped events use `ticks=tick,period,event_tag,group_tag`; +- one `sequence_control` family publishes, starts, stops, gates and clears; +- active executions retain immutable published revisions; +- one, N and infinite repeats share the same repeat-count model; +- quantization uses AMY's own sequencer clock; and +- a root event may launch a group, while a group cannot launch another group. + +LB Omnichord owns all musical policy: which rhythm roles become groups, which +ones a fill gates, which arpeggios may overlap, group/tag allocation and root +arrangement schedules. The frontend remains a wire-protocol client and never +imports or calls AMY engine internals. + +The release profile uses 1,024 group slots, 64 local event tags per group and +32 active or pending executions. The high group count stores the complete fill +catalogue; it does not create 1,024 players. Event tables are allocated lazily +only for definitions that are actually authored. + +## Platform boundary + +On Android, the Qt frontend and the unexported `:amy` service are separate +processes under the same application UID. The frontend discovers the +application-private socket path and sends only AMY wire messages. Audio is +rendered by the service and handed to Oboe/AAudio. The service is built at 48 +kHz with 128-frame stereo blocks. + +Desktop Linux and macOS use the same frontend wire protocol over a private +Unix socket. Windows may use its wrapper/named-pipe transport, but the AMY +message stream and frontend logic stay platform-independent. + +The CPython `AMY_PCM_BANK` build selector is release/build policy rather than +generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the default +for this release line is Gamma9001. Both choices force a fresh extension build +because they share an output filename. + +`amy.live(audio=AMY_AUDIO_IS_NONE, ...)` is the deterministic test mode. The +default remains live miniaudio, preserving existing callers. Offline mode +prevents a system-audio callback and a deterministic renderer from consuming +the same AMY stream concurrently. + +## Release procedure + +1. Verify fork main exactly matches the chosen Shorepine main. +2. Test generic work on the clean upstream-directed branch. +3. Create the release branch and add only required fork integrations. +4. Run native AMY, wire/socket, PCM-bank, offline and Android contract tests. +5. Pin the final release branch and SHA in LB Omnichord configuration and + packaging inputs. +6. Run LB Omnichord's generic and platform-specific suites against that same + SHA. +7. Record the exact AMY SHA in release notes and keep diagnostic commits. + +ESP32 validation is deliberately deferred for this rework; it must be +completed before claiming ESP32 support for the resulting release. diff --git a/src/pyamy.c b/src/pyamy.c index d7b69fef..d7152369 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -21,7 +21,12 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) long lv = 0; long long llv = 0; - if (strcmp(key, "chorus") == 0) { + if (strcmp(key, "audio") == 0) { + int enabled = PyObject_IsTrue(value); + if (enabled < 0) return -1; + cfg->audio = enabled ? AMY_AUDIO_IS_MINIAUDIO : AMY_AUDIO_IS_NONE; + return 0; + } else if (strcmp(key, "chorus") == 0) { lv = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; cfg->features.chorus = (lv != 0); @@ -171,6 +176,10 @@ static PyObject * live_wrapper(PyObject *self, PyObject *args, PyObject *kwargs) // running AMY: a rejected kwarg then leaves audio playing instead of // silently killing it (and leaving AMY stopped for the next live() call). amy_config_t amy_config = amy_default_config(); + // live() has always meant system audio by default. Callers which render + // deterministically with render_to_list() can opt out of the independent + // miniaudio callback while retaining every runtime sizing kwarg. + amy_config.audio = AMY_AUDIO_IS_MINIAUDIO; Py_ssize_t pos = 0; PyObject *key_obj = NULL; PyObject *value_obj = NULL; @@ -188,7 +197,6 @@ static PyObject * live_wrapper(PyObject *self, PyObject *args, PyObject *kwargs) } } - amy_config.audio = AMY_AUDIO_IS_MINIAUDIO; amy_stop(); amy_start(amy_config); // initializes amy Py_RETURN_NONE; diff --git a/tests/test_python_offline_live.py b/tests/test_python_offline_live.py new file mode 100644 index 00000000..63576a00 --- /dev/null +++ b/tests/test_python_offline_live.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Smoke-test configured CPython rendering without a competing audio thread.""" + +from __future__ import annotations + +import time + +import amy +import c_amy + + +def main() -> int: + c_amy.live( + audio=False, + default_synths=0, + max_patterns=1024, + max_pattern_tags=64, + max_pattern_instances=32, + ) + + before = amy.ticks_ms() + time.sleep(0.05) + after_sleep = amy.ticks_ms() + if after_sleep != before: + raise AssertionError( + "audio=False advanced AMY without an explicit render: " + f"{before} -> {after_sleep}" + ) + + amy.send(osc=0, wave=amy.SINE, freq=440, vel=1) + peak = 0 + for _ in range(8): + block = c_amy.render_to_list() + peak = max(peak, max((abs(int(sample)) for sample in block), default=0)) + if peak <= 0: + raise AssertionError("offline render produced no audio") + if amy.ticks_ms() <= after_sleep: + raise AssertionError("explicit offline renders did not advance AMY time") + + # A high pattern id proves that audio=False retained live()'s configurable + # engine sizing instead of falling back to the import-time defaults. + amy.pattern_begin(1000, 4) + amy.pattern_event_wire(1000, 0, "v0l0Z", period=4, tag=0) + amy.pattern_commit(1000) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 553af87aa9e0a879920588697a722f5dbd94cea7 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 00:18:12 +0200 Subject: [PATCH 071/112] Build Gamma9001 Omnichord service profile --- amy/headers.py | 56 +++++++++++++++---- android/README.md | 6 +- .../amy-service/src/main/cpp/CMakeLists.txt | 24 ++++++++ docs/lb_omnichord_release_contract.md | 6 ++ tests/test_android_service_contract.py | 11 +++- 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/amy/headers.py b/amy/headers.py index 64103892..188883a0 100644 --- a/amy/headers.py +++ b/amy/headers.py @@ -235,6 +235,40 @@ def _write_int16_carray(p, data, column=15): p.write(" %s,\n" % (",".join([("%d" % (d)).ljust(8) for d in data[-rem:]]))) +def generate_gamma9001_blob_c(c_path, sounds_dir='sounds/gamma9001', + pcm_AMY_SAMPLE_RATE=22050): + """Write only the linkable Gamma9001 PCM blob for native host builds. + + Unlike ``generate_gamma9001_headers()``, this entry point does not rewrite + tracked headers. A CMake target can therefore generate one private source + file per ABI/build directory without two concurrent Android builds racing + over files in the source checkout. + """ + import json + manifest = json.load(open(os.path.join(sounds_dir, 'manifest.json'))) + bin_entries = [m for m in manifest if m['bank'] != GAMMA9001_ROM_BANK] + entries = [ + (m, _read_wav_mono16( + os.path.join(sounds_dir, m['file']), pcm_AMY_SAMPLE_RATE)) + for m in bin_entries + ] + frames = sum(len(data) for _, data in entries) + parent = os.path.dirname(c_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(c_path, 'w') as p: + p.write("// Automatically generated by amy.headers.generate_gamma9001_blob_c()\n") + p.write("// The Gamma9001 drums.bin blob as C data; see src/pcm_gamma9001.h for the map.\n") + p.write("#include \n") + p.write("const int16_t gamma9001_pcm_data[%d] = {\n" % frames) + for m, data in entries: + p.write(" // %s: %s\n" % (m['bank'], m['name'])) + _write_int16_carray(p, data) + p.write("};\n") + print("gamma9001: %d samples (%.2f MB) -> %s" % ( + len(entries), frames * 2 / 1e6, c_path)) + + def generate_gamma9001_headers(sounds_dir='sounds/gamma9001', bin_path='build/drums.bin', pcm_AMY_SAMPLE_RATE=22050): import json @@ -338,19 +372,10 @@ def generate_gamma9001_headers(sounds_dir='sounds/gamma9001', bin_path='build/dr p.close() # drums.bin as a linkable C array, for targets that bake the banks into the - # binary (the wasm build). ESP32-S3 flashes drums.bin as a partition instead. + # binary (web, CPython and native host builds). ESP32-S3 flashes drums.bin + # as a partition instead. c_path = os.path.join(os.path.dirname(bin_path), 'drums_bin.c') - p = open(c_path, 'w') - p.write("// Automatically generated by amy.headers.generate_gamma9001_headers()\n") - p.write("// The Gamma9001 drums.bin blob as C data; see src/pcm_gamma9001.h for the map.\n") - p.write("#include \n") - p.write("const int16_t gamma9001_pcm_data[%d] = {\n" % offset) - for m in bin_entries: - data = _read_wav_mono16(os.path.join(sounds_dir, m['file']), pcm_AMY_SAMPLE_RATE) - p.write(" // %s: %s\n" % (m['bank'], m['name'])) - _write_int16_carray(p, data) - p.write("};\n") - p.close() + generate_gamma9001_blob_c(c_path, sounds_dir, pcm_AMY_SAMPLE_RATE) print("gamma9001: %d ROM samples -> pcm_gamma808.h, %d samples (%.2f MB) -> %s + pcm_gamma9001.h + %s" % (len(rom), len(bin_entries), offset * 2 / 1e6, bin_path, c_path)) @@ -1222,6 +1247,13 @@ def generate_all(): def main(): + if 'gamma9001-blob-c' in sys.argv: + index = sys.argv.index('gamma9001-blob-c') + if len(sys.argv) != index + 2: + raise SystemExit( + 'usage: python -m amy.headers gamma9001-blob-c OUTPUT.c') + generate_gamma9001_blob_c(sys.argv[index + 1]) + return if 'gamma9001' in sys.argv: generate_gamma9001_headers() return diff --git a/android/README.md b/android/README.md index 63c50788..fdd8a406 100644 --- a/android/README.md +++ b/android/README.md @@ -46,7 +46,11 @@ intentional and preserves the private-socket security model. See ## Audio profile The Android native build uses AMY's existing 48 kHz / 128-frame build profile -and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. +and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. This LB release +profile also defines `GAMMA9001`. CMake invokes the stdlib-only +`python -m amy.headers gamma9001-blob-c` generator for each ABI build directory +and links that private generated source into the service, so presets 0-18 use +the Gamma808 ROM and presets 256-391 use the complete Gamma9001 sample blob. The marker-gated CI capture records eight seconds from both AMY's rendered samples and the exact buffer handed to Oboe. This leaves a packaged framework diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 08fbf51c..27208fa7 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -2,9 +2,31 @@ cmake_minimum_required(VERSION 3.22.1) project(amy_android LANGUAGES C CXX) find_package(oboe REQUIRED CONFIG) +find_package(Python3 REQUIRED COMPONENTS Interpreter) set(AMY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../..") set(AMY_SRC "${AMY_ROOT}/src") +set(GAMMA9001_PCM_C "${CMAKE_CURRENT_BINARY_DIR}/drums_bin.c") +file(GLOB_RECURSE GAMMA9001_PCM_INPUTS CONFIGURE_DEPENDS + "${AMY_ROOT}/sounds/gamma9001/*.wav" +) + +# Generate the large linkable sample blob inside each ABI's private build +# directory. The tracked maps remain source inputs; parallel Android ABI +# builds never rewrite or share a generated C file in the checkout. +add_custom_command( + OUTPUT "${GAMMA9001_PCM_C}" + COMMAND "${Python3_EXECUTABLE}" -m amy.headers + gamma9001-blob-c "${GAMMA9001_PCM_C}" + WORKING_DIRECTORY "${AMY_ROOT}" + DEPENDS + "${AMY_ROOT}/amy/headers.py" + "${AMY_ROOT}/sounds/gamma9001/manifest.json" + ${GAMMA9001_PCM_INPUTS} + COMMENT "Generating Gamma9001 PCM blob" + VERBATIM +) +set_source_files_properties("${GAMMA9001_PCM_C}" PROPERTIES GENERATED TRUE) set(AMY_SOURCES ${AMY_SRC}/algorithms.c @@ -33,6 +55,7 @@ add_library(amy_android SHARED amy_android.cpp amy_android_capture.cpp amy_android_profile.cpp + ${GAMMA9001_PCM_C} ${AMY_SOURCES} ) @@ -54,6 +77,7 @@ target_compile_definitions(amy_android PRIVATE AMY_HOST_MIDI=1 AMY_NO_MINIAUDIO=1 AMY_WAVETABLE=1 + GAMMA9001=1 ) target_compile_options(amy_android PRIVATE diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index 9c6b5a5e..afc5c333 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -59,6 +59,12 @@ Desktop Linux and macOS use the same frontend wire protocol over a private Unix socket. Windows may use its wrapper/named-pipe transport, but the AMY message stream and frontend logic stay platform-independent. +The Android AAR defines `GAMMA9001` and generates its linkable sample blob in +a private per-ABI build directory. Native downstream builds use the same +`gamma9001-blob-c` generator and link its output while defining `GAMMA9001`. +Consequently PCM presets 0-18 consistently mean the Gamma808 ROM bank on these +targets; this profile changes no wire or sequencer semantics. + The CPython `AMY_PCM_BANK` build selector is release/build policy rather than generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the default for this release line is Gamma9001. Both choices force a fresh extension build diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index b4a71d8d..4ed04849 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -19,6 +19,9 @@ def main() -> None: ROOT / "android/amy-service/src/main/cpp/amy_android_capture.cpp" ).read_text() gradle = (ROOT / "android/amy-service/build.gradle.kts").read_text() + cmake = ( + ROOT / "android/amy-service/src/main/cpp/CMakeLists.txt" + ).read_text() manifest = (ROOT / "android/amy-service/src/main/AndroidManifest.xml").read_text() hello = (ROOT / "android/hello-world/src/main/java/org/amy/hello/MainActivity.java").read_text() @@ -34,6 +37,12 @@ def main() -> None: "the framework-safe eight-second audio capture window") require(r'ndkVersion\s*=\s*"27\.2\.12479018"', gradle, "the PySide-compatible Android NDK r27c") + require(r"gamma9001-blob-c", cmake, + "per-ABI Gamma9001 blob generation") + require(r"GAMMA9001=1", cmake, + "the Gamma9001 AMY compile profile") + require(r"\$\{GAMMA9001_PCM_C\}", cmake, + "the linked Gamma9001 PCM source") require(r"android:process=\":amy\"", manifest, "the separate :amy process") require(r"android:exported=\"false\"", manifest, "a private Android component") require(r"\$\{applicationId\}\.amy-autostart", manifest, @@ -47,7 +56,7 @@ def main() -> None: ) print("Android service contract OK: private :amy process, socket-only client, " - "336 oscillators, 11 buses, 8-second test capture") + "Gamma9001 PCM, 336 oscillators, 11 buses, 8-second test capture") if __name__ == "__main__": From 55f7d1e948692e5bf7fe7ba59a4ce18948e4602c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 00:23:17 +0200 Subject: [PATCH 072/112] Register Gamma9001 PCM in Android service --- android/amy-service/src/main/cpp/amy_android.cpp | 6 ++++++ tests/test_android_service_contract.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 1648098f..0af7342b 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -17,6 +17,9 @@ extern "C" { #include "amy.h" #include "amy_unix_socket.h" +#ifdef GAMMA9001 +extern const int16_t gamma9001_pcm_data[]; +#endif } #define LOG_TAG "AmyAndroid" @@ -81,6 +84,9 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, /* Physical-string clients can require many simultaneous KS voices. */ config.ks_oscs = 16; +#ifdef GAMMA9001 + amy_set_gamma9001_pcm(gamma9001_pcm_data); +#endif amy_start(config); mAmyStarted = true; diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 4ed04849..c5ea38db 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -43,6 +43,8 @@ def main() -> None: "the Gamma9001 AMY compile profile") require(r"\$\{GAMMA9001_PCM_C\}", cmake, "the linked Gamma9001 PCM source") + require(r"amy_set_gamma9001_pcm\(gamma9001_pcm_data\)", engine, + "Gamma9001 PCM registration before AMY starts") require(r"android:process=\":amy\"", manifest, "the separate :amy process") require(r"android:exported=\"false\"", manifest, "a private Android component") require(r"\$\{applicationId\}\.amy-autostart", manifest, From d2cdf6a86662f818c630dbc9ad096acff628017d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 22:27:43 +0200 Subject: [PATCH 073/112] Preserve socket commands under receiver backpressure --- src/amy_unix_socket.c | 26 ++++++++++++++++++++++++-- src/amy_unix_socket.h | 4 +++- tests/test_amy_unix_socket.c | 15 +++++++++------ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c index bba2221f..d059dee6 100644 --- a/src/amy_unix_socket.c +++ b/src/amy_unix_socket.c @@ -23,6 +23,7 @@ #endif #define AMY_UNIX_SOCKET_POLL_MS 50 +#define AMY_UNIX_SOCKET_BACKPRESSURE_POLL_MS 1 struct amy_unix_socket_packet { uint16_t len; @@ -188,9 +189,22 @@ static void queue_packet(amy_unix_socket_server_t *server, store_u32(&server->write_index, write_index + 1u); } +static bool packet_queue_is_full(const amy_unix_socket_server_t *server) { + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + return (uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY; +} + static void receive_client_packets(amy_unix_socket_server_t *server, int client_fd) { for (;;) { + // Leave unread packets in the kernel socket queue when the bounded + // realtime handoff queue is full. The connected sender then receives + // normal socket backpressure instead of a successful write for a + // control message that this process discarded. + if (packet_queue_is_full(server)) return; + char packet[MAX_MESSAGE_LEN]; ssize_t received = recv(client_fd, packet, @@ -265,14 +279,22 @@ static void *socket_thread(void *arg) { fds[0].revents = 0; int client_fd = current_client_fd(server); - if (client_fd >= 0) { + bool queue_full = + client_fd >= 0 && packet_queue_is_full(server); + if (client_fd >= 0 && !queue_full) { fds[1].fd = client_fd; fds[1].events = POLLIN; fds[1].revents = 0; count = 2; } - int ready = poll(fds, count, AMY_UNIX_SOCKET_POLL_MS); + // Do not poll a readable client while the handoff queue is full: that + // would spin. Recheck quickly so the consumer can release + // backpressure without adding a full control-poll interval of latency. + int timeout_ms = queue_full + ? AMY_UNIX_SOCKET_BACKPRESSURE_POLL_MS + : AMY_UNIX_SOCKET_POLL_MS; + int ready = poll(fds, count, timeout_ms); if (ready < 0) { if (errno == EINTR) continue; break; diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h index 24a721c4..97db712d 100644 --- a/src/amy_unix_socket.h +++ b/src/amy_unix_socket.h @@ -16,7 +16,9 @@ extern "C" { // application process <-> amy.sock <-> native AMY/audio process // // The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET -// packets into this fixed SPSC queue. The audio/control owner drains packets +// packets into this fixed SPSC queue. When that queue is full, packets remain +// unread in the kernel socket queue so normal socket backpressure preserves +// every accepted control message. The audio/control owner drains packets // explicitly at a safe point (for example, immediately before rendering the // next AMY block) and may then pass them to amy_add_message(). // diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c index 8fe36540..0da00535 100644 --- a/tests/test_amy_unix_socket.c +++ b/tests/test_amy_unix_socket.c @@ -222,7 +222,7 @@ static void test_oversize_packet_is_dropped(void) { remove_temp_dir(path); } -static void test_queue_is_bounded_and_ordered(void) { +static void test_full_queue_applies_backpressure_and_preserves_order(void) { char dir_template[] = "/tmp/amy-unix-queue-XXXXXX"; char path[256]; make_temp_path(dir_template, path, sizeof(path)); @@ -239,18 +239,21 @@ static void test_queue_is_bounded_and_ordered(void) { send_packet(client, packet, (size_t)len); } - wait_counter(amy_unix_socket_queue_overruns, server, extra); - assert(amy_unix_socket_queue_overruns(server) == extra); + // Let the receiver reach its bounded in-process capacity before the + // consumer starts. Excess packets must remain in the kernel socket queue, + // not be read and discarded. + usleep(150000); - for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY; ++i) { + for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY + extra; ++i) { char expected[32]; int expected_len = snprintf(expected, sizeof(expected), "packet-%03u", i); char received[MAX_MESSAGE_LEN]; - int rc = amy_unix_socket_receive(server, received, sizeof(received)); + int rc = wait_receive(server, received, sizeof(received)); assert(rc == expected_len); assert(strcmp(received, expected) == 0); } + assert(amy_unix_socket_queue_overruns(server) == 0); char received[MAX_MESSAGE_LEN]; assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); @@ -389,7 +392,7 @@ int main(void) { test_invalid_arguments(); test_round_trip_limits_and_permissions(); test_oversize_packet_is_dropped(); - test_queue_is_bounded_and_ordered(); + test_full_queue_applies_backpressure_and_preserves_order(); test_only_one_client_and_reconnect(); test_live_socket_is_not_stolen(); test_owned_stale_socket_is_replaced(); From 6edcc75e73267f95a4c75979451ffb2565ad8cef Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 12:51:32 +0200 Subject: [PATCH 074/112] Make embedded audio geometry configurable --- Makefile | 18 ++++++++++++++++-- amy/constants.py | 5 +++-- docs/porting.md | 13 +++++++++++++ src/amy.h | 34 +++++++++++++++++++++++++++------- src/i2s.c | 14 ++++++++++++++ tests/test_build_config.c | 24 ++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 tests/test_build_config.c diff --git a/Makefile b/Makefile index 9745ffea..8646cb30 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \ -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000 PYTHON = python3 -.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api js-api-test +.PHONY: default all clean amy-module test ctest build-config-test web deploy-web godot-api c-api check-c-api js-api-test default: $(TARGET) all: default @@ -159,7 +159,20 @@ tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS $(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ -ctest: $(CTESTS) +build-config-test: + $(CC) $(CFLAGS) -Isrc \ + -DEXPECT_AMY_BLOCK_SIZE=256 -DEXPECT_BLOCK_SIZE_BITS=8 \ + -DEXPECT_AMY_SAMPLE_RATE=44100 \ + tests/test_build_config.c -o tests/test_build_config_default + ./tests/test_build_config_default + $(CC) $(CFLAGS) -Isrc \ + -DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 \ + -DEXPECT_AMY_BLOCK_SIZE=128 -DEXPECT_BLOCK_SIZE_BITS=7 \ + -DEXPECT_AMY_SAMPLE_RATE=48000 \ + tests/test_build_config.c -o tests/test_build_config_embedded + ./tests/test_build_config_embedded + +ctest: build-config-test $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done amy-module: amy-example @@ -241,3 +254,4 @@ clean: -rm -f amy/constants.py -rm -f $(TARGET) -rm -f tests/*.o $(CTESTS) + -rm -f tests/test_build_config_default tests/test_build_config_embedded diff --git a/amy/constants.py b/amy/constants.py index 9f85e94d..a78d60d3 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -1,10 +1,11 @@ MAX_FILENAME_LEN=127 AMY_BLOCK_SIZE=128 -BLOCK_SIZE_BITS=7 AMY_BLOCK_SIZE=256 +BLOCK_SIZE_BITS=9 +BLOCK_SIZE_BITS=6 +BLOCK_SIZE_BITS=7 BLOCK_SIZE_BITS=8 AMY_SAMPLE_RATE=48000 -AMY_SAMPLE_RATE=48000 AMY_SAMPLE_RATE=44100 PCM_AMY_SAMPLE_RATE=22050 AMY_TRANSFER_TYPE_NONE=0 diff --git a/docs/porting.md b/docs/porting.md index f1a9a004..5d121263 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -25,6 +25,19 @@ Keep blocking IPC away from the realtime audio callback. If a receiver thread accepts commands, move them through a bounded queue and let the AMY/audio owner drain that queue between render blocks. +An embedded build can select its audio geometry without modifying AMY sources: + +```text +-DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 +``` + +`BLOCK_SIZE_BITS` is derived for supported power-of-two block sizes (64, 128, +256, or 512), and a mismatched explicit value is rejected at compile time. On +ESP-IDF, the generic I2S adapter additionally accepts +`AMY_ESP_I2S_PHILIPS_FORMAT`, `AMY_ESP_I2S_DMA_DESC_NUM`, and +`AMY_ESP_I2S_DMA_FRAME_NUM`. If none of these definitions is supplied, AMY's +existing block, sample-rate, I2S-format, and DMA defaults are unchanged. + ## Linux/Android packet transport `src/amy_unix_socket.[ch]` implements a local pathname `AF_UNIX` / diff --git a/src/amy.h b/src/amy.h index a37ae701..40bbef68 100644 --- a/src/amy.h +++ b/src/amy.h @@ -72,21 +72,41 @@ extern const uint32_t pcm_wavetable_len; -// Set block size and SR. We try for 256/44100, but some platforms don't let us: +// Set block size and SR. The defaults remain 256/44100, except on platforms +// that require something else. Embedded applications may provide these as +// compiler definitions instead of carrying a private patch to amy.h. +#ifndef AMY_BLOCK_SIZE #ifdef AMY_DAISY #define AMY_BLOCK_SIZE 128 -#define BLOCK_SIZE_BITS 7 // log2 of BLOCK_SIZE #else #define AMY_BLOCK_SIZE 256 -#define BLOCK_SIZE_BITS 8 // log2 of BLOCK_SIZE +#endif #endif -#ifdef AMY_DAISY -#define AMY_SAMPLE_RATE 48000 -#elif defined __EMSCRIPTEN__ +#ifndef BLOCK_SIZE_BITS +#if AMY_BLOCK_SIZE == 512 +#define BLOCK_SIZE_BITS 9 +#elif AMY_BLOCK_SIZE == 64 +#define BLOCK_SIZE_BITS 6 +#elif AMY_BLOCK_SIZE == 128 +#define BLOCK_SIZE_BITS 7 +#elif AMY_BLOCK_SIZE == 256 +#define BLOCK_SIZE_BITS 8 +#else +#error "AMY_BLOCK_SIZE must be 64, 128, 256, or 512" +#endif +#endif + +#if (1 << BLOCK_SIZE_BITS) != AMY_BLOCK_SIZE +#error "BLOCK_SIZE_BITS must be log2(AMY_BLOCK_SIZE)" +#endif + +#ifndef AMY_SAMPLE_RATE +#if defined(AMY_DAISY) || defined(__EMSCRIPTEN__) #define AMY_SAMPLE_RATE 48000 #else -#define AMY_SAMPLE_RATE 44100 +#define AMY_SAMPLE_RATE 44100 +#endif #endif #define PCM_AMY_SAMPLE_RATE 22050 diff --git a/src/i2s.c b/src/i2s.c index 110bea10..91f8cbfd 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -70,6 +70,12 @@ i2s_chan_handle_t rx_handle; // default ESP setup i2s amy_err_t esp32_setup_i2s(void) { i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER); +#ifdef AMY_ESP_I2S_DMA_DESC_NUM + chan_cfg.dma_desc_num = AMY_ESP_I2S_DMA_DESC_NUM; +#endif +#ifdef AMY_ESP_I2S_DMA_FRAME_NUM + chan_cfg.dma_frame_num = AMY_ESP_I2S_DMA_FRAME_NUM; +#endif if(AMY_HAS_AUDIO_IN) { i2s_new_channel(&chan_cfg, &tx_handle, &rx_handle); } else { @@ -81,7 +87,11 @@ amy_err_t esp32_setup_i2s(void) { #ifdef I2S_32BIT i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AMY_SAMPLE_RATE), +#ifdef AMY_ESP_I2S_PHILIPS_FORMAT + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO), +#else .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO), +#endif .gpio_cfg = { .mclk = (amy_global.config.i2s_mclk == -1)? I2S_GPIO_UNUSED : amy_global.config.i2s_mclk, .bclk = amy_global.config.i2s_bclk, @@ -98,7 +108,11 @@ amy_err_t esp32_setup_i2s(void) { #else // 16 bit I2S i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AMY_SAMPLE_RATE), +#ifdef AMY_ESP_I2S_PHILIPS_FORMAT + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), +#else .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), +#endif .gpio_cfg = { .mclk = (amy_global.config.i2s_mclk == -1)? I2S_GPIO_UNUSED : amy_global.config.i2s_mclk, .bclk = amy_global.config.i2s_bclk, diff --git a/tests/test_build_config.c b/tests/test_build_config.c new file mode 100644 index 00000000..a5a1977a --- /dev/null +++ b/tests/test_build_config.c @@ -0,0 +1,24 @@ +#include "amy.h" + +#ifndef EXPECT_AMY_BLOCK_SIZE +#error "EXPECT_AMY_BLOCK_SIZE is required" +#endif + +#ifndef EXPECT_BLOCK_SIZE_BITS +#error "EXPECT_BLOCK_SIZE_BITS is required" +#endif + +#ifndef EXPECT_AMY_SAMPLE_RATE +#error "EXPECT_AMY_SAMPLE_RATE is required" +#endif + +_Static_assert(AMY_BLOCK_SIZE == EXPECT_AMY_BLOCK_SIZE, + "unexpected AMY block size"); +_Static_assert(BLOCK_SIZE_BITS == EXPECT_BLOCK_SIZE_BITS, + "block-size shift does not match the selected block size"); +_Static_assert(AMY_SAMPLE_RATE == EXPECT_AMY_SAMPLE_RATE, + "unexpected AMY sample rate"); + +int main(void) { + return 0; +} From 41f2753b936dccdbdb8614019b7554c163adf07d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 12:58:12 +0200 Subject: [PATCH 075/112] Use ESP-IDF task entry signatures --- src/amy_midi.c | 3 ++- src/i2s.c | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/amy_midi.c b/src/amy_midi.c index c6e132af..bba875cf 100644 --- a/src/amy_midi.c +++ b/src/amy_midi.c @@ -614,7 +614,8 @@ void esp_poll_midi(void) { } } -void run_midi_task() { +void run_midi_task(void *pvParameters) { + (void)pvParameters; while(1) { esp_poll_midi(); diff --git a/src/i2s.c b/src/i2s.c index 91f8cbfd..0974829c 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -341,7 +341,8 @@ static int64_t _rl_last_print = 0; static int32_t _rl_render_us = 0; #endif // ARDUINO_SPEEDTEST -void esp_fill_audio_buffer_task() { +void esp_fill_audio_buffer_task(void *pvParameters) { + (void)pvParameters; while(1) { int64_t t; uint32_t blocked_us = 0; From 166a63d51e397d1e027812ee474615b7b973fc78 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:59:26 +0200 Subject: [PATCH 076/112] Size reusable sequences for Omnichord integrations --- android/amy-service/src/main/cpp/amy_android.cpp | 6 ++++++ tests/test_android_service_contract.py | 13 ++++++++++++- tests/test_python_offline_live.py | 13 ++++++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 0af7342b..36a5ce8c 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -58,6 +58,9 @@ constexpr int kAudioReadyTimeoutMs = 2000; constexpr int kAudioReadyPollMs = 2; constexpr uint16_t kIntegrationMaxOscillators = 336; constexpr uint16_t kIntegrationMaxBuses = 11; +constexpr uint32_t kIntegrationMaxSequencerTags = 1280; +constexpr uint32_t kIntegrationMaxSequenceEvents = 64; +constexpr uint32_t kIntegrationMaxSequenceExecutions = 40; class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback { @@ -78,6 +81,9 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, */ config.max_oscs = kIntegrationMaxOscillators; config.max_buses = kIntegrationMaxBuses; + config.max_sequencer_tags = kIntegrationMaxSequencerTags; + config.max_sequence_events = kIntegrationMaxSequenceEvents; + config.max_sequence_executions = kIntegrationMaxSequenceExecutions; /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ config.platform.multicore = 0; config.platform.multithread = 0; diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index c5ea38db..8b139ee5 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -33,6 +33,16 @@ def main() -> None: "runtime oscillator configuration") require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, "runtime bus configuration") + require(r"kIntegrationMaxSequencerTags\s*=\s*1280\s*;", engine, + "the shared live-event and stored-sequence tag capacity") + require(r"config\.max_sequencer_tags\s*=\s*kIntegrationMaxSequencerTags\s*;", + engine, "runtime sequencer-tag configuration") + require(r"config\.max_sequence_events\s*=\s*kIntegrationMaxSequenceEvents\s*;", + engine, "runtime per-sequence event configuration") + require(r"config\.max_sequence_executions\s*=\s*kIntegrationMaxSequenceExecutions\s*;", + engine, "runtime sequence-execution configuration") + require(r"kIntegrationMaxSequenceExecutions\s*=\s*40\s*;", engine, + "characterized Omnichord sequence-execution capacity") require(r"kCaptureSeconds\s*=\s*8\s*;", capture, "the framework-safe eight-second audio capture window") require(r'ndkVersion\s*=\s*"27\.2\.12479018"', gradle, @@ -58,7 +68,8 @@ def main() -> None: ) print("Android service contract OK: private :amy process, socket-only client, " - "Gamma9001 PCM, 336 oscillators, 11 buses, 8-second test capture") + "Gamma9001 PCM, 336 oscillators, 11 buses, 1280 sequencer tags, " + "8-second test capture") if __name__ == "__main__": diff --git a/tests/test_python_offline_live.py b/tests/test_python_offline_live.py index 63576a00..66261124 100644 --- a/tests/test_python_offline_live.py +++ b/tests/test_python_offline_live.py @@ -13,9 +13,9 @@ def main() -> int: c_amy.live( audio=False, default_synths=0, - max_patterns=1024, - max_pattern_tags=64, - max_pattern_instances=32, + max_sequencer_tags=1280, + max_sequence_events=64, + max_sequence_executions=40, ) before = amy.ticks_ms() @@ -37,11 +37,10 @@ def main() -> int: if amy.ticks_ms() <= after_sleep: raise AssertionError("explicit offline renders did not advance AMY time") - # A high pattern id proves that audio=False retained live()'s configurable + # A high sequence tag proves that audio=False retained live()'s configurable # engine sizing instead of falling back to the import-time defaults. - amy.pattern_begin(1000, 4) - amy.pattern_event_wire(1000, 0, "v0l0Z", period=4, tag=0) - amy.pattern_commit(1000) + amy.define_sequence(1000, [dict(ticks=(0,), osc=0, vel=0)]) + amy.send(sequence_control=(1000, amy.SEQUENCE_CONTROL_START)) return 0 From 11f0c39fe8350e7a32b9a1c7b1114f4a7806d795 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 10:53:23 +0200 Subject: [PATCH 077/112] Update Omnichord release profile contract --- docs/lb_omnichord_release_contract.md | 118 ++++++++++++++------------ 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index afc5c333..9078da89 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -7,85 +7,97 @@ immutable build input used by every platform package. The fork's `main` remains a fast-forward mirror of `shorepine/amy` `main`. Generic changes are developed on a clean upstream-directed branch. A release -branch layers the tested platform and application profile on that clean work; -it is never itself offered upstream. +branch starts from that clean work and layers only the tested platform and +application profile on top; it is never itself offered upstream. ## Current line -`releases/amy_omnichord_R20260903T201525` starts with: +`releases/amy_omnichord_R20260905T104903` starts from fork branch +`rework/sequencer` at `3872b4be16af4f486c8f3259d44478ee7174864f`, the +source offered in Shorepine PR 1151. That source in turn starts from Shorepine +main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`. + +The release layers on: -- Shorepine main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`; -- the generic sequencer-group work from `rework/sequencer`; - the private Unix-socket service and Android Oboe integration; -- the Gamma9001 hosted drum bank profile; -- deterministic offline CPython startup for tests; and -- the larger bounded sequencer-group capacity required by the rhythm - catalogue. +- socket receiver backpressure protection; +- the Gamma9001 hosted drum-bank profile; +- deterministic offline CPython startup for tests; +- 336 oscillators and 11 buses; and +- 1,280 sequence tags, 64 events per definition and 40 active or + alignment-pending executions. -The abandoned bus-mixer experiment is not part of this line. AMY's generic -bus support remains whatever is present in Shorepine main; no private mixer -module or routing policy is restored. +The abandoned bus-mixer experiment is not part of this line. The 11-bus +setting only enlarges AMY's existing generic bus capacity; it introduces no +private mixer, routing API or musical policy. -## Sequencer boundary +## Sequence boundary The clean `rework/sequencer` branch contains only generic AMY behavior: -- grouped events use `ticks=tick,period,event_tag,group_tag`; -- one `sequence_control` family publishes, starts, stops, gates and clears; -- active executions retain immutable published revisions; -- one, N and infinite repeats share the same repeat-count model; -- quantization uses AMY's own sequencer clock; and -- a root event may launch a group, while a group cannot launch another group. - -LB Omnichord owns all musical policy: which rhythm roles become groups, which -ones a fill gates, which arpeggios may overlap, group/tag allocation and root -arrangement schedules. The frontend remains a wire-protocol client and never -imports or calls AMY engine internals. - -The release profile uses 1,024 group slots, 64 local event tags per group and -32 active or pending executions. The high group count stores the complete fill -catalogue; it does not create 1,024 players. Event tables are allocated lazily -only for definitions that are actually authored. +- untagged one- and two-field `ticks` retain direct scheduling; +- a tagged `ticks=(tick, period, tag)` event cumulatively extends a stopped, + reusable sequence definition; +- `sequence_reset` clears a future definition; +- `sequence_control` starts, stops or gates executions, with optional + alignment on AMY's own clock; +- finite executions may overlap and each execution retains its immutable + definition snapshot; +- publication and deferred reclamation keep clone/free work out of the render + path; and +- a sequence may start another sequence, while bounded execution capacity + prevents cyclic graphs from recursing without limit. + +LB Omnichord owns all musical policy: instrument roles, fills, arpeggios, +sequence/tag allocation and replacement boundaries. The frontend remains a +wire-protocol client and never imports or calls AMY engine internals. + +The high tag capacity stores the complete rhythm catalogue. It does not create +1,280 players: definitions and executions allocate from separate bounded +resources, and only authored definitions consume event storage. ## Platform boundary On Android, the Qt frontend and the unexported `:amy` service are separate processes under the same application UID. The frontend discovers the application-private socket path and sends only AMY wire messages. Audio is -rendered by the service and handed to Oboe/AAudio. The service is built at 48 -kHz with 128-frame stereo blocks. +rendered by the service and handed to Oboe/AAudio. The service is built at +48 kHz with 128-frame stereo blocks. Desktop Linux and macOS use the same frontend wire protocol over a private -Unix socket. Windows may use its wrapper/named-pipe transport, but the AMY -message stream and frontend logic stay platform-independent. +Unix socket. Windows uses its wrapper/named-pipe transport. The AMY command +stream and frontend synthesis logic stay platform-independent. The Android AAR defines `GAMMA9001` and generates its linkable sample blob in a private per-ABI build directory. Native downstream builds use the same `gamma9001-blob-c` generator and link its output while defining `GAMMA9001`. -Consequently PCM presets 0-18 consistently mean the Gamma808 ROM bank on these -targets; this profile changes no wire or sequencer semantics. +Consequently PCM presets 0-18 mean the Gamma808 ROM and presets 256-391 use +the Gamma9001 sample set on all hosted release targets. + +The CPython `AMY_PCM_BANK` selector is release/build policy rather than +generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the hosted +Omnichord profile selects Gamma9001. Both choices force a fresh extension +build because they share an output filename. -The CPython `AMY_PCM_BANK` build selector is release/build policy rather than -generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the default -for this release line is Gamma9001. Both choices force a fresh extension build -because they share an output filename. +`amy.live(audio=False, ...)` is the deterministic host-test mode. The default +remains live miniaudio, preserving existing callers. Offline mode prevents a +system-audio callback and a deterministic renderer from consuming the same +AMY stream concurrently. -`amy.live(audio=AMY_AUDIO_IS_NONE, ...)` is the deterministic test mode. The -default remains live miniaudio, preserving existing callers. Offline mode -prevents a system-audio callback and a deterministic renderer from consuming -the same AMY stream concurrently. +The release also keeps compile-time embedded audio geometry configurable, +including the already characterized 48 kHz / 128-sample ESP32-P4 frame size. +Physical ESP32-P4 timing, heap and DMA validation remains a separate hardware +gate and is not implied by hosted tests. ## Release procedure 1. Verify fork main exactly matches the chosen Shorepine main. 2. Test generic work on the clean upstream-directed branch. -3. Create the release branch and add only required fork integrations. -4. Run native AMY, wire/socket, PCM-bank, offline and Android contract tests. -5. Pin the final release branch and SHA in LB Omnichord configuration and - packaging inputs. -6. Run LB Omnichord's generic and platform-specific suites against that same - SHA. -7. Record the exact AMY SHA in release notes and keep diagnostic commits. - -ESP32 validation is deliberately deferred for this rework; it must be -completed before claiming ESP32 support for the resulting release. +3. Start a new immutable release branch at that exact generic commit. +4. Add only required fork integrations in diagnostic commits. +5. Run native AMY, wire/socket, PCM-bank, offline and Android contract tests. +6. Pin the final release branch and SHA once in LB Omnichord's release-input + manifest and update its human-readable platform documents. +7. Reinstall that exact AMY SHA and run LB Omnichord's generic and + platform-specific suites. +8. Record the exact AMY SHA in release notes. From 084247aed45d481f8a7a015e52632fd92b8ed611 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 13:31:04 +0200 Subject: [PATCH 078/112] Avoid forgotten-note buildup when note-offs are ignored --- Makefile | 3 +- src/instrument.c | 15 +++++++ tests/test_ignore_note_offs.c | 78 +++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/test_ignore_note_offs.c diff --git a/Makefile b/Makefile index f849e7dc..98677939 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,8 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ - tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope + tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope \ + tests/test_ignore_note_offs # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). diff --git a/src/instrument.c b/src/instrument.c index 0e1a2a39..4dcf1219 100644 --- a/src/instrument.c +++ b/src/instrument.c @@ -223,6 +223,11 @@ void instrument_free(struct instrument_info *instrument) { } void _instrument_push_note_forgotten(struct instrument_info *instrument, uint16_t note) { + // Forgotten notes exist only to absorb their eventual note-offs. A synth + // which explicitly ignores note-offs (typically a one-shot drum synth) + // has nothing to match, and may otherwise fill this bounded pool forever. + if (instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) return; + int available_index = -1; for (int i = 0; i < FORGOTTEN_POOL_SIZE; ++i) { if (instrument->forgotten_notes[i] == note) { @@ -294,6 +299,10 @@ uint16_t _instrument_voice_off(struct instrument_info *instrument, uint16_t voic uint16_t instrument_note_off(struct instrument_info *instrument, uint16_t note) { uint16_t voice = _instrument_voice_for_note(instrument, note); if (voice == _INSTRUMENT_NO_VOICE) { + // A late note-off for an already stolen one-shot is expected when + // note-offs are ignored; no forgotten-note entry is kept for it. + if (instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) + return _INSTRUMENT_NO_VOICE; // Don't report an unmatched note-off if it was a victim of stealing. if (!_instrument_pop_note_forgotten(instrument, note) && !(instrument->flags & SYNTH_FLAGS_NO_NOTE_WARNINGS)) @@ -551,6 +560,12 @@ uint32_t instrument_get_flags(int instrument_number) { void instrument_set_flags(int instrument_number, uint32_t flags) { if (!instrument_number_exists(instrument_number, "set_flags")) return; struct instrument_info *instrument = instruments[instrument_number]; + if ((flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) + && !(instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS)) { + // Entries accumulated under the old policy can never be needed once + // note-offs are ignored, and must not become stale if flags change. + _instrument_reset_forgotten_pool(instrument); + } instrument->flags = flags; } diff --git a/tests/test_ignore_note_offs.c b/tests/test_ignore_note_offs.c new file mode 100644 index 00000000..4f09a98d --- /dev/null +++ b/tests/test_ignore_note_offs.c @@ -0,0 +1,78 @@ +// Regression coverage for synths which intentionally ignore note-offs. +// +// A one-shot drum synth can receive an unlimited series of note-ons without +// matching note-offs. Voice stealing must not put those notes into the +// bounded forgotten-note pool: its only purpose is to absorb note-offs which +// this synth has explicitly said will be ignored. + +#include +#include +#include +#include "amy.h" + +static int failures = 0; + +#define CHECK(cond, message) do { \ + if (cond) { printf(" ok %s\n", message); } \ + else { printf(" FAIL %s\n", message); failures++; } \ +} while (0) + +static void render_a_bit(void) { + for (int i = 0; i < 4; ++i) amy_simple_fill_buffer(); +} + +static void send(const char *message) { + amy_add_message((char *)message); + render_a_bit(); +} + +static int file_contains(const char *path, const char *needle) { + FILE *file = fopen(path, "r"); + if (file == NULL) return 0; + char buffer[8192] = {0}; + size_t count = fread(buffer, 1, sizeof(buffer) - 1, file); + buffer[count] = 0; + fclose(file); + return strstr(buffer, needle) != NULL; +} + +static void test_ignored_note_offs_do_not_fill_forgotten_pool(void) { + const char *path = "test_ignore_note_offs.stderr.tmp"; + printf("ignored note-offs require no forgotten-note bookkeeping\n"); + fflush(stderr); + FILE *redirected = freopen(path, "w", stderr); + + // This is the shape used by a small polyphonic one-shot PCM drum synth: + // four voices, one oscillator per voice, and no note-offs by design. + send("i0iv4in1if2Z"); + for (int note = 1; note <= 64; ++note) { + char message[32]; + snprintf(message, sizeof(message), "n%dl1i0Z", note); + send(message); + } + + fflush(stderr); + int overflow = redirected != NULL + && file_contains(path, "forgotten pool overflow"); + FILE *restored = freopen("/dev/stderr", "w", stderr); + (void)restored; + remove(path); + CHECK(!overflow, "64 one-shot onsets do not overflow the pool"); +} + +// examples.o wants this from amy-example.c; every ctest stubs it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + render_a_bit(); + + test_ignored_note_offs_do_not_fill_forgotten_pool(); + + amy_stop(); + if (failures) { printf("%d FAILURES\n", failures); return 1; } + printf("all ok\n"); + return 0; +} From 5cde3bf783052424dc8720b7d5c466eab0194df3 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 13:33:29 +0200 Subject: [PATCH 079/112] Record one-shot drum bookkeeping release --- docs/lb_omnichord_release_contract.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index 9078da89..37e72c52 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -12,7 +12,7 @@ application profile on top; it is never itself offered upstream. ## Current line -`releases/amy_omnichord_R20260905T104903` starts from fork branch +`releases/amy_omnichord_R20260905T133309` starts from fork branch `rework/sequencer` at `3872b4be16af4f486c8f3259d44478ee7174864f`, the source offered in Shorepine PR 1151. That source in turn starts from Shorepine main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`. @@ -23,6 +23,8 @@ The release layers on: - socket receiver backpressure protection; - the Gamma9001 hosted drum-bank profile; - deterministic offline CPython startup for tests; +- ignored-note-off bookkeeping suitable for indefinitely running one-shot + percussion synths; - 336 oscillators and 11 buses; and - 1,280 sequence tags, 64 events per definition and 40 active or alignment-pending executions. From 57c92c2dcf01fd7827fc48d9b099879ef50aeca7 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 13:41:30 +0200 Subject: [PATCH 080/112] Make ignored-note-off regression state based --- Makefile | 12 +++++++++++- src/instrument.c | 11 +++++++++++ tests/test_ignore_note_offs.c | 37 +++++++++++++++-------------------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index 98677939..14c81ff1 100644 --- a/Makefile +++ b/Makefile @@ -135,9 +135,19 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ $(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(CTESTS): %: %.o $(OBJECTS) +INSTRUMENT_SPECIAL_TEST = tests/test_ignore_note_offs + +$(filter-out $(INSTRUMENT_SPECIAL_TEST),$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ +# Read internal pool occupancy in this test without parsing stderr or relying +# on platform-specific file-descriptor redirection. +tests/instrument_testing_impl.o: src/instrument.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_INSTRUMENT_TESTING -c $< -o $@ + +$(INSTRUMENT_SPECIAL_TEST): %: %.o tests/instrument_testing_impl.o $(filter-out src/instrument.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/instrument.o,$(OBJECTS)) tests/instrument_testing_impl.o $< -Wall $(LIBS) -o $@ + ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done diff --git a/src/instrument.c b/src/instrument.c index 4dcf1219..e3bf0c86 100644 --- a/src/instrument.c +++ b/src/instrument.c @@ -569,6 +569,17 @@ void instrument_set_flags(int instrument_number, uint32_t flags) { instrument->flags = flags; } +#ifdef AMY_INSTRUMENT_TESTING +int instrument_test_forgotten_note_slots(int instrument_number) { + if (!instrument_number_exists(instrument_number, NULL)) return -1; + struct instrument_info *instrument = instruments[instrument_number]; + int occupied = 0; + for (int i = 0; i < FORGOTTEN_POOL_SIZE; ++i) + if (instrument->forgotten_notes[i] != _INSTRUMENT_NO_NOTE) ++occupied; + return occupied; +} +#endif + uint16_t instrument_noteon_delay_ms(int instrument_number) { if (!instrument_number_exists(instrument_number, "noteon_delay")) return 0; struct instrument_info *instrument = instruments[instrument_number]; diff --git a/tests/test_ignore_note_offs.c b/tests/test_ignore_note_offs.c index 4f09a98d..005c93a3 100644 --- a/tests/test_ignore_note_offs.c +++ b/tests/test_ignore_note_offs.c @@ -7,7 +7,6 @@ #include #include -#include #include "amy.h" static int failures = 0; @@ -26,22 +25,10 @@ static void send(const char *message) { render_a_bit(); } -static int file_contains(const char *path, const char *needle) { - FILE *file = fopen(path, "r"); - if (file == NULL) return 0; - char buffer[8192] = {0}; - size_t count = fread(buffer, 1, sizeof(buffer) - 1, file); - buffer[count] = 0; - fclose(file); - return strstr(buffer, needle) != NULL; -} +extern int instrument_test_forgotten_note_slots(int instrument_number); static void test_ignored_note_offs_do_not_fill_forgotten_pool(void) { - const char *path = "test_ignore_note_offs.stderr.tmp"; printf("ignored note-offs require no forgotten-note bookkeeping\n"); - fflush(stderr); - FILE *redirected = freopen(path, "w", stderr); - // This is the shape used by a small polyphonic one-shot PCM drum synth: // four voices, one oscillator per voice, and no note-offs by design. send("i0iv4in1if2Z"); @@ -51,13 +38,20 @@ static void test_ignored_note_offs_do_not_fill_forgotten_pool(void) { send(message); } - fflush(stderr); - int overflow = redirected != NULL - && file_contains(path, "forgotten pool overflow"); - FILE *restored = freopen("/dev/stderr", "w", stderr); - (void)restored; - remove(path); - CHECK(!overflow, "64 one-shot onsets do not overflow the pool"); + CHECK(instrument_test_forgotten_note_slots(0) == 0, + "64 one-shot onsets leave the pool empty"); +} + +static void test_ordinary_synths_still_track_stolen_notes(void) { + printf("ordinary synths retain forgotten-note matching\n"); + send("i1iv4in1Z"); + for (int note = 1; note <= 5; ++note) { + char message[32]; + snprintf(message, sizeof(message), "n%dl1i1Z", note); + send(message); + } + CHECK(instrument_test_forgotten_note_slots(1) == 1, + "one stolen ordinary note occupies one pool slot"); } // examples.o wants this from amy-example.c; every ctest stubs it. @@ -70,6 +64,7 @@ int main(void) { render_a_bit(); test_ignored_note_offs_do_not_fill_forgotten_pool(); + test_ordinary_synths_still_track_stolen_notes(); amy_stop(); if (failures) { printf("%d FAILURES\n", failures); return 1; } From 9306a2cb1c2488264e384bb534264f039fb60b8c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 11:39:12 +0200 Subject: [PATCH 081/112] Add opt-in ESP render load diagnostics --- src/i2s.c | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/i2s.c b/src/i2s.c index 0974829c..8d4c0662 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -278,6 +278,56 @@ TaskHandle_t amy_update_handle = NULL; // caller combine the worker's half-written buffer. static SemaphoreHandle_t esp_render_done_sem = NULL; +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +typedef struct { + uint64_t window_started_us; + uint64_t execute_sum_us; + uint64_t render_sum_us; + uint64_t fill_sum_us; + uint64_t total_sum_us; + uint32_t execute_max_us; + uint32_t render_max_us; + uint32_t fill_max_us; + uint32_t total_max_us; + uint32_t blocks; +} esp_load_diagnostic_t; + +static esp_load_diagnostic_t esp_load_diagnostic; + +static void esp_load_diagnostic_record(uint32_t execute_us, + uint32_t render_us, + uint32_t fill_us, + uint32_t total_us) { + esp_load_diagnostic_t *stats = &esp_load_diagnostic; + stats->execute_sum_us += execute_us; + stats->render_sum_us += render_us; + stats->fill_sum_us += fill_us; + stats->total_sum_us += total_us; + if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; + if (render_us > stats->render_max_us) stats->render_max_us = render_us; + if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; + if (total_us > stats->total_max_us) stats->total_max_us = total_us; + ++stats->blocks; + + uint64_t now_us = amy_get_us(); + if (stats->window_started_us == 0) stats->window_started_us = now_us; + if (now_us - stats->window_started_us < 2000000 || stats->blocks == 0) return; + + uint32_t blocks = stats->blocks; + fprintf(stderr, + "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " + "max_us execute=%u render=%u fill=%u total=%u\n", + (unsigned)blocks, + (unsigned)(stats->execute_sum_us / blocks), + (unsigned)(stats->render_sum_us / blocks), + (unsigned)(stats->fill_sum_us / blocks), + (unsigned)(stats->total_sum_us / blocks), + (unsigned)stats->execute_max_us, (unsigned)stats->render_max_us, + (unsigned)stats->fill_max_us, (unsigned)stats->total_max_us); + *stats = (esp_load_diagnostic_t){ .window_started_us = now_us }; +} +#endif + // Render the second core void esp_render_task( void * pvParameters) { while(1) { @@ -357,14 +407,35 @@ void esp_fill_audio_buffer_task(void *pvParameters) { int64_t _rl_start_t = esp_timer_get_time(); #endif // ARDUINO_SPEEDTEST // Get ready to render +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t stage_started_us = amy_get_us(); +#endif amy_execute_deltas(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t execute_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif // Render on whichever cores we have available. +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + stage_started_us = amy_get_us(); +#endif esp_render_on_cores(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t render_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif // Write to i2s +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + stage_started_us = amy_get_us(); +#endif output_sample_type *block = amy_fill_buffer(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t fill_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif uint32_t busy_us = (uint32_t)(amy_get_us() - t); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_load_diagnostic_record(execute_us, render_us, fill_us, busy_us); +#endif AMY_PROFILE_STOP(AMY_ESP_FILL_BUFFER) last_audio_buffer = block; From 0bd9feaf8d6c353184ba3bc530842204eb047ca8 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 14:51:33 +0200 Subject: [PATCH 082/112] Add shared aux reverb rooms --- Makefile | 2 +- amy/__init__.py | 4 +- docs/api.md | 33 +++++ src/amy.c | 273 ++++++++++++++++++++++++++++++++++++- src/amy.h | 74 ++++++++++ src/amy_api.generated.js | 64 +++++---- src/api.c | 11 ++ src/delay.c | 142 ++++++++++++++++--- src/delay.h | 8 ++ src/i2s.c | 30 +++- src/parse.c | 55 ++++++-- src/patches.c | 80 ++++++++++- tests/test_shared_reverb.c | 118 ++++++++++++++++ 13 files changed, 832 insertions(+), 62 deletions(-) create mode 100644 tests/test_shared_reverb.c diff --git a/Makefile b/Makefile index a33868ee..508b8e21 100644 --- a/Makefile +++ b/Makefile @@ -134,7 +134,7 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope \ - tests/test_ignore_note_offs + tests/test_ignore_note_offs tests/test_shared_reverb # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). diff --git a/amy/__init__.py b/amy/__init__.py index 7347dba2..cf22dd6d 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -408,7 +408,9 @@ def _normalize_sequence_action(kwargs): ('mod_source', 'LL'), ('eq', 'xL'), ('filter_type', 'GI'), ('ratio', 'IF'), ('latency_ms', 'NI'), ('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'), ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), - ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), + ('algorithm', 'oI'), ('chorus', 'kL'), + ('reverb_room', 'hRL'), ('reverb_send', 'hSL'), ('reverb', 'hL'), + ('echo', 'ML'), ('patch', 'KI'), ('sequence_reset', 'HRI'), ('sequence_control', 'HCL'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), diff --git a/docs/api.md b/docs/api.md index 750a7893..3e26198b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,6 +203,10 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | +| `max_reverb_rooms` | Int | 0 | Number of optional shared aux-return reverbs. Zero preserves the historical per-bus reverb path. | +| `reverb_room_memory` | `void **` | `NULL` | Optional array of one caller-owned arena per shared room. A null entry uses AMY's configured heaps. This lets embedded hosts keep each room in a dedicated SRAM bank. | +| `reverb_room_memory_bytes` | bytes | 0 | Size of every supplied room arena. A 128 KiB arena holds the current stereo reverb network and its block workspace. | +| `reverb_diagnostics` | `0=off, 1=on` | Off | Store per-room and total-stage timing counters for later retrieval. Nothing is printed in the realtime path. | | `max_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | | `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | | `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | @@ -480,10 +484,39 @@ Default AMY has 4 buses, 0..3. Set `max_buses` in `amy_config_t` before `amy_st | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `h` | `reverb_level, reverb_liveness, reverb_damping, reverb_xover_hz` | `reverb` | float[,float,float,float] | Reverb parameters -- level, liveness, damping, xover: Level is for output mix; +| `hR` | `reverb_room, reverb_room_level, reverb_room_liveness, reverb_room_damping, reverb_room_xover_hz` | `reverb_room` | int,float[,float,float,float] | Configure a shared reverb room: room, return level, liveness, damping and crossover. Shared rooms must first be enabled with `max_reverb_rooms`. | +| `hS` | `reverb_send_room, reverb_send_level` | `reverb_send` | int,float | Route the selected bus to a shared room with a weighted post-fader send. A send of zero excludes the bus while retaining its room selection. | | `k` | `chorus_level, chorus_max_delay, chorus_lfo_freq, chorus_depth` | `chorus` | float[,float,float,float] | Chorus parameters -- level, delay, freq, depth: Level is for output mix (0 to turn off); delay is max in samples (320); freq is LFO rate in Hz (0.5); depth is proportion of max delay (0.5). | | `M` | `echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef` | `echo` | float[,int,int,float,float] | Echo parameters -- level, delay_ms, max_delay_ms, feedback, filter_coef (-1 is HPF, 0 is flat, +1 is LPF). | | `x` | `eq_l, eq_m, eq_h` |`eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Hz) -15 to 15. 0 is off. default 0. | +#### Shared reverb rooms + +Per-bus `reverb`/`h` remains the default and is unchanged. A host that needs +many buses but only a few acoustic spaces can instead enable shared rooms in +`amy_config_t`. Each room owns one reverb delay network; any number of buses +can feed it: + +```python +amy.send(reverb_room=[0, 0.6, 0.85, 0.5, 3000]) +amy.send(bus=0, reverb_send=[0, 1.0]) +amy.send(bus=1, reverb_send=[0, 0.35]) +amy.send(bus=2, reverb_send=[0, 0.0]) # dry bus; room selection retained +``` + +The equivalent wire messages are `hR0,0.6,0.85,0.5,3000Z`, +`y0hS0,1Z`, `y1hS0,0.35Z`, and `y2hS0,0Z`. Sends are post-fader: changing a +bus volume changes both its dry signal and what it contributes to the room. +The room return is added once to the final mix, so buses sharing a room also +share its tail and room parameters. + +On ESP with multicore rendering, rooms 0 and 1 are processed concurrently on +the existing two pinned audio/render tasks. Additional rooms are processed +serially. `amy_reverb_diagnostics_get()` and +`amy_reverb_stage_diagnostics_get()` take lock-free snapshots of counters +collected by those tasks; `amy_reverb_diagnostics_print()` is intended to be +called later from a low-priority control task, never from the audio callback. + Distortion (`GC`/`GF`/`GH`/`GD`/`GM`) runs per bus too, first in the bus FX chain -- before EQ, chorus, echo and reverb. It has no bus-specific commands: the `G` commands above address a bus whenever the event that carries them names no oscillator. #### Distortion scope diff --git a/src/amy.c b/src/amy.c index 75cc47b0..a74ebb29 100644 --- a/src/amy.c +++ b/src/amy.c @@ -9,6 +9,8 @@ // AMY_DEBUG) the profiler. #ifdef ESP_PLATFORM #include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" int64_t amy_get_us() { return esp_timer_get_time(); } #elif defined PICO_ON_DEVICE #include "pico/time.h" @@ -410,7 +412,195 @@ void dealloc_reverb_delay_lines(uint16_t bus) { if (amy_global.bus[bus]->reverb.rev != NULL) { deinit_stereo_reverb(amy_global.bus[bus]->reverb.rev); delete_reverb(amy_global.bus[bus]->reverb.rev); + amy_global.bus[bus]->reverb.rev = NULL; + } +} + +static amy_reverb_diagnostic_t reverb_stage_diagnostic; +static volatile uint32_t reverb_stage_diagnostic_seq; + +static uint32_t reverb_current_core_mask(void) { +#ifdef ESP_PLATFORM + int core = xPortGetCoreID(); + return (core >= 0 && core < 32) ? (1u << core) : 0; +#else + return 1u; +#endif +} + +static void reverb_diagnostic_record(volatile uint32_t *seq, + amy_reverb_diagnostic_t *diagnostic, + uint32_t elapsed_us) { + ++*seq; + __sync_synchronize(); + ++diagnostic->calls; + diagnostic->total_us += elapsed_us; + if (elapsed_us > diagnostic->max_us) diagnostic->max_us = elapsed_us; + if (elapsed_us > AMY_BLOCK_US) ++diagnostic->deadline_misses; + diagnostic->core_mask |= reverb_current_core_mask(); + __sync_synchronize(); + ++*seq; +} + +static bool reverb_diagnostic_snapshot(volatile uint32_t *seq, + amy_reverb_diagnostic_t *source, + amy_reverb_diagnostic_t *result) { + if (result == NULL) return false; + for (int attempt = 0; attempt < 8; ++attempt) { + uint32_t before = *seq; + if (before & 1u) continue; + __sync_synchronize(); + *result = *source; + __sync_synchronize(); + if (before == *seq) return true; + } + return false; +} + +static bool init_reverb_room(uint16_t room) { + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + state->effect.level = 0; + state->effect.liveness = REVERB_DEFAULT_LIVENESS; + state->effect.damping = REVERB_DEFAULT_DAMPING; + state->effect.xover_hz = REVERB_DEFAULT_XOVER_HZ; + + void *arena = NULL; + if (amy_global.config.reverb_room_memory != NULL) + arena = amy_global.config.reverb_room_memory[room]; + if (arena != NULL) { + state->arena = arena; + state->arena_bytes = amy_global.config.reverb_room_memory_bytes; + state->effect.rev = new_reverb_in_arena( + arena, state->arena_bytes, &state->block, &state->arena_used); + if (state->effect.rev == NULL) { + fprintf(stderr, + "shared reverb room %u does not fit its %zu-byte arena\n", + room, state->arena_bytes); + return false; + } + } else { + state->effect.rev = new_reverb(); + state->block = (SAMPLE *)malloc_caps( + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + amy_global.config.ram_caps_block); + state->block_heap_owned = 1; + if (state->effect.rev == NULL || state->block == NULL + || !init_stereo_reverb(state->effect.rev)) { + fprintf(stderr, "unable to allocate shared reverb room %u\n", room); + return false; + } + bzero(state->block, + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + } + config_stereo_reverb(state->effect.rev, state->effect.liveness, + state->effect.xover_hz, state->effect.damping); + return true; +} + +static void deinit_reverb_room(shared_reverb_state_t *state) { + if (state == NULL) return; + if (state->effect.rev != NULL) { + deinit_stereo_reverb(state->effect.rev); + delete_reverb(state->effect.rev); + } + if (state->block_heap_owned) free(state->block); + *state = (shared_reverb_state_t){0}; +} + +void config_reverb_room(uint16_t room, float level, float liveness, + float damping, float xover_hz) { + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) { + fprintf(stderr, "shared reverb room %u is not configured (max %u)\n", + room, amy_global.config.max_reverb_rooms); + return; } + reverb_state_t *effect = &amy_global.reverb_rooms[room].effect; + if (AMY_IS_UNSET(level)) level = S2F(effect->level); + if (AMY_IS_UNSET(liveness)) liveness = effect->liveness; + if (AMY_IS_UNSET(damping)) damping = effect->damping; + if (AMY_IS_UNSET(xover_hz)) xover_hz = effect->xover_hz; + if (!isfinite(level) || level < 0) level = 0; + effect->level = F2S(level); + effect->liveness = liveness; + effect->damping = damping; + effect->xover_hz = xover_hz; + config_stereo_reverb(effect->rev, liveness, xover_hz, damping); +} + +void config_reverb_send(uint16_t bus, uint16_t room, float level) { + bus = amy_validate_bus(bus); + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) { + fprintf(stderr, "shared reverb room %u is not configured (max %u)\n", + room, amy_global.config.max_reverb_rooms); + return; + } + if (AMY_IS_UNSET(level)) level = S2F(amy_global.bus[bus]->reverb_send_level); + if (!isfinite(level)) { + fprintf(stderr, "shared reverb send level must be finite\n"); + return; + } + if (level < 0) level = 0; + amy_global.bus[bus]->reverb_send_room = room; + amy_global.bus[bus]->reverb_send_level = F2S(level); +} + +void amy_process_reverb_room(uint16_t room) { + if (room >= amy_global.config.max_reverb_rooms) return; + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + if (state->effect.rev == NULL || state->block == NULL) return; + uint64_t started = amy_global.config.reverb_diagnostics ? amy_get_us() : 0; + stereo_reverb_wet(state->effect.rev, state->block, + AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, + state->block, + AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, + AMY_BLOCK_SIZE, state->effect.level); + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&state->diagnostic_seq, &state->diagnostic, + (uint32_t)(amy_get_us() - started)); +} + +void amy_process_reverb_rooms(void) { + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) + amy_process_reverb_room(room); +} + +bool amy_reverb_diagnostics_get(uint16_t room, + amy_reverb_diagnostic_t *result) { + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) return false; + return reverb_diagnostic_snapshot( + &amy_global.reverb_rooms[room].diagnostic_seq, + &amy_global.reverb_rooms[room].diagnostic, result); +} + +bool amy_reverb_stage_diagnostics_get(amy_reverb_diagnostic_t *result) { + return reverb_diagnostic_snapshot(&reverb_stage_diagnostic_seq, + &reverb_stage_diagnostic, result); +} + +void amy_reverb_diagnostics_print(void) { + amy_reverb_diagnostic_t diagnostic; + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (!amy_reverb_diagnostics_get(room, &diagnostic)) continue; + fprintf(stderr, + "AMY reverb room %u: calls=%" PRIu64 " avg_us=%" PRIu64 + " max_us=%u deadline_misses=%u core_mask=0x%x arena=%zu/%zu\n", + room, diagnostic.calls, + diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, + diagnostic.max_us, diagnostic.deadline_misses, + diagnostic.core_mask, amy_global.reverb_rooms[room].arena_used, + amy_global.reverb_rooms[room].arena_bytes); + } + if (amy_reverb_stage_diagnostics_get(&diagnostic)) + fprintf(stderr, + "AMY reverb stage: calls=%" PRIu64 " avg_us=%" PRIu64 + " max_us=%u deadline_misses=%u core_mask=0x%x\n", + diagnostic.calls, + diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, + diagnostic.max_us, diagnostic.deadline_misses, + diagnostic.core_mask); } void config_reverb(uint16_t bus, float level, float liveness, float damping, float xover_hz) { @@ -498,6 +688,8 @@ void bus_reset(uint16_t bus) { amy_global.bus[bus]->dist_state[c].hold_count = 0; amy_global.bus[bus]->dist_state[c].hpf_yn1 = 0; } + amy_global.bus[bus]->reverb_send_room = AMY_REVERB_ROOM_NONE; + amy_global.bus[bus]->reverb_send_level = 0; if (AMY_HAS_CHORUS) config_chorus(bus, CHORUS_DEFAULT_LEVEL, CHORUS_DEFAULT_MAX_DELAY, CHORUS_DEFAULT_LFO_FREQ, CHORUS_DEFAULT_MOD_DEPTH); if (AMY_HAS_REVERB) config_reverb(bus, REVERB_DEFAULT_LEVEL, REVERB_DEFAULT_LIVENESS, REVERB_DEFAULT_DAMPING, REVERB_DEFAULT_XOVER_HZ); @@ -536,10 +728,31 @@ int8_t global_init(amy_config_t c) { amy_global.config.ram_caps_synth); amy_global.bus = (bus_state_t **)malloc_caps(sizeof(bus_state_t *) * amy_global.config.max_buses, amy_global.config.ram_caps_synth); - if (amy_global.volume == NULL || amy_global.volume_scale == NULL || amy_global.bus == NULL) { + amy_global.reverb_rooms = NULL; + if (amy_global.config.max_reverb_rooms > 0) + amy_global.reverb_rooms = (shared_reverb_state_t *)malloc_caps( + sizeof(shared_reverb_state_t) * amy_global.config.max_reverb_rooms, + amy_global.config.ram_caps_synth); + if (amy_global.volume == NULL || amy_global.volume_scale == NULL + || amy_global.bus == NULL + || (amy_global.config.max_reverb_rooms > 0 + && amy_global.reverb_rooms == NULL)) { fprintf(stderr, "unable to alloc %d buses\n", amy_global.config.max_buses); return -1; } + if (amy_global.reverb_rooms != NULL) + bzero(amy_global.reverb_rooms, + sizeof(shared_reverb_state_t) + * amy_global.config.max_reverb_rooms); + reverb_stage_diagnostic = (amy_reverb_diagnostic_t){0}; + reverb_stage_diagnostic_seq = 0; + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (!init_reverb_room(room)) { + for (uint16_t initialized = 0; initialized <= room; ++initialized) + deinit_reverb_room(&amy_global.reverb_rooms[initialized]); + return -1; + } + } for (int bus = 0; bus < amy_global.config.max_buses; ++bus) amy_global.volume[bus] = 1.0f; amy_global.pitch_bend = 0; @@ -584,13 +797,17 @@ int8_t global_init(amy_config_t c) { void global_deinit(void) { for (int bus = 0; bus < amy_global.config.max_buses; ++bus) filters_deinit(bus); + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) + deinit_reverb_room(&amy_global.reverb_rooms[room]); free(amy_global.bus[0]); // One allocation for every bus_state; bus[i] points into it. free(amy_global.bus); free(amy_global.volume_scale); free(amy_global.volume); + free(amy_global.reverb_rooms); amy_global.bus = NULL; amy_global.volume_scale = NULL; amy_global.volume = NULL; + amy_global.reverb_rooms = NULL; } // Drive rides a log2 rail, like freq and filter freq. The wire and the CONST @@ -784,6 +1001,16 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe d.time = e->time; if(AMY_IS_UNSET(e->time)) { d.time = 0; } + // Shared room configuration is global rather than bus- or osc-scoped. + // The room id rides delta.osc, matching how bus ids are carried below. + if (AMY_IS_SET(e->reverb_room)) { + d.osc = e->reverb_room; + EVENT_TO_DELTA_F(reverb_room_level, REVERB_ROOM_LEVEL) + EVENT_TO_DELTA_F(reverb_room_liveness, REVERB_ROOM_LIVENESS) + EVENT_TO_DELTA_F(reverb_room_damping, REVERB_ROOM_DAMPING) + EVENT_TO_DELTA_F(reverb_room_xover_hz, REVERB_ROOM_XOVER_HZ) + } + // If this is a bus-directed event, use d->osc to store the bus number instead. if (event_addresses_bus(e)) { // Store the target bus in d.osc. Either bus is specified, or synth is specified and has a bus, or default. @@ -809,6 +1036,8 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe EVENT_TO_DELTA_F(reverb_liveness, REVERB_LIVENESS) EVENT_TO_DELTA_F(reverb_damping, REVERB_DAMPING) EVENT_TO_DELTA_F(reverb_xover_hz, REVERB_XOVER_HZ) + EVENT_TO_DELTA_I(reverb_send_room, REVERB_SEND_ROOM) + EVENT_TO_DELTA_F(reverb_send_level, REVERB_SEND_LEVEL) // The distortion fields serve both scopes; naming no osc is what // puts them at bus scope. Only the CONST coef of drive and mix // reaches a bus - the modulation coefs need per-note sources a bus @@ -1876,6 +2105,12 @@ void play_delta(struct delta *d) { if(d->param == REVERB_LIVENESS) config_reverb(bus, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); if(d->param == REVERB_DAMPING) config_reverb(bus, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT); if(d->param == REVERB_XOVER_HZ) config_reverb(bus, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f); + if(d->param == REVERB_ROOM_LEVEL) config_reverb_room(d->osc, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_LIVENESS) config_reverb_room(d->osc, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_DAMPING) config_reverb_room(d->osc, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_XOVER_HZ) config_reverb_room(d->osc, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f); + if(d->param == REVERB_SEND_ROOM) config_reverb_send(bus, d->data.i, AMY_UNSET_FLOAT); + if(d->param == REVERB_SEND_LEVEL) config_reverb_send(bus, amy_global.bus[bus]->reverb_send_room, d->data.f); // Per-bus distortion: same range rules as the per-osc stage (clamped here // so dist_process_bus doesn't range-check per block). if(d->param == BUS_DIST_CLIP_EN) { @@ -2505,6 +2740,11 @@ int16_t * amy_fill_buffer() { // Apply global processing only if there is some signal. //if (max_val > 0) { // NO - see #629 // apply the eq filters if there is some signal and EQ is non-default. + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (amy_global.reverb_rooms[room].block != NULL) + bzero(amy_global.reverb_rooms[room].block, + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + } for (int bus=0; bus <= amy_global.highest_bus; ++bus) { // Per-bus distortion, first so echo/reverb take clean tails. if (amy_global.bus[bus]->dist.stages) { @@ -2537,6 +2777,18 @@ int16_t * amy_fill_buffer() { } } } + // Shared reverbs are post-fader aux sends. The source bus remains in + // the dry mix; only its scaled copy enters the selected room. + uint16_t room = amy_global.bus[bus]->reverb_send_room; + SAMPLE send = amy_global.bus[bus]->reverb_send_level; + if (room < amy_global.config.max_reverb_rooms && send != 0) { + SAMPLE gain = MUL8_SS(send, + MUL4_SS(F2S(0.1f), + F2S(amy_global.volume[bus]))); + SAMPLE *room_block = amy_global.reverb_rooms[room].block; + for (int16_t i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) + room_block[i] += MUL8_SS(gain, fbl[0][bus][i]); + } if(AMY_HAS_REVERB) { // apply per-bus reverb. if(amy_global.bus[bus]->reverb.level > 0 && amy_global.bus[bus]->reverb.rev != NULL && amy_global.bus[bus]->reverb.rev->delay_1 != NULL) { @@ -2566,6 +2818,20 @@ int16_t * amy_fill_buffer() { }, bus, fbl[0][bus], AMY_BLOCK_SIZE, AMY_NCHANS); #endif } // end of per-bus FX + + if (amy_global.config.max_reverb_rooms > 0) { + uint64_t reverb_stage_started = + amy_global.config.reverb_diagnostics ? amy_get_us() : 0; +#ifdef ESP_PLATFORM + amy_platform_process_reverb_rooms(); +#else + amy_process_reverb_rooms(); +#endif + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&reverb_stage_diagnostic_seq, + &reverb_stage_diagnostic, + (uint32_t)(amy_get_us() - reverb_stage_started)); + } // global volume is supposed to max out at 10, so scale by 0.1. SAMPLE *volume_scale = amy_global.volume_scale; // max_buses long, allocated at start. for (int bus = 0; bus <= amy_global.highest_bus; ++bus) @@ -2578,6 +2844,11 @@ int16_t * amy_fill_buffer() { // Convert the mixed sample into the int16 range, applying overall gain. fsample += MUL8_SS(volume_scale[bus], fbl[0][bus][i + c * AMY_BLOCK_SIZE]); } + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + SAMPLE *room_block = amy_global.reverb_rooms[room].block; + if (room_block != NULL) + fsample += room_block[i + c * AMY_BLOCK_SIZE]; + } // One-pole high-pass filter to remove large low-frequency excursions from // some FM patches. b = [1 -1]; a = [1 -0.995] diff --git a/src/amy.h b/src/amy.h index 40bbef68..3cf5cce2 100644 --- a/src/amy.h +++ b/src/amy.h @@ -149,6 +149,10 @@ extern void amy_set_gamma9001_pcm(const int16_t * data); #define AMY_DEFAULT_NUM_BUSES 4 #define AMY_DEFAULT_BUS 0 +// Shared reverbs are optional aux-send rooms. With max_reverb_rooms == 0, +// AMY retains its historical inline per-bus reverb behavior exactly. +#define AMY_REVERB_ROOM_NONE UINT16_MAX + // How many external CV inputs to contemplate. #define AMY_MAX_CV_IN 2 @@ -489,6 +493,12 @@ enum params{ REVERB_LIVENESS, REVERB_DAMPING, REVERB_XOVER_HZ, + REVERB_ROOM_LEVEL, + REVERB_ROOM_LIVENESS, + REVERB_ROOM_DAMPING, + REVERB_ROOM_XOVER_HZ, + REVERB_SEND_ROOM, + REVERB_SEND_LEVEL, // Per-bus distortion stage; bus in delta.osc like the params above. // Same per-stage enables as the per-osc stage, and the same event fields // feed both - which of the two an event reaches is its own scope, but the @@ -710,6 +720,16 @@ typedef struct amy_event { float reverb_liveness; float reverb_damping; float reverb_xover_hz; + // hRroom,level,liveness,damping,xover configures a shared room. + uint16_t reverb_room; + float reverb_room_level; + float reverb_room_liveness; + float reverb_room_damping; + float reverb_room_xover_hz; + // yBUS hSroom,level sends one bus to one shared room. A zero level is + // the explicit off state and does not disturb the room's existing tail. + uint16_t reverb_send_room; + float reverb_send_level; } amy_event; // Distortion stage. Split from synthinfo so the same shaper can run at any @@ -983,6 +1003,17 @@ typedef struct { uint32_t max_sequence_events; uint32_t max_sequence_executions; + // Optional shared reverb rooms. reverb_room_memory may point to + // max_reverb_rooms caller-owned arenas, each reverb_room_memory_bytes + // long. A NULL entry falls back to AMY's configured heaps. Supplying + // fixed arenas lets an embedded host reserve isolated SRAM banks. + uint16_t max_reverb_rooms; + void **reverb_room_memory; + size_t reverb_room_memory_bytes; + // Collect lock-free timing counters for later readout. Disabled by + // default so production builds pay no timer-read cost in the audio path. + uint8_t reverb_diagnostics; + } amy_config_t; typedef struct eq_state { @@ -998,6 +1029,10 @@ typedef struct reverb_params { SAMPLE lpfcoef; SAMPLE lpfgain; SAMPLE liveness; + // Heap-backed bus reverbs own both this object and their delay lines. + // Shared rooms may instead live entirely inside a caller-supplied arena. + uint8_t heap_owned; + uint8_t delay_lines_heap_owned; } reverb_params_t; typedef struct reverb_state { @@ -1008,6 +1043,27 @@ typedef struct reverb_state { reverb_params_t *rev; } reverb_state_t; +typedef struct amy_reverb_diagnostic { + uint64_t calls; + uint64_t total_us; + uint32_t max_us; + uint32_t deadline_misses; + uint32_t core_mask; +} amy_reverb_diagnostic_t; + +typedef struct shared_reverb_state { + reverb_state_t effect; + SAMPLE *block; // non-interleaved stereo send accumulator / wet return + void *arena; + size_t arena_bytes; + size_t arena_used; + uint8_t block_heap_owned; + // One realtime writer updates these counters; a low-priority reader uses + // diagnostic_seq as a sequence lock and never blocks the audio task. + volatile uint32_t diagnostic_seq; + amy_reverb_diagnostic_t diagnostic; +} shared_reverb_state_t; + typedef struct chorus_config { SAMPLE level; // How much of the delayed signal to mix in to the output, typ F2S(0.5). int32_t max_delay; // Max delay when modulating. Must be <= DELAY_LINE_LEN @@ -1032,6 +1088,8 @@ typedef struct bus_state { // State of fixed dc-blocking HPF eq_state_t eq; reverb_state_t reverb; + uint16_t reverb_send_room; + SAMPLE reverb_send_level; chorus_config_t chorus; echo_config_t echo; // Distortion, first in the bus FX chain; per-channel state per @@ -1084,6 +1142,10 @@ typedef struct global_state { // Per-bus output gain, recomputed each block from volume[]; max_buses entries. SAMPLE *volume_scale; + // Optional shared aux-return reverbs. Each room owns exactly one delay + // network and one block workspace, regardless of how many buses send it. + shared_reverb_state_t *reverb_rooms; + // Smoothed microseconds per render execution. uint32_t render_us; uint16_t overload_count; // Consecutive over-threshold blocks. @@ -1157,6 +1219,18 @@ void amy_oom(const char *fmt, ...); // Returns the bus, or AMY_DEFAULT_BUS (with a complaint) if it's out of range. uint16_t amy_validate_bus(int bus); void config_reverb(uint16_t bus, float level, float liveness, float damping, float xover_hz); +void config_reverb_room(uint16_t room, float level, float liveness, + float damping, float xover_hz); +void config_reverb_send(uint16_t bus, uint16_t room, float level); +void amy_process_reverb_room(uint16_t room); +void amy_process_reverb_rooms(void); +#ifdef ESP_PLATFORM +void amy_platform_process_reverb_rooms(void); +#endif +bool amy_reverb_diagnostics_get(uint16_t room, + amy_reverb_diagnostic_t *result); +bool amy_reverb_stage_diagnostics_get(amy_reverb_diagnostic_t *result); +void amy_reverb_diagnostics_print(void); void config_chorus(uint16_t bus, float level, uint16_t max_delay, float lfo_freq, float depth); void config_echo(uint16_t bus, float level, float delay_ms, float max_delay_ms, float feedback, float filter_coef); void osc_note_on(uint16_t osc, float initial_freq); diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index f8a7311e..fd6e21d7 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -48,6 +48,8 @@ var AMY_KW_MAP = { disk_sample: {wire: "zF", type: "L"}, algorithm: {wire: "o", type: "I"}, chorus: {wire: "k", type: "L"}, + reverb_room: {wire: "hR", type: "L"}, + reverb_send: {wire: "hS", type: "L"}, reverb: {wire: "h", type: "L"}, echo: {wire: "M", type: "L"}, patch: {wire: "K", type: "I"}, @@ -125,36 +127,38 @@ var AMY_KW_PRIORITY = { disk_sample: 41, algorithm: 42, chorus: 43, - reverb: 44, - echo: 45, - patch: 46, - sequence_reset: 47, - sequence_control: 48, - external_channel: 49, - portamento: 50, - tempo: 51, - sequencer_run: 52, - external_midi_sync: 53, - synth: 54, - pedal: 55, - synth_flags: 56, - num_voices: 57, - oscs_per_voice: 58, - synth_level: 59, - to_synth: 60, - grab_midi_notes: 61, - note_source_channel: 62, - synth_delay: 63, - preset: 64, - num_partials: 65, - start_sample: 66, - stop_sample: 67, - bus: 68, - mode: 69, - midi_cc: 70, - midi_note_cmd: 71, - cv_trigger: 72, - patch_string: 73 + reverb_room: 44, + reverb_send: 45, + reverb: 46, + echo: 47, + patch: 48, + sequence_reset: 49, + sequence_control: 50, + external_channel: 51, + portamento: 52, + tempo: 53, + sequencer_run: 54, + external_midi_sync: 55, + synth: 56, + pedal: 57, + synth_flags: 58, + num_voices: 59, + oscs_per_voice: 60, + synth_level: 61, + to_synth: 62, + grab_midi_notes: 63, + note_source_channel: 64, + synth_delay: 65, + preset: 66, + num_partials: 67, + start_sample: 68, + stop_sample: 69, + bus: 70, + mode: 71, + midi_cc: 72, + midi_note_cmd: 73, + cv_trigger: 74, + patch_string: 75 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; diff --git a/src/api.c b/src/api.c index daa68ee3..b7d40e9d 100644 --- a/src/api.c +++ b/src/api.c @@ -51,6 +51,10 @@ amy_config_t amy_default_config() { c.max_sequencer_tags = 256; c.max_sequence_events = 64; c.max_sequence_executions = 32; + c.max_reverb_rooms = 0; + c.reverb_room_memory = NULL; + c.reverb_room_memory_bytes = 0; + c.reverb_diagnostics = 0; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -206,6 +210,13 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->reverb_liveness); AMY_UNSET(e->reverb_damping); AMY_UNSET(e->reverb_xover_hz); + AMY_UNSET(e->reverb_room); + AMY_UNSET(e->reverb_room_level); + AMY_UNSET(e->reverb_room_liveness); + AMY_UNSET(e->reverb_room_damping); + AMY_UNSET(e->reverb_room_xover_hz); + AMY_UNSET(e->reverb_send_room); + AMY_UNSET(e->reverb_send_level); AMY_UNSET(e->oscs_per_voice); } diff --git a/src/delay.c b/src/delay.c index 33dd253c..a16345e5 100644 --- a/src/delay.c +++ b/src/delay.c @@ -199,12 +199,15 @@ void apply_fixed_delay(SAMPLE *block, delay_line_t *delay_line, uint32_t delay_s reverb_params_t *new_reverb() { reverb_params_t *rev = malloc_caps(sizeof(reverb_params_t), amy_global.config.ram_caps_synth); + if (rev == NULL) return NULL; bzero(rev, sizeof(reverb_params_t)); + rev->heap_owned = 1; + rev->delay_lines_heap_owned = 1; return rev; } void delete_reverb(reverb_params_t *rev) { - if(rev) free(rev); + if(rev && rev->heap_owned) free(rev); } void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossover_hz, float damping) { @@ -241,6 +244,7 @@ void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossove bool init_stereo_reverb(reverb_params_t *rev) { + if (rev == NULL) return false; if (rev->delay_1 != NULL) return true; // already initialised @@ -269,18 +273,107 @@ bool init_stereo_reverb(reverb_params_t *rev) { } void deinit_stereo_reverb(reverb_params_t *rev) { - if (rev->delay_1 != NULL) { - free(rev->delay_1); rev->delay_1 = NULL; - free(rev->delay_2); rev->delay_2 = NULL; - free(rev->delay_3); rev->delay_3 = NULL; - free(rev->delay_4); rev->delay_4 = NULL; - free(rev->ref_1); rev->ref_1 = NULL; - free(rev->ref_2); rev->ref_2 = NULL; - free(rev->ref_3); rev->ref_3 = NULL; - free(rev->ref_4); rev->ref_4 = NULL; - free(rev->ref_5); rev->ref_5 = NULL; - free(rev->ref_6); rev->ref_6 = NULL; - } + if (rev == NULL) return; +#define RELEASE_REVERB_LINE(FIELD) do { \ + if (rev->delay_lines_heap_owned && rev->FIELD != NULL) \ + free_delay_line(rev->FIELD); \ + rev->FIELD = NULL; \ + } while (0) + RELEASE_REVERB_LINE(delay_1); + RELEASE_REVERB_LINE(delay_2); + RELEASE_REVERB_LINE(delay_3); + RELEASE_REVERB_LINE(delay_4); + RELEASE_REVERB_LINE(ref_1); + RELEASE_REVERB_LINE(ref_2); + RELEASE_REVERB_LINE(ref_3); + RELEASE_REVERB_LINE(ref_4); + RELEASE_REVERB_LINE(ref_5); + RELEASE_REVERB_LINE(ref_6); +#undef RELEASE_REVERB_LINE +} + +typedef struct { + uint8_t *next; + uint8_t *end; +} reverb_arena_cursor_t; + +static void *reverb_arena_take(reverb_arena_cursor_t *cursor, size_t bytes, + size_t alignment) { + uintptr_t aligned = ((uintptr_t)cursor->next + alignment - 1) + & ~(uintptr_t)(alignment - 1); + if (aligned > (uintptr_t)cursor->end + || bytes > (size_t)((uintptr_t)cursor->end - aligned)) return NULL; + cursor->next = (uint8_t *)(aligned + bytes); + return (void *)aligned; +} + +static delay_line_t *reverb_arena_delay_line(reverb_arena_cursor_t *cursor, + int len, int fixed_delay) { + if (is_power_of_two(len) < 0) return NULL; + delay_line_t *line = reverb_arena_take( + cursor, sizeof(delay_line_t), _Alignof(delay_line_t)); + SAMPLE *samples = reverb_arena_take( + cursor, (size_t)len * sizeof(SAMPLE), _Alignof(SAMPLE)); + if (line == NULL || samples == NULL) return NULL; + *line = (delay_line_t){ + .samples = samples, + .len = len, + .log_2_len = is_power_of_two(len), + .fixed_delay = fixed_delay, + .next_in = 0, + }; + bzero(samples, (size_t)len * sizeof(SAMPLE)); + return line; +} + +reverb_params_t *new_reverb_in_arena(void *arena, size_t arena_bytes, + SAMPLE **workspace, size_t *used_bytes) { + if (workspace != NULL) *workspace = NULL; + if (used_bytes != NULL) *used_bytes = 0; + if (arena == NULL || arena_bytes == 0 || workspace == NULL) return NULL; + + reverb_arena_cursor_t cursor = { + .next = (uint8_t *)arena, + .end = (uint8_t *)arena + arena_bytes, + }; + reverb_params_t *rev = reverb_arena_take( + &cursor, sizeof(reverb_params_t), _Alignof(reverb_params_t)); + if (rev == NULL) return NULL; + bzero(rev, sizeof(*rev)); + + // Keep the block input/output beside the delay network. On banked SRAM + // targets this guarantees the complete hot working set belongs to the + // room's reserved arena rather than the general heap. + *workspace = reverb_arena_take( + &cursor, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + _Alignof(SAMPLE)); + if (*workspace == NULL) return NULL; + bzero(*workspace, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + +#define ARENA_REVERB_LINE(FIELD, LEN, DELAY) \ + do { \ + rev->FIELD = reverb_arena_delay_line(&cursor, (LEN), (DELAY)); \ + if (rev->FIELD == NULL) return NULL; \ + } while (0) + ARENA_REVERB_LINE(delay_1, DELAY_POW2, DELAY1SAMPS); + ARENA_REVERB_LINE(delay_2, DELAY_POW2, DELAY2SAMPS); + ARENA_REVERB_LINE(delay_3, DELAY_POW2, DELAY3SAMPS); + ARENA_REVERB_LINE(delay_4, DELAY_POW2, DELAY4SAMPS); + ARENA_REVERB_LINE(ref_1, 4096, REF1SAMPS); + ARENA_REVERB_LINE(ref_2, 2048, REF2SAMPS); + ARENA_REVERB_LINE(ref_3, 2048, REF3SAMPS); + ARENA_REVERB_LINE(ref_4, 1024, REF4SAMPS); + ARENA_REVERB_LINE(ref_5, 1024, REF5SAMPS); + ARENA_REVERB_LINE(ref_6, 1024, REF6SAMPS); +#undef ARENA_REVERB_LINE + + rev->heap_owned = 0; + rev->delay_lines_heap_owned = 0; + config_stereo_reverb( + rev, INITIAL_LIVENESS, INITIAL_XOVER_HZ, INITIAL_DAMPING); + if (used_bytes != NULL) + *used_bytes = (size_t)(cursor.next - (uint8_t *)arena); + return rev; } // Cache one delay line's state in locals for the reverb loop, the same way @@ -308,7 +401,9 @@ void deinit_stereo_reverb(reverb_params_t *rev) { #define DL_WRITE(P, val) do { P##_s[P##_n] = (val); P##_n = (P##_n + 1) & P##_m; } while (0) #define DL_READ(P) (P##_s[(P##_n - P##_f) & P##_m]) -void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, int n_samples, SAMPLE level) { +static void stereo_reverb_core(reverb_params_t *rev, SAMPLE *r_in, + SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, + int n_samples, SAMPLE level, bool include_dry) { // Stereo reverb. *{r,l}_in each point to n_samples input samples. // n_samples are written to {r,l}_out. // Recreate @@ -376,12 +471,13 @@ void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_o SAMPLE d1 = DL_READ(dl1); d1 = LPF(d1, &f1state, lpfcoef, lpfgain, liveness); d1 += r_acc; - *r_out++ = in_r + MUL8_SS(level, d1); + *r_out++ = (include_dry ? in_r : 0) + MUL8_SS(level, d1); SAMPLE d2 = DL_READ(dl2); d2 = LPF(d2, &f2state, lpfcoef, lpfgain, liveness); d2 += l_acc; - if (l_out != NULL) *l_out++ = in_l + MUL8_SS(level, d2); + if (l_out != NULL) + *l_out++ = (include_dry ? in_l : 0) + MUL8_SS(level, d2); SAMPLE d3 = DL_READ(dl3); d3 = LPF(d3, &f3state, lpfcoef, lpfgain, liveness); @@ -412,3 +508,17 @@ void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_o rev->f3state = f3state; rev->f4state = f4state; } + +void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level) { + stereo_reverb_core( + rev, r_in, l_in, r_out, l_out, n_samples, level, true); +} + +void stereo_reverb_wet(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level) { + stereo_reverb_core( + rev, r_in, l_in, r_out, l_out, n_samples, level, false); +} diff --git a/src/delay.h b/src/delay.h index d1dfdc4c..69162c45 100644 --- a/src/delay.h +++ b/src/delay.h @@ -14,10 +14,18 @@ void apply_variable_delay(SAMPLE *block, delay_line_t *delay_line, SAMPLE *delay void apply_fixed_delay(SAMPLE *block, delay_line_t *delay_line, uint32_t delay_samples, SAMPLE mix_level, SAMPLE feedback, SAMPLE filter_coef); reverb_params_t *new_reverb(); +// Construct a complete reverb plus its block workspace inside one fixed arena. +// No allocation from the general heap occurs. Returns NULL when the arena is +// too small; used_bytes reports the exact high-water mark on success. +reverb_params_t *new_reverb_in_arena(void *arena, size_t arena_bytes, + SAMPLE **workspace, size_t *used_bytes); void delete_reverb(reverb_params_t *rev); void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossover_hz, float damping); bool init_stereo_reverb(reverb_params_t *rev); void deinit_stereo_reverb(reverb_params_t *rev); void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, int n_samples, SAMPLE level); +void stereo_reverb_wet(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level); #endif // !_DELAY_H diff --git a/src/i2s.c b/src/i2s.c index 8d4c0662..b23175fb 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -278,6 +278,13 @@ TaskHandle_t amy_update_handle = NULL; // caller combine the worker's half-written buffer. static SemaphoreHandle_t esp_render_done_sem = NULL; +typedef enum { + AMY_WORKER_RENDER_OSCS = 0, + AMY_WORKER_REVERB_ROOM_0, +} amy_worker_job_t; + +static volatile amy_worker_job_t amy_worker_job = AMY_WORKER_RENDER_OSCS; + #ifdef AMY_ESP_LOAD_DIAGNOSTIC typedef struct { uint64_t window_started_us; @@ -332,7 +339,10 @@ static void esp_load_diagnostic_record(uint32_t execute_us, void esp_render_task( void * pvParameters) { while(1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // from esp_render_on_cores - amy_render(0, AMY_OSCS/2, 1); + if (amy_worker_job == AMY_WORKER_REVERB_ROOM_0) + amy_process_reverb_room(0); + else + amy_render(0, AMY_OSCS/2, 1); // Tell the caller we're done. xSemaphoreGive(esp_render_done_sem); // to esp_render_on_cores } @@ -342,6 +352,7 @@ void esp_render_on_cores() { // Call amy_render on all the oscs, using multicore if available. if (amy_global.config.platform.multicore) { // Tell the other core to start rendering. + amy_worker_job = AMY_WORKER_RENDER_OSCS; xTaskNotifyGive(amy_render_handle); // to esp_render_task // Render me amy_render(AMY_OSCS/2, AMY_OSCS, 0); @@ -353,6 +364,23 @@ void esp_render_on_cores() { } } +void amy_platform_process_reverb_rooms(void) { + uint16_t rooms = amy_global.config.max_reverb_rooms; + if (rooms == 0) return; + if (rooms >= 2 && amy_global.config.platform.multicore) { + // Reuse the already-pinned render worker after oscillator rendering: + // room 0 runs on core 0 while the fill task runs room 1 on core 1. + amy_worker_job = AMY_WORKER_REVERB_ROOM_0; + xTaskNotifyGive(amy_render_handle); + amy_process_reverb_room(1); + xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); + for (uint16_t room = 2; room < rooms; ++room) + amy_process_reverb_room(room); + } else { + amy_process_reverb_rooms(); + } +} + #ifdef I2S_32BIT static int32_t block32[AMY_BLOCK_SIZE * AMY_NCHANS]; #define I2S_BYTES_PER_SAMPLE 4 diff --git a/src/parse.c b/src/parse.c index b06cb4ff..2fea5d8c 100644 --- a/src/parse.c +++ b/src/parse.c @@ -515,6 +515,48 @@ int amy_parse_dist_layer_message(char *message, amy_event *e) { return 1; // skip the sub-command letter. } +// Parser for the reverb family. A numeric payload keeps the historical +// per-bus h command. hR addresses one shared room and +// hS addresses the send on the event's bus. Keeping these under h makes the +// wire protocol advertise one coherent effect rather than consuming unrelated +// top-level letters. +static int amy_parse_reverb_layer_message(char *message, amy_event *e) { + if (message[0] != 'R' && message[0] != 'S') { + float values[4]; + parse_list_float( + message, values, 4, AMY_UNSET_VALUE(e->reverb_level)); + e->reverb_level = values[0]; + e->reverb_liveness = values[1]; + e->reverb_damping = values[2]; + e->reverb_xover_hz = values[3]; + return 0; + } + + char command = *message++; + float values[5]; + parse_list_float(message, values, command == 'R' ? 5 : 2, + AMY_UNSET_FLOAT); + if (!isfinite(values[0]) || values[0] < 0.0f + || values[0] >= (float)AMY_REVERB_ROOM_NONE + || values[0] != floorf(values[0])) { + fprintf(stderr, "invalid shared reverb room: expected an integer 0..65534\n"); + return 1; + } + + uint16_t room = (uint16_t)values[0]; + if (command == 'R') { + e->reverb_room = room; + e->reverb_room_level = values[1]; + e->reverb_room_liveness = values[2]; + e->reverb_room_damping = values[3]; + e->reverb_room_xover_hz = values[4]; + } else { + e->reverb_send_room = room; + e->reverb_send_level = values[1]; + } + return 1; // skip R/S in the outer scanner +} + // Parse a sample-load parameter list ('z'/'zS' messages): comma-separated // unsigned integers, except the midinote field which may be fractional (e.g. // a sample tuned 4 cents sharp of C4 is "60.04"). parse_list_uint32_t cannot @@ -908,15 +950,10 @@ int amy_parse_message(char * message, amy_event *e) { /* g used for Alles for client # */ // 'H' is the ticks= schedule command, it's caught in amy_add_message before this. //case 'H': parse_list_uint32_t(arg, e->ticks, 3, 0); break; - case 'h': if (AMY_HAS_REVERB) { - float reverb_params[4]; - parse_list_float(arg, reverb_params, 4, AMY_UNSET_VALUE(e->reverb_level)); - e->reverb_level = reverb_params[0]; - e->reverb_liveness = reverb_params[1]; - e->reverb_damping = reverb_params[2]; - e->reverb_xover_hz = reverb_params[3]; - } - break; + case 'h': + if (AMY_HAS_REVERB) + pos += amy_parse_reverb_layer_message(arg, e); + break; /* i is used by alles for sync index -- but only for sync messages -- ok to use here but test */ case 'i': pos += amy_parse_synth_layer_message(arg, e); break; // Skip over second cmd letter, if any, or entire MIDI CC code string. case 'I': e->ratio = atoff(arg); break; diff --git a/src/patches.c b/src/patches.c index 63c07b5d..fb04d866 100644 --- a/src/patches.c +++ b/src/patches.c @@ -418,6 +418,49 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { _EPRINT_VALS_5(e->echo_level, e->echo_delay_ms, e->echo_max_delay_ms, e->echo_feedback, e->echo_filter_coef, "echo_{level,delay,max,fb,filt}", "M"); _EPRINT_VALS_5(e->chorus_level, e->chorus_max_delay, e->chorus_lfo_freq, e->chorus_depth, AMY_UNSET_FLOAT, "chorus_{level,delay,lfo,depth}", "k"); _EPRINT_VALS_5(e->reverb_level, e->reverb_liveness, e->reverb_damping, e->reverb_xover_hz, AMY_UNSET_FLOAT, "reverb_{level,live,damp,xover}", "h"); + if (AMY_IS_SET(e->reverb_room)) { + if (wirecode) { + snprintf(s, len - (size_t)(s - s_entry), "hR%u", e->reverb_room); + s += strlen(s); +#define APPEND_ROOM_FLOAT(FIELD) do { \ + snprintf(s, len - (size_t)(s - s_entry), ","); \ + s += strlen(s); \ + if (AMY_IS_SET(e->FIELD)) { \ + snprintfloat3dp(s, len - (size_t)(s - s_entry), e->FIELD); \ + s += strlen(s); \ + } \ + } while (0) + APPEND_ROOM_FLOAT(reverb_room_level); + APPEND_ROOM_FLOAT(reverb_room_liveness); + APPEND_ROOM_FLOAT(reverb_room_damping); + APPEND_ROOM_FLOAT(reverb_room_xover_hz); +#undef APPEND_ROOM_FLOAT + } else { + snprintf(s, len - (size_t)(s - s_entry), + "reverb_room=%u level=%f live=%f damp=%f xover=%f ", + e->reverb_room, e->reverb_room_level, + e->reverb_room_liveness, e->reverb_room_damping, + e->reverb_room_xover_hz); + s += strlen(s); + } + } + if (AMY_IS_SET(e->reverb_send_room)) { + if (wirecode) { + snprintf(s, len - (size_t)(s - s_entry), "hS%u,", + e->reverb_send_room); + s += strlen(s); + if (AMY_IS_SET(e->reverb_send_level)) { + snprintfloat3dp(s, len - (size_t)(s - s_entry), + e->reverb_send_level); + s += strlen(s); + } + } else { + snprintf(s, len - (size_t)(s - s_entry), + "reverb_send=%u,%f ", e->reverb_send_room, + e->reverb_send_level); + s += strlen(s); + } + } if (wirecode && (s - s_entry) > 0) { snprintf(s, len - (size_t)(s - s_entry), "Z"); s += strlen(s); } @@ -459,6 +502,8 @@ bool event_addresses_bus(amy_event *e) { _RET_TRUE_IF_5_F_SET(echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef); _RET_TRUE_IF_5_F_SET(chorus_level, chorus_max_delay, chorus_lfo_freq, chorus_depth, chorus_depth); _RET_TRUE_IF_5_F_SET(reverb_level, reverb_liveness, reverb_damping, reverb_xover_hz, reverb_xover_hz); + _RET_TRUE_IF_SET(reverb_send_room); + _RET_TRUE_IF_SET(reverb_send_level); // Distortion addresses a bus only when the event names no osc; naming one // makes the same fields osc-scope (see event_addresses_oscs). // Not _RET_TRUE_IF_5_F_SET: the int fields' unset sentinels cast to @@ -635,6 +680,12 @@ struct delta *deltas_to_event(struct delta *queue, struct amy_event *event) { _CASE_F(reverb_liveness, REVERB_LIVENESS) _CASE_F(reverb_damping, REVERB_DAMPING) _CASE_F(reverb_xover_hz, REVERB_XOVER_HZ) + case REVERB_ROOM_LEVEL: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_level = queue->data.f; break; + case REVERB_ROOM_LIVENESS: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_liveness = queue->data.f; break; + case REVERB_ROOM_DAMPING: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_damping = queue->data.f; break; + case REVERB_ROOM_XOVER_HZ: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_xover_hz = queue->data.f; break; + case REVERB_SEND_ROOM: event->bus = queue->osc; AMY_UNSET(event->osc); event->reverb_send_room = queue->data.i; break; + case REVERB_SEND_LEVEL: event->bus = queue->osc; AMY_UNSET(event->osc); event->reverb_send_level = queue->data.f; break; // Bus distortion comes back through the same event fields the per-osc // stage uses; the event's own osc says which scope it will be read at // on the way back in, exactly as it does for VOLUME below. @@ -847,6 +898,11 @@ void set_event_for_bus_fx(amy_event *event, uint16_t bus, global_state_t *state) event->reverb_liveness = state->bus[bus]->reverb.liveness; event->reverb_damping = state->bus[bus]->reverb.damping; event->reverb_xover_hz = state->bus[bus]->reverb.xover_hz; + if (state->bus[bus]->reverb_send_room != AMY_REVERB_ROOM_NONE) { + event->reverb_send_room = state->bus[bus]->reverb_send_room; + event->reverb_send_level = + S2F(state->bus[bus]->reverb_send_level); + } // Chorus event->chorus_level = S2F(state->bus[bus]->chorus.level); event->chorus_max_delay = state->bus[bus]->chorus.max_delay; @@ -874,6 +930,15 @@ void set_event_for_bus_fx(amy_event *event, uint16_t bus, global_state_t *state) } } +static void set_event_for_reverb_room(amy_event *event, uint16_t room, + global_state_t *state) { + event->reverb_room = room; + event->reverb_room_level = S2F(state->reverb_rooms[room].effect.level); + event->reverb_room_liveness = state->reverb_rooms[room].effect.liveness; + event->reverb_room_damping = state->reverb_rooms[room].effect.damping; + event->reverb_room_xover_hz = state->reverb_rooms[room].effect.xover_hz; +} + int num_oscs_for_voice(int voice) { uint16_t osc = voice_to_base_osc[voice]; @@ -975,17 +1040,26 @@ void *yield_synth_commands(uint8_t instr_num, char *s, size_t len, bool include_ void *yield_bus_commands(char *s, size_t len, void *state) { - // Like yield_synth_commands, returns just the commands for the FX + // Like yield_synth_commands, returns bus FX followed by shared room + // configuration so a state dump can restore the complete mix graph. int state_val = (intptr_t)state; - if (state_val > amy_global.highest_bus) { + int bus_count = amy_global.highest_bus + 1; + int end = bus_count + amy_global.config.max_reverb_rooms; + if (state_val >= end) { state_val = 0; - } else { + } else if (state_val < bus_count) { // Return a wire command to set up a bus. uint16_t bus = state_val; amy_event e = amy_default_event(); set_event_for_bus_fx(&e, bus, &amy_global); sprint_event(&e, s, len, /* wirecode= */ true); ++state_val; + } else { + uint16_t room = state_val - bus_count; + amy_event e = amy_default_event(); + set_event_for_reverb_room(&e, room, &amy_global); + sprint_event(&e, s, len, /* wirecode= */ true); + ++state_val; } return (void *)(intptr_t)state_val; } diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c new file mode 100644 index 00000000..6d0a45d2 --- /dev/null +++ b/tests/test_shared_reverb.c @@ -0,0 +1,118 @@ +// Shared aux-reverb routing, fixed arenas, and deferred diagnostics. + +#include +#include +#include +#include "amy.h" + +#define ROOM_BYTES (128u * 1024u) + +static int failures; +static uint8_t room_memory[2][ROOM_BYTES]; +static void *room_arenas[2] = { room_memory[0], room_memory[1] }; + +#define CHECK(c, fmt, ...) do { \ + if (c) printf(" ok " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); ++failures; } \ +} while (0) + +static bool inside_room(const void *pointer, int room) { + uintptr_t p = (uintptr_t)pointer; + uintptr_t first = (uintptr_t)room_memory[room]; + return p >= first && p < first + ROOM_BYTES; +} + +static void start_shared(void) { + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_buses = 4; + config.max_reverb_rooms = 2; + config.reverb_room_memory = room_arenas; + config.reverb_room_memory_bytes = ROOM_BYTES; + config.reverb_diagnostics = 1; + amy_start(config); +} + +static void test_arena_and_wire_routing(void) { + puts("fixed rooms and hR/hS routing"); + start_shared(); + for (int room = 0; room < 2; ++room) { + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + CHECK(state->arena == room_memory[room], "room %d uses its arena", room); + CHECK(state->arena_used > 108u * 1024u && state->arena_used < ROOM_BYTES, + "room %d fits (%zu/%u bytes)", room, state->arena_used, ROOM_BYTES); + CHECK(inside_room(state->effect.rev, room), "room %d state is contained", room); + CHECK(inside_room(state->block, room), "room %d workspace is contained", room); + CHECK(inside_room(state->effect.rev->delay_1->samples, room), + "room %d delay data is contained", room); + } + + amy_add_message("hR0,0.6,0.8,0.4,2800Z"); + amy_add_message("hR1,0.3,0.7,0.2,3500Z"); + amy_add_message("y2hS1,0.75Z"); + amy_execute_deltas(); + CHECK(S2F(amy_global.reverb_rooms[0].effect.level) > 0.59f, + "room 0 level configured"); + CHECK(amy_global.reverb_rooms[1].effect.xover_hz == 3500.0f, + "room 1 filter configured"); + CHECK(amy_global.bus[2]->reverb_send_room == 1, "bus 2 targets room 1"); + CHECK(S2F(amy_global.bus[2]->reverb_send_level) > 0.74f, + "bus 2 has a weighted send"); + amy_add_message("y2hS1,0Z"); + amy_execute_deltas(); + CHECK(amy_global.bus[2]->reverb_send_level == 0, + "zero send excludes a bus without changing its room"); +} + +static void test_audio_and_deferred_diagnostics(void) { + puts("audio path and stored diagnostics"); + start_shared(); + amy_add_message("hR0,0.8,0.85,0.5,3000Zy0hS0,1Zv0w0n60l1Z"); + for (int i = 0; i < 48; ++i) amy_simple_fill_buffer(); + + amy_reverb_diagnostic_t room, stage; + CHECK(amy_reverb_diagnostics_get(0, &room), "room snapshot succeeds"); + CHECK(amy_reverb_stage_diagnostics_get(&stage), "stage snapshot succeeds"); + CHECK(room.calls == 48, "room measured once per rendered block (%llu)", + (unsigned long long)room.calls); + CHECK(stage.calls == 48, "stage measured once per rendered block (%llu)", + (unsigned long long)stage.calls); + CHECK(room.core_mask == 1, "host room ran on its one render core"); + + bool wet_nonzero = false; + SAMPLE *wet = amy_global.reverb_rooms[0].block; + for (int i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) + if (wet[i] != 0) wet_nonzero = true; + CHECK(wet_nonzero, "shared room produced a wet return"); +} + +static void test_legacy_default(void) { + puts("legacy per-bus behavior remains the default"); + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + CHECK(amy_global.config.max_reverb_rooms == 0, "shared rooms default off"); + amy_add_message("y0h0.5,0.8,0.4,3000Z"); + amy_execute_deltas(); + CHECK(amy_global.bus[0]->reverb.rev != NULL, + "historical h command still allocates a per-bus reverb"); + CHECK(S2F(amy_global.bus[0]->reverb.level) > 0.49f, + "historical h level is unchanged"); +} + +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + test_arena_and_wire_routing(); + test_audio_and_deferred_diagnostics(); + test_legacy_default(); + amy_stop(); + if (failures) return 1; + puts("all shared reverb checks passed"); + return 0; +} From 21828d8f89d370ff46737197e4b7aeed0dc58fc0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 14:56:53 +0200 Subject: [PATCH 083/112] Store ESP load diagnostics for deferred readout --- src/amy.h | 16 +++++++++++++ src/i2s.c | 68 ++++++++++++++++++++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/amy.h b/src/amy.h index 3cf5cce2..a1be1e03 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1051,6 +1051,18 @@ typedef struct amy_reverb_diagnostic { uint32_t core_mask; } amy_reverb_diagnostic_t; +typedef struct amy_esp_load_diagnostic { + uint64_t execute_sum_us; + uint64_t render_sum_us; + uint64_t fill_sum_us; + uint64_t total_sum_us; + uint32_t execute_max_us; + uint32_t render_max_us; + uint32_t fill_max_us; + uint32_t total_max_us; + uint32_t blocks; +} amy_esp_load_diagnostic_t; + typedef struct shared_reverb_state { reverb_state_t effect; SAMPLE *block; // non-interleaved stereo send accumulator / wet return @@ -1231,6 +1243,10 @@ bool amy_reverb_diagnostics_get(uint16_t room, amy_reverb_diagnostic_t *result); bool amy_reverb_stage_diagnostics_get(amy_reverb_diagnostic_t *result); void amy_reverb_diagnostics_print(void); +#ifdef ESP_PLATFORM +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result); +void amy_esp_load_diagnostics_print(void); +#endif void config_chorus(uint16_t bus, float level, uint16_t max_delay, float lfo_freq, float depth); void config_echo(uint16_t bus, float level, float delay_ms, float max_delay_ms, float feedback, float filter_coef); void osc_note_on(uint16_t osc, float initial_freq); diff --git a/src/i2s.c b/src/i2s.c index b23175fb..72a04b9c 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -286,26 +286,16 @@ typedef enum { static volatile amy_worker_job_t amy_worker_job = AMY_WORKER_RENDER_OSCS; #ifdef AMY_ESP_LOAD_DIAGNOSTIC -typedef struct { - uint64_t window_started_us; - uint64_t execute_sum_us; - uint64_t render_sum_us; - uint64_t fill_sum_us; - uint64_t total_sum_us; - uint32_t execute_max_us; - uint32_t render_max_us; - uint32_t fill_max_us; - uint32_t total_max_us; - uint32_t blocks; -} esp_load_diagnostic_t; - -static esp_load_diagnostic_t esp_load_diagnostic; +static amy_esp_load_diagnostic_t esp_load_diagnostic; +static volatile uint32_t esp_load_diagnostic_seq; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, uint32_t fill_us, uint32_t total_us) { - esp_load_diagnostic_t *stats = &esp_load_diagnostic; + amy_esp_load_diagnostic_t *stats = &esp_load_diagnostic; + ++esp_load_diagnostic_seq; + __sync_synchronize(); stats->execute_sum_us += execute_us; stats->render_sum_us += render_us; stats->fill_sum_us += fill_us; @@ -315,23 +305,49 @@ static void esp_load_diagnostic_record(uint32_t execute_us, if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; if (total_us > stats->total_max_us) stats->total_max_us = total_us; ++stats->blocks; + __sync_synchronize(); + ++esp_load_diagnostic_seq; +} - uint64_t now_us = amy_get_us(); - if (stats->window_started_us == 0) stats->window_started_us = now_us; - if (now_us - stats->window_started_us < 2000000 || stats->blocks == 0) return; +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { + if (result == NULL) return false; + for (int attempt = 0; attempt < 8; ++attempt) { + uint32_t before = esp_load_diagnostic_seq; + if (before & 1u) continue; + __sync_synchronize(); + *result = esp_load_diagnostic; + __sync_synchronize(); + if (before == esp_load_diagnostic_seq) return true; + } + return false; +} - uint32_t blocks = stats->blocks; +void amy_esp_load_diagnostics_print(void) { + amy_esp_load_diagnostic_t stats; + if (!amy_esp_load_diagnostics_get(&stats) || stats.blocks == 0) { + fprintf(stderr, "AMY ESP load: no samples\n"); + return; + } + uint32_t blocks = stats.blocks; fprintf(stderr, "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " "max_us execute=%u render=%u fill=%u total=%u\n", (unsigned)blocks, - (unsigned)(stats->execute_sum_us / blocks), - (unsigned)(stats->render_sum_us / blocks), - (unsigned)(stats->fill_sum_us / blocks), - (unsigned)(stats->total_sum_us / blocks), - (unsigned)stats->execute_max_us, (unsigned)stats->render_max_us, - (unsigned)stats->fill_max_us, (unsigned)stats->total_max_us); - *stats = (esp_load_diagnostic_t){ .window_started_us = now_us }; + (unsigned)(stats.execute_sum_us / blocks), + (unsigned)(stats.render_sum_us / blocks), + (unsigned)(stats.fill_sum_us / blocks), + (unsigned)(stats.total_sum_us / blocks), + (unsigned)stats.execute_max_us, (unsigned)stats.render_max_us, + (unsigned)stats.fill_max_us, (unsigned)stats.total_max_us); +} +#else +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { + if (result != NULL) *result = (amy_esp_load_diagnostic_t){0}; + return false; +} + +void amy_esp_load_diagnostics_print(void) { + fprintf(stderr, "AMY ESP load diagnostics were not compiled in\n"); } #endif From 594b5f32c9b2e7fe47df2d8b1123fbbc8453af6b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 15:00:02 +0200 Subject: [PATCH 084/112] Use portable reverb diagnostic formats --- src/amy.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/amy.c b/src/amy.c index a74ebb29..eeece195 100644 --- a/src/amy.c +++ b/src/amy.c @@ -586,7 +586,8 @@ void amy_reverb_diagnostics_print(void) { if (!amy_reverb_diagnostics_get(room, &diagnostic)) continue; fprintf(stderr, "AMY reverb room %u: calls=%" PRIu64 " avg_us=%" PRIu64 - " max_us=%u deadline_misses=%u core_mask=0x%x arena=%zu/%zu\n", + " max_us=%" PRIu32 " deadline_misses=%" PRIu32 + " core_mask=0x%" PRIx32 " arena=%zu/%zu\n", room, diagnostic.calls, diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, diagnostic.max_us, diagnostic.deadline_misses, @@ -596,7 +597,8 @@ void amy_reverb_diagnostics_print(void) { if (amy_reverb_stage_diagnostics_get(&diagnostic)) fprintf(stderr, "AMY reverb stage: calls=%" PRIu64 " avg_us=%" PRIu64 - " max_us=%u deadline_misses=%u core_mask=0x%x\n", + " max_us=%" PRIu32 " deadline_misses=%" PRIu32 + " core_mask=0x%" PRIx32 "\n", diagnostic.calls, diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, diagnostic.max_us, diagnostic.deadline_misses, From 789af46b478ca0eeec9faadb13c51d6a3adacf03 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 15:09:22 +0200 Subject: [PATCH 085/112] Skip disabled shared reverb processing --- src/amy.c | 4 ++++ tests/test_shared_reverb.c | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/amy.c b/src/amy.c index eeece195..8e59df47 100644 --- a/src/amy.c +++ b/src/amy.c @@ -550,6 +550,10 @@ void amy_process_reverb_room(uint16_t room) { if (room >= amy_global.config.max_reverb_rooms) return; shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; if (state->effect.rev == NULL || state->block == NULL) return; + // A disabled return cannot contribute to the mix. Avoid walking all of + // its delay memory, but keep processing an enabled room through silent + // input so an existing tail decays naturally. + if (state->effect.level == 0) return; uint64_t started = amy_global.config.reverb_diagnostics ? amy_get_us() : 0; stereo_reverb_wet(state->effect.rev, state->block, AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c index 6d0a45d2..b432663f 100644 --- a/tests/test_shared_reverb.c +++ b/tests/test_shared_reverb.c @@ -68,15 +68,22 @@ static void test_arena_and_wire_routing(void) { static void test_audio_and_deferred_diagnostics(void) { puts("audio path and stored diagnostics"); start_shared(); + + // Configured storage is cheap while its return level is disabled: it must + // not walk the delay lines merely because a room exists. + for (int i = 0; i < 2; ++i) amy_simple_fill_buffer(); + amy_reverb_diagnostic_t room, stage; + CHECK(amy_reverb_diagnostics_get(0, &room), "disabled-room snapshot succeeds"); + CHECK(room.calls == 0, "disabled room performs no DSP work"); + amy_add_message("hR0,0.8,0.85,0.5,3000Zy0hS0,1Zv0w0n60l1Z"); for (int i = 0; i < 48; ++i) amy_simple_fill_buffer(); - amy_reverb_diagnostic_t room, stage; CHECK(amy_reverb_diagnostics_get(0, &room), "room snapshot succeeds"); CHECK(amy_reverb_stage_diagnostics_get(&stage), "stage snapshot succeeds"); CHECK(room.calls == 48, "room measured once per rendered block (%llu)", (unsigned long long)room.calls); - CHECK(stage.calls == 48, "stage measured once per rendered block (%llu)", + CHECK(stage.calls == 50, "stage measured once per rendered block (%llu)", (unsigned long long)stage.calls); CHECK(room.core_mask == 1, "host room ran on its one render core"); From 355e8dd66408259cb4721d5c0696149ab094df5e Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 15:39:17 +0200 Subject: [PATCH 086/112] Expose shared reverb capacity to CPython hosts --- README.md | 3 ++- src/pyamy.c | 9 +++++++++ tests/test_python_offline_live.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 486599e6..6678ff46 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ In Python: `amy.live(audio=False, ...)` applies the same runtime configuration without starting a system-audio callback. This is intended for deterministic offline rendering with `c_amy.render_to_list()`; omitting `audio` retains the existing -live-audio behavior. +live-audio behavior. Runtime allocation options such as `max_buses`, +`max_reverb_rooms`, and the sequencer limits use the same keyword interface. In C: diff --git a/src/pyamy.c b/src/pyamy.c index d7152369..d31ff7fe 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -84,6 +84,15 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_buses = (uint16_t)lv; return 0; + } else if (strcmp(key, "max_reverb_rooms") == 0) { + lv = PyLong_AsLong(value); + if (PyErr_Occurred()) return -1; + if (lv < 0 || lv > UINT16_MAX) { + PyErr_SetString(PyExc_ValueError, "max_reverb_rooms must be in range [0, 65535]"); + return -1; + } + cfg->max_reverb_rooms = (uint16_t)lv; + return 0; } else if (strcmp(key, "ks_oscs") == 0) { lv = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; diff --git a/tests/test_python_offline_live.py b/tests/test_python_offline_live.py index 66261124..6945d617 100644 --- a/tests/test_python_offline_live.py +++ b/tests/test_python_offline_live.py @@ -16,6 +16,7 @@ def main() -> int: max_sequencer_tags=1280, max_sequence_events=64, max_sequence_executions=40, + max_reverb_rooms=2, ) before = amy.ticks_ms() @@ -28,6 +29,8 @@ def main() -> int: ) amy.send(osc=0, wave=amy.SINE, freq=440, vel=1) + amy.send(reverb_room=[1, 0.35, 0.8, 0.5, 3000]) + amy.send(bus=0, reverb_send=[1, 0.5]) peak = 0 for _ in range(8): block = c_amy.render_to_list() @@ -41,6 +44,20 @@ def main() -> int: # engine sizing instead of falling back to the import-time defaults. amy.define_sequence(1000, [dict(ticks=(0,), osc=0, vel=0)]) amy.send(sequence_control=(1000, amy.SEQUENCE_CONTROL_START)) + + # CPython validates this runtime allocation dimension before stopping an + # already-running engine, just like the other live() sizing arguments. + try: + c_amy.live(audio=False, max_reverb_rooms=-1) + except ValueError as exc: + if "max_reverb_rooms" not in str(exc): + raise AssertionError(f"unclear shared-reverb validation: {exc}") from exc + else: + raise AssertionError("negative max_reverb_rooms was accepted") + + # The rejected call above must not have stopped or replaced this engine. + if not c_amy.render_to_list(): + raise AssertionError("rejected live() call stopped the current engine") return 0 From a338d5a8242ecd0591647e79346328c90e06c2ba Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 17:15:57 +0200 Subject: [PATCH 087/112] Report ESP audio deadline pressure by interval --- src/amy.h | 2 ++ src/i2s.c | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/amy.h b/src/amy.h index a1be1e03..4522f0dc 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1060,6 +1060,8 @@ typedef struct amy_esp_load_diagnostic { uint32_t render_max_us; uint32_t fill_max_us; uint32_t total_max_us; + uint32_t total_near_deadline; + uint32_t total_deadline_misses; uint32_t blocks; } amy_esp_load_diagnostic_t; diff --git a/src/i2s.c b/src/i2s.c index 72a04b9c..caafb9c6 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -288,6 +288,7 @@ static volatile amy_worker_job_t amy_worker_job = AMY_WORKER_RENDER_OSCS; #ifdef AMY_ESP_LOAD_DIAGNOSTIC static amy_esp_load_diagnostic_t esp_load_diagnostic; static volatile uint32_t esp_load_diagnostic_seq; +static amy_esp_load_diagnostic_t esp_load_print_baseline; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, @@ -304,6 +305,9 @@ static void esp_load_diagnostic_record(uint32_t execute_us, if (render_us > stats->render_max_us) stats->render_max_us = render_us; if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; if (total_us > stats->total_max_us) stats->total_max_us = total_us; + if (total_us >= (AMY_BLOCK_US * 9u) / 10u) + ++stats->total_near_deadline; + if (total_us > AMY_BLOCK_US) ++stats->total_deadline_misses; ++stats->blocks; __sync_synchronize(); ++esp_load_diagnostic_seq; @@ -329,16 +333,46 @@ void amy_esp_load_diagnostics_print(void) { return; } uint32_t blocks = stats.blocks; + uint32_t interval_blocks = blocks - esp_load_print_baseline.blocks; + uint64_t interval_execute_us = + stats.execute_sum_us - esp_load_print_baseline.execute_sum_us; + uint64_t interval_render_us = + stats.render_sum_us - esp_load_print_baseline.render_sum_us; + uint64_t interval_fill_us = + stats.fill_sum_us - esp_load_print_baseline.fill_sum_us; + uint64_t interval_total_us = + stats.total_sum_us - esp_load_print_baseline.total_sum_us; + uint32_t interval_near = + stats.total_near_deadline - esp_load_print_baseline.total_near_deadline; + uint32_t interval_misses = + stats.total_deadline_misses + - esp_load_print_baseline.total_deadline_misses; fprintf(stderr, "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " - "max_us execute=%u render=%u fill=%u total=%u\n", + "max_us execute=%u render=%u fill=%u total=%u " + "near_deadline=%u deadline_misses=%u " + "interval_blocks=%u " + "interval_avg_us execute=%u render=%u fill=%u total=%u " + "interval_near_deadline=%u interval_deadline_misses=%u\n", (unsigned)blocks, (unsigned)(stats.execute_sum_us / blocks), (unsigned)(stats.render_sum_us / blocks), (unsigned)(stats.fill_sum_us / blocks), (unsigned)(stats.total_sum_us / blocks), (unsigned)stats.execute_max_us, (unsigned)stats.render_max_us, - (unsigned)stats.fill_max_us, (unsigned)stats.total_max_us); + (unsigned)stats.fill_max_us, (unsigned)stats.total_max_us, + (unsigned)stats.total_near_deadline, + (unsigned)stats.total_deadline_misses, + (unsigned)interval_blocks, + (unsigned)(interval_blocks + ? interval_execute_us / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_render_us / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_fill_us / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_total_us / interval_blocks : 0), + (unsigned)interval_near, (unsigned)interval_misses); + esp_load_print_baseline = stats; } #else bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { From d3676ce0b68d11e6ef362671ae015d1af8690996 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 18:08:53 +0200 Subject: [PATCH 088/112] Attribute ESP deadline misses to render stages --- src/amy.h | 9 +++++++ src/i2s.c | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/amy.h b/src/amy.h index 4522f0dc..92987e12 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1054,14 +1054,23 @@ typedef struct amy_reverb_diagnostic { typedef struct amy_esp_load_diagnostic { uint64_t execute_sum_us; uint64_t render_sum_us; + uint64_t render_core_sum_us[2]; uint64_t fill_sum_us; uint64_t total_sum_us; uint32_t execute_max_us; uint32_t render_max_us; + uint32_t render_core_max_us[2]; uint32_t fill_max_us; uint32_t total_max_us; uint32_t total_near_deadline; uint32_t total_deadline_misses; + uint64_t missed_execute_sum_us; + uint64_t missed_render_sum_us; + uint64_t missed_fill_sum_us; + uint64_t missed_total_sum_us; + uint32_t missed_execute_max_us; + uint32_t missed_render_max_us; + uint32_t missed_fill_max_us; uint32_t blocks; } amy_esp_load_diagnostic_t; diff --git a/src/i2s.c b/src/i2s.c index caafb9c6..cca0f648 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -289,6 +289,7 @@ static volatile amy_worker_job_t amy_worker_job = AMY_WORKER_RENDER_OSCS; static amy_esp_load_diagnostic_t esp_load_diagnostic; static volatile uint32_t esp_load_diagnostic_seq; static amy_esp_load_diagnostic_t esp_load_print_baseline; +static volatile uint32_t esp_render_core_us[2]; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, @@ -299,6 +300,12 @@ static void esp_load_diagnostic_record(uint32_t execute_us, __sync_synchronize(); stats->execute_sum_us += execute_us; stats->render_sum_us += render_us; + for (uint8_t core = 0; core < 2; ++core) { + uint32_t core_us = esp_render_core_us[core]; + stats->render_core_sum_us[core] += core_us; + if (core_us > stats->render_core_max_us[core]) + stats->render_core_max_us[core] = core_us; + } stats->fill_sum_us += fill_us; stats->total_sum_us += total_us; if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; @@ -307,7 +314,19 @@ static void esp_load_diagnostic_record(uint32_t execute_us, if (total_us > stats->total_max_us) stats->total_max_us = total_us; if (total_us >= (AMY_BLOCK_US * 9u) / 10u) ++stats->total_near_deadline; - if (total_us > AMY_BLOCK_US) ++stats->total_deadline_misses; + if (total_us > AMY_BLOCK_US) { + ++stats->total_deadline_misses; + stats->missed_execute_sum_us += execute_us; + stats->missed_render_sum_us += render_us; + stats->missed_fill_sum_us += fill_us; + stats->missed_total_sum_us += total_us; + if (execute_us > stats->missed_execute_max_us) + stats->missed_execute_max_us = execute_us; + if (render_us > stats->missed_render_max_us) + stats->missed_render_max_us = render_us; + if (fill_us > stats->missed_fill_max_us) + stats->missed_fill_max_us = fill_us; + } ++stats->blocks; __sync_synchronize(); ++esp_load_diagnostic_seq; @@ -338,6 +357,12 @@ void amy_esp_load_diagnostics_print(void) { stats.execute_sum_us - esp_load_print_baseline.execute_sum_us; uint64_t interval_render_us = stats.render_sum_us - esp_load_print_baseline.render_sum_us; + uint64_t interval_render_core_us[2] = { + stats.render_core_sum_us[0] + - esp_load_print_baseline.render_core_sum_us[0], + stats.render_core_sum_us[1] + - esp_load_print_baseline.render_core_sum_us[1], + }; uint64_t interval_fill_us = stats.fill_sum_us - esp_load_print_baseline.fill_sum_us; uint64_t interval_total_us = @@ -347,13 +372,29 @@ void amy_esp_load_diagnostics_print(void) { uint32_t interval_misses = stats.total_deadline_misses - esp_load_print_baseline.total_deadline_misses; + uint64_t interval_missed_execute_us = + stats.missed_execute_sum_us + - esp_load_print_baseline.missed_execute_sum_us; + uint64_t interval_missed_render_us = + stats.missed_render_sum_us + - esp_load_print_baseline.missed_render_sum_us; + uint64_t interval_missed_fill_us = + stats.missed_fill_sum_us + - esp_load_print_baseline.missed_fill_sum_us; + uint64_t interval_missed_total_us = + stats.missed_total_sum_us + - esp_load_print_baseline.missed_total_sum_us; fprintf(stderr, "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " "max_us execute=%u render=%u fill=%u total=%u " "near_deadline=%u deadline_misses=%u " "interval_blocks=%u " "interval_avg_us execute=%u render=%u fill=%u total=%u " - "interval_near_deadline=%u interval_deadline_misses=%u\n", + "interval_near_deadline=%u interval_deadline_misses=%u " + "miss_avg_us execute=%u render=%u fill=%u total=%u " + "miss_stage_max_us execute=%u render=%u fill=%u " + "interval_render_core_avg_us core0=%u core1=%u " + "render_core_max_us core0=%u core1=%u\n", (unsigned)blocks, (unsigned)(stats.execute_sum_us / blocks), (unsigned)(stats.render_sum_us / blocks), @@ -371,7 +412,24 @@ void amy_esp_load_diagnostics_print(void) { (unsigned)(interval_blocks ? interval_fill_us / interval_blocks : 0), (unsigned)(interval_blocks ? interval_total_us / interval_blocks : 0), - (unsigned)interval_near, (unsigned)interval_misses); + (unsigned)interval_near, (unsigned)interval_misses, + (unsigned)(interval_misses + ? interval_missed_execute_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_render_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_fill_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_total_us / interval_misses : 0), + (unsigned)stats.missed_execute_max_us, + (unsigned)stats.missed_render_max_us, + (unsigned)stats.missed_fill_max_us, + (unsigned)(interval_blocks + ? interval_render_core_us[0] / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_render_core_us[1] / interval_blocks : 0), + (unsigned)stats.render_core_max_us[0], + (unsigned)stats.render_core_max_us[1]); esp_load_print_baseline = stats; } #else @@ -391,8 +449,16 @@ void esp_render_task( void * pvParameters) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // from esp_render_on_cores if (amy_worker_job == AMY_WORKER_REVERB_ROOM_0) amy_process_reverb_room(0); - else + else { +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t started = amy_get_us(); +#endif amy_render(0, AMY_OSCS/2, 1); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_render_core_us[xPortGetCoreID()] = + (uint32_t)(amy_get_us() - started); +#endif + } // Tell the caller we're done. xSemaphoreGive(esp_render_done_sem); // to esp_render_on_cores } @@ -405,7 +471,14 @@ void esp_render_on_cores() { amy_worker_job = AMY_WORKER_RENDER_OSCS; xTaskNotifyGive(amy_render_handle); // to esp_render_task // Render me +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t started = amy_get_us(); +#endif amy_render(AMY_OSCS/2, AMY_OSCS, 0); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_render_core_us[xPortGetCoreID()] = + (uint32_t)(amy_get_us() - started); +#endif // Wait for the other core to finish xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); // from esp_render_task } else { From bebd4a4e36eadc3ec85463a52e41c3e97d74541b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 18:18:25 +0200 Subject: [PATCH 089/112] Count deltas in ESP deadline diagnostics --- src/amy.c | 18 ++++++++++++++---- src/amy.h | 4 ++++ src/i2s.c | 25 +++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/amy.c b/src/amy.c index 8e59df47..ad813454 100644 --- a/src/amy.c +++ b/src/amy.c @@ -979,7 +979,10 @@ bool osc_ref_within_voice(int rel_osc, uint16_t oscs_per_voice, const char *what #define EVENT_TO_DELTA_FREQ_COEFS(FIELD, FLAG) \ EVENT_TO_DELTA_COEFS_COEF0_SPECIAL(FIELD, FLAG, logfreq_of_freq) -static void flush_due_deltas(); // definition next to amy_execute_deltas() +static uint32_t flush_due_deltas(); // definition next to amy_execute_deltas() +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +uint32_t amy_last_executed_delta_count; +#endif // Take the distortion fields out of an event once they have been turned into // deltas, so no later pass over the same event can spend them a second time @@ -1079,7 +1082,7 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe // Settle pending deltas without running the sequencer tick // service - this can execute on any sending thread (see // flush_due_deltas). - flush_due_deltas(); + (void)flush_due_deltas(); patches_load_patch(e); } // Execute any other commands in this event. @@ -2646,21 +2649,24 @@ AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { // service is rendering-context-only (unguarded RMW on next_amy_tick_us, and // the external hook expects audio-thread context). Everything here is under // the queue lock - safe from any thread. -static void flush_due_deltas() { +static uint32_t flush_due_deltas() { // check to see which sounds to play uint32_t sysclock = amy_sysclock(); amy_grab_lock(); // find any deltas that need to be played from the (in-order) queue struct delta *d = amy_global.delta_queue; + uint32_t executed = 0; while(d && AMY_TIME_GEQ(sysclock, d->time)) { play_delta(d); d = delta_release(d); amy_global.delta_qsize--; + ++executed; } amy_global.delta_queue = d; amy_release_lock(); + return executed; } // this takes scheduled deltas and plays them at the right time @@ -2671,7 +2677,11 @@ void amy_execute_deltas() { sequencer_check_and_fill(); // Make sure any CV-triggered events are added to delta queue update_external_cv_in(); - flush_due_deltas(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_executed_delta_count = flush_due_deltas(); +#else + (void)flush_due_deltas(); +#endif AMY_PROFILE_STOP(AMY_EXECUTE_DELTAS) } diff --git a/src/amy.h b/src/amy.h index 92987e12..6cd7deca 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1068,9 +1068,13 @@ typedef struct amy_esp_load_diagnostic { uint64_t missed_render_sum_us; uint64_t missed_fill_sum_us; uint64_t missed_total_sum_us; + uint64_t executed_delta_sum; + uint64_t missed_executed_delta_sum; uint32_t missed_execute_max_us; uint32_t missed_render_max_us; uint32_t missed_fill_max_us; + uint32_t executed_delta_max; + uint32_t missed_executed_delta_max; uint32_t blocks; } amy_esp_load_diagnostic_t; diff --git a/src/i2s.c b/src/i2s.c index cca0f648..54030077 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -290,6 +290,7 @@ static amy_esp_load_diagnostic_t esp_load_diagnostic; static volatile uint32_t esp_load_diagnostic_seq; static amy_esp_load_diagnostic_t esp_load_print_baseline; static volatile uint32_t esp_render_core_us[2]; +extern uint32_t amy_last_executed_delta_count; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, @@ -308,6 +309,9 @@ static void esp_load_diagnostic_record(uint32_t execute_us, } stats->fill_sum_us += fill_us; stats->total_sum_us += total_us; + stats->executed_delta_sum += amy_last_executed_delta_count; + if (amy_last_executed_delta_count > stats->executed_delta_max) + stats->executed_delta_max = amy_last_executed_delta_count; if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; if (render_us > stats->render_max_us) stats->render_max_us = render_us; if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; @@ -320,12 +324,15 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->missed_render_sum_us += render_us; stats->missed_fill_sum_us += fill_us; stats->missed_total_sum_us += total_us; + stats->missed_executed_delta_sum += amy_last_executed_delta_count; if (execute_us > stats->missed_execute_max_us) stats->missed_execute_max_us = execute_us; if (render_us > stats->missed_render_max_us) stats->missed_render_max_us = render_us; if (fill_us > stats->missed_fill_max_us) stats->missed_fill_max_us = fill_us; + if (amy_last_executed_delta_count > stats->missed_executed_delta_max) + stats->missed_executed_delta_max = amy_last_executed_delta_count; } ++stats->blocks; __sync_synchronize(); @@ -384,6 +391,12 @@ void amy_esp_load_diagnostics_print(void) { uint64_t interval_missed_total_us = stats.missed_total_sum_us - esp_load_print_baseline.missed_total_sum_us; + uint64_t interval_delta_count = + stats.executed_delta_sum + - esp_load_print_baseline.executed_delta_sum; + uint64_t interval_missed_delta_count = + stats.missed_executed_delta_sum + - esp_load_print_baseline.missed_executed_delta_sum; fprintf(stderr, "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " "max_us execute=%u render=%u fill=%u total=%u " @@ -394,7 +407,9 @@ void amy_esp_load_diagnostics_print(void) { "miss_avg_us execute=%u render=%u fill=%u total=%u " "miss_stage_max_us execute=%u render=%u fill=%u " "interval_render_core_avg_us core0=%u core1=%u " - "render_core_max_us core0=%u core1=%u\n", + "render_core_max_us core0=%u core1=%u " + "deltas_avg=%u deltas_max=%u " + "miss_deltas_avg=%u miss_deltas_max=%u\n", (unsigned)blocks, (unsigned)(stats.execute_sum_us / blocks), (unsigned)(stats.render_sum_us / blocks), @@ -429,7 +444,13 @@ void amy_esp_load_diagnostics_print(void) { (unsigned)(interval_blocks ? interval_render_core_us[1] / interval_blocks : 0), (unsigned)stats.render_core_max_us[0], - (unsigned)stats.render_core_max_us[1]); + (unsigned)stats.render_core_max_us[1], + (unsigned)(interval_blocks + ? interval_delta_count / interval_blocks : 0), + (unsigned)stats.executed_delta_max, + (unsigned)(interval_misses + ? interval_missed_delta_count / interval_misses : 0), + (unsigned)stats.missed_executed_delta_max); esp_load_print_baseline = stats; } #else From f2d71c0ef216426875f4bd35c884bac27e6d7179 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 18:41:30 +0200 Subject: [PATCH 090/112] Correlate deadline misses with sequencer and voices --- src/amy.c | 20 ++++++++++++++ src/amy.h | 12 +++++++++ src/i2s.c | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/amy.c b/src/amy.c index ad813454..0736f123 100644 --- a/src/amy.c +++ b/src/amy.c @@ -982,6 +982,9 @@ bool osc_ref_within_voice(int rel_osc, uint16_t oscs_per_voice, const char *what static uint32_t flush_due_deltas(); // definition next to amy_execute_deltas() #ifdef AMY_ESP_LOAD_DIAGNOSTIC uint32_t amy_last_executed_delta_count; +uint32_t amy_last_sequencer_us; +uint32_t amy_last_flush_us; +uint16_t amy_last_audible_osc_count[2]; #endif // Take the distortion fields out of an event once they have been turned into @@ -2565,11 +2568,17 @@ SAMPLE render_osc_wave(uint16_t osc, uint8_t core, SAMPLE* buf) { AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { AMY_PROFILE_START(AMY_RENDER) +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint16_t audible_osc_count = 0; +#endif for(int bus = 0; bus <= amy_global.highest_bus; ++bus) bzero(fbl[core][bus], sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); SAMPLE max_max = 0; for(uint16_t osc=start; oscstatus == SYNTH_AUDIBLE) { // skip oscs that are silent or mod sources from playback +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + ++audible_osc_count; +#endif uint16_t bus = synth[osc]->bus; bzero(per_osc_fb[core][bus], AMY_BLOCK_SIZE * sizeof(SAMPLE)); SAMPLE max_val = render_osc_wave(osc, core, per_osc_fb[core][bus]); @@ -2620,6 +2629,9 @@ AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { } // end if audible } core_max[core] = max_max; +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + if (core < 2) amy_last_audible_osc_count[core] = audible_osc_count; +#endif if(AMY_HAS_CHORUS && core == 0) { for(int bus = 0; bus <= amy_global.highest_bus; ++bus) { @@ -2674,11 +2686,19 @@ void amy_execute_deltas() { AMY_PROFILE_START(AMY_EXECUTE_DELTAS) // Advance the sequencer on AMY (sample) time and play any due sequence // events, so sequencing works in any rendering context, real-time or not. +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t diagnostic_started_us = amy_get_us(); +#endif sequencer_check_and_fill(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequencer_us = (uint32_t)(amy_get_us() - diagnostic_started_us); +#endif // Make sure any CV-triggered events are added to delta queue update_external_cv_in(); #ifdef AMY_ESP_LOAD_DIAGNOSTIC + diagnostic_started_us = amy_get_us(); amy_last_executed_delta_count = flush_due_deltas(); + amy_last_flush_us = (uint32_t)(amy_get_us() - diagnostic_started_us); #else (void)flush_due_deltas(); #endif diff --git a/src/amy.h b/src/amy.h index 6cd7deca..73978323 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1053,28 +1053,40 @@ typedef struct amy_reverb_diagnostic { typedef struct amy_esp_load_diagnostic { uint64_t execute_sum_us; + uint64_t sequencer_sum_us; + uint64_t flush_sum_us; uint64_t render_sum_us; uint64_t render_core_sum_us[2]; + uint64_t audible_osc_sum[2]; uint64_t fill_sum_us; uint64_t total_sum_us; uint32_t execute_max_us; + uint32_t sequencer_max_us; + uint32_t flush_max_us; uint32_t render_max_us; uint32_t render_core_max_us[2]; + uint32_t audible_osc_max[2]; uint32_t fill_max_us; uint32_t total_max_us; uint32_t total_near_deadline; uint32_t total_deadline_misses; uint64_t missed_execute_sum_us; + uint64_t missed_sequencer_sum_us; + uint64_t missed_flush_sum_us; uint64_t missed_render_sum_us; uint64_t missed_fill_sum_us; uint64_t missed_total_sum_us; uint64_t executed_delta_sum; uint64_t missed_executed_delta_sum; uint32_t missed_execute_max_us; + uint32_t missed_sequencer_max_us; + uint32_t missed_flush_max_us; uint32_t missed_render_max_us; uint32_t missed_fill_max_us; uint32_t executed_delta_max; uint32_t missed_executed_delta_max; + uint64_t missed_audible_osc_sum[2]; + uint32_t missed_audible_osc_max[2]; uint32_t blocks; } amy_esp_load_diagnostic_t; diff --git a/src/i2s.c b/src/i2s.c index 54030077..b97fcaac 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -291,6 +291,9 @@ static volatile uint32_t esp_load_diagnostic_seq; static amy_esp_load_diagnostic_t esp_load_print_baseline; static volatile uint32_t esp_render_core_us[2]; extern uint32_t amy_last_executed_delta_count; +extern uint32_t amy_last_sequencer_us; +extern uint32_t amy_last_flush_us; +extern uint16_t amy_last_audible_osc_count[2]; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, @@ -300,12 +303,17 @@ static void esp_load_diagnostic_record(uint32_t execute_us, ++esp_load_diagnostic_seq; __sync_synchronize(); stats->execute_sum_us += execute_us; + stats->sequencer_sum_us += amy_last_sequencer_us; + stats->flush_sum_us += amy_last_flush_us; stats->render_sum_us += render_us; for (uint8_t core = 0; core < 2; ++core) { uint32_t core_us = esp_render_core_us[core]; stats->render_core_sum_us[core] += core_us; if (core_us > stats->render_core_max_us[core]) stats->render_core_max_us[core] = core_us; + stats->audible_osc_sum[core] += amy_last_audible_osc_count[core]; + if (amy_last_audible_osc_count[core] > stats->audible_osc_max[core]) + stats->audible_osc_max[core] = amy_last_audible_osc_count[core]; } stats->fill_sum_us += fill_us; stats->total_sum_us += total_us; @@ -313,6 +321,10 @@ static void esp_load_diagnostic_record(uint32_t execute_us, if (amy_last_executed_delta_count > stats->executed_delta_max) stats->executed_delta_max = amy_last_executed_delta_count; if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; + if (amy_last_sequencer_us > stats->sequencer_max_us) + stats->sequencer_max_us = amy_last_sequencer_us; + if (amy_last_flush_us > stats->flush_max_us) + stats->flush_max_us = amy_last_flush_us; if (render_us > stats->render_max_us) stats->render_max_us = render_us; if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; if (total_us > stats->total_max_us) stats->total_max_us = total_us; @@ -321,18 +333,32 @@ static void esp_load_diagnostic_record(uint32_t execute_us, if (total_us > AMY_BLOCK_US) { ++stats->total_deadline_misses; stats->missed_execute_sum_us += execute_us; + stats->missed_sequencer_sum_us += amy_last_sequencer_us; + stats->missed_flush_sum_us += amy_last_flush_us; stats->missed_render_sum_us += render_us; stats->missed_fill_sum_us += fill_us; stats->missed_total_sum_us += total_us; stats->missed_executed_delta_sum += amy_last_executed_delta_count; if (execute_us > stats->missed_execute_max_us) stats->missed_execute_max_us = execute_us; + if (amy_last_sequencer_us > stats->missed_sequencer_max_us) + stats->missed_sequencer_max_us = amy_last_sequencer_us; + if (amy_last_flush_us > stats->missed_flush_max_us) + stats->missed_flush_max_us = amy_last_flush_us; if (render_us > stats->missed_render_max_us) stats->missed_render_max_us = render_us; if (fill_us > stats->missed_fill_max_us) stats->missed_fill_max_us = fill_us; if (amy_last_executed_delta_count > stats->missed_executed_delta_max) stats->missed_executed_delta_max = amy_last_executed_delta_count; + for (uint8_t core = 0; core < 2; ++core) { + stats->missed_audible_osc_sum[core] += + amy_last_audible_osc_count[core]; + if (amy_last_audible_osc_count[core] + > stats->missed_audible_osc_max[core]) + stats->missed_audible_osc_max[core] = + amy_last_audible_osc_count[core]; + } } ++stats->blocks; __sync_synchronize(); @@ -397,6 +423,26 @@ void amy_esp_load_diagnostics_print(void) { uint64_t interval_missed_delta_count = stats.missed_executed_delta_sum - esp_load_print_baseline.missed_executed_delta_sum; + uint64_t interval_sequencer_us = + stats.sequencer_sum_us - esp_load_print_baseline.sequencer_sum_us; + uint64_t interval_flush_us = + stats.flush_sum_us - esp_load_print_baseline.flush_sum_us; + uint64_t interval_missed_sequencer_us = + stats.missed_sequencer_sum_us + - esp_load_print_baseline.missed_sequencer_sum_us; + uint64_t interval_missed_flush_us = + stats.missed_flush_sum_us + - esp_load_print_baseline.missed_flush_sum_us; + uint64_t interval_audible_osc[2]; + uint64_t interval_missed_audible_osc[2]; + for (uint8_t core = 0; core < 2; ++core) { + interval_audible_osc[core] = + stats.audible_osc_sum[core] + - esp_load_print_baseline.audible_osc_sum[core]; + interval_missed_audible_osc[core] = + stats.missed_audible_osc_sum[core] + - esp_load_print_baseline.missed_audible_osc_sum[core]; + } fprintf(stderr, "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " "max_us execute=%u render=%u fill=%u total=%u " @@ -409,7 +455,15 @@ void amy_esp_load_diagnostics_print(void) { "interval_render_core_avg_us core0=%u core1=%u " "render_core_max_us core0=%u core1=%u " "deltas_avg=%u deltas_max=%u " - "miss_deltas_avg=%u miss_deltas_max=%u\n", + "miss_deltas_avg=%u miss_deltas_max=%u " + "interval_execute_detail_avg_us sequencer=%u flush=%u " + "execute_detail_max_us sequencer=%u flush=%u " + "miss_execute_detail_avg_us sequencer=%u flush=%u " + "miss_execute_detail_max_us sequencer=%u flush=%u " + "audible_oscs_avg core0=%u core1=%u " + "audible_oscs_max core0=%u core1=%u " + "miss_audible_oscs_avg core0=%u core1=%u " + "miss_audible_oscs_max core0=%u core1=%u\n", (unsigned)blocks, (unsigned)(stats.execute_sum_us / blocks), (unsigned)(stats.render_sum_us / blocks), @@ -450,7 +504,28 @@ void amy_esp_load_diagnostics_print(void) { (unsigned)stats.executed_delta_max, (unsigned)(interval_misses ? interval_missed_delta_count / interval_misses : 0), - (unsigned)stats.missed_executed_delta_max); + (unsigned)stats.missed_executed_delta_max, + (unsigned)(interval_blocks + ? interval_sequencer_us / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_flush_us / interval_blocks : 0), + (unsigned)stats.sequencer_max_us, + (unsigned)stats.flush_max_us, + (unsigned)(interval_misses + ? interval_missed_sequencer_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_flush_us / interval_misses : 0), + (unsigned)stats.missed_sequencer_max_us, + (unsigned)stats.missed_flush_max_us, + (unsigned)(interval_blocks ? interval_audible_osc[0] / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_audible_osc[1] / interval_blocks : 0), + (unsigned)stats.audible_osc_max[0], + (unsigned)stats.audible_osc_max[1], + (unsigned)(interval_misses + ? interval_missed_audible_osc[0] / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_audible_osc[1] / interval_misses : 0), + (unsigned)stats.missed_audible_osc_max[0], + (unsigned)stats.missed_audible_osc_max[1]); esp_load_print_baseline = stats; } #else From c6822e16cd77112739778d47814b27c4dfab005b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 18:50:26 +0200 Subject: [PATCH 091/112] Break down sequencer deadline cost --- src/amy.h | 16 ++++++++++ src/i2s.c | 84 +++++++++++++++++++++++++++++++++++++++++++++++-- src/sequencer.c | 31 ++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/amy.h b/src/amy.h index 73978323..80dff420 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1055,6 +1055,10 @@ typedef struct amy_esp_load_diagnostic { uint64_t execute_sum_us; uint64_t sequencer_sum_us; uint64_t flush_sum_us; + uint64_t sequence_root_sum_us; + uint64_t sequence_control_sum_us; + uint64_t sequence_event_sum_us; + uint64_t sequence_tick_sum; uint64_t render_sum_us; uint64_t render_core_sum_us[2]; uint64_t audible_osc_sum[2]; @@ -1063,6 +1067,10 @@ typedef struct amy_esp_load_diagnostic { uint32_t execute_max_us; uint32_t sequencer_max_us; uint32_t flush_max_us; + uint32_t sequence_root_max_us; + uint32_t sequence_control_max_us; + uint32_t sequence_event_max_us; + uint32_t sequence_tick_max; uint32_t render_max_us; uint32_t render_core_max_us[2]; uint32_t audible_osc_max[2]; @@ -1073,6 +1081,10 @@ typedef struct amy_esp_load_diagnostic { uint64_t missed_execute_sum_us; uint64_t missed_sequencer_sum_us; uint64_t missed_flush_sum_us; + uint64_t missed_sequence_root_sum_us; + uint64_t missed_sequence_control_sum_us; + uint64_t missed_sequence_event_sum_us; + uint64_t missed_sequence_tick_sum; uint64_t missed_render_sum_us; uint64_t missed_fill_sum_us; uint64_t missed_total_sum_us; @@ -1081,6 +1093,10 @@ typedef struct amy_esp_load_diagnostic { uint32_t missed_execute_max_us; uint32_t missed_sequencer_max_us; uint32_t missed_flush_max_us; + uint32_t missed_sequence_root_max_us; + uint32_t missed_sequence_control_max_us; + uint32_t missed_sequence_event_max_us; + uint32_t missed_sequence_tick_max; uint32_t missed_render_max_us; uint32_t missed_fill_max_us; uint32_t executed_delta_max; diff --git a/src/i2s.c b/src/i2s.c index b97fcaac..4e9d0591 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -294,6 +294,10 @@ extern uint32_t amy_last_executed_delta_count; extern uint32_t amy_last_sequencer_us; extern uint32_t amy_last_flush_us; extern uint16_t amy_last_audible_osc_count[2]; +extern uint32_t amy_last_sequence_root_us; +extern uint32_t amy_last_sequence_control_us; +extern uint32_t amy_last_sequence_event_us; +extern uint32_t amy_last_sequence_tick_count; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, @@ -305,6 +309,10 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->execute_sum_us += execute_us; stats->sequencer_sum_us += amy_last_sequencer_us; stats->flush_sum_us += amy_last_flush_us; + stats->sequence_root_sum_us += amy_last_sequence_root_us; + stats->sequence_control_sum_us += amy_last_sequence_control_us; + stats->sequence_event_sum_us += amy_last_sequence_event_us; + stats->sequence_tick_sum += amy_last_sequence_tick_count; stats->render_sum_us += render_us; for (uint8_t core = 0; core < 2; ++core) { uint32_t core_us = esp_render_core_us[core]; @@ -325,6 +333,14 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->sequencer_max_us = amy_last_sequencer_us; if (amy_last_flush_us > stats->flush_max_us) stats->flush_max_us = amy_last_flush_us; + if (amy_last_sequence_root_us > stats->sequence_root_max_us) + stats->sequence_root_max_us = amy_last_sequence_root_us; + if (amy_last_sequence_control_us > stats->sequence_control_max_us) + stats->sequence_control_max_us = amy_last_sequence_control_us; + if (amy_last_sequence_event_us > stats->sequence_event_max_us) + stats->sequence_event_max_us = amy_last_sequence_event_us; + if (amy_last_sequence_tick_count > stats->sequence_tick_max) + stats->sequence_tick_max = amy_last_sequence_tick_count; if (render_us > stats->render_max_us) stats->render_max_us = render_us; if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; if (total_us > stats->total_max_us) stats->total_max_us = total_us; @@ -335,6 +351,10 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->missed_execute_sum_us += execute_us; stats->missed_sequencer_sum_us += amy_last_sequencer_us; stats->missed_flush_sum_us += amy_last_flush_us; + stats->missed_sequence_root_sum_us += amy_last_sequence_root_us; + stats->missed_sequence_control_sum_us += amy_last_sequence_control_us; + stats->missed_sequence_event_sum_us += amy_last_sequence_event_us; + stats->missed_sequence_tick_sum += amy_last_sequence_tick_count; stats->missed_render_sum_us += render_us; stats->missed_fill_sum_us += fill_us; stats->missed_total_sum_us += total_us; @@ -345,6 +365,16 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->missed_sequencer_max_us = amy_last_sequencer_us; if (amy_last_flush_us > stats->missed_flush_max_us) stats->missed_flush_max_us = amy_last_flush_us; + if (amy_last_sequence_root_us > stats->missed_sequence_root_max_us) + stats->missed_sequence_root_max_us = amy_last_sequence_root_us; + if (amy_last_sequence_control_us + > stats->missed_sequence_control_max_us) + stats->missed_sequence_control_max_us = + amy_last_sequence_control_us; + if (amy_last_sequence_event_us > stats->missed_sequence_event_max_us) + stats->missed_sequence_event_max_us = amy_last_sequence_event_us; + if (amy_last_sequence_tick_count > stats->missed_sequence_tick_max) + stats->missed_sequence_tick_max = amy_last_sequence_tick_count; if (render_us > stats->missed_render_max_us) stats->missed_render_max_us = render_us; if (fill_us > stats->missed_fill_max_us) @@ -433,6 +463,29 @@ void amy_esp_load_diagnostics_print(void) { uint64_t interval_missed_flush_us = stats.missed_flush_sum_us - esp_load_print_baseline.missed_flush_sum_us; + uint64_t interval_sequence_root_us = + stats.sequence_root_sum_us + - esp_load_print_baseline.sequence_root_sum_us; + uint64_t interval_sequence_control_us = + stats.sequence_control_sum_us + - esp_load_print_baseline.sequence_control_sum_us; + uint64_t interval_sequence_event_us = + stats.sequence_event_sum_us + - esp_load_print_baseline.sequence_event_sum_us; + uint64_t interval_sequence_ticks = + stats.sequence_tick_sum - esp_load_print_baseline.sequence_tick_sum; + uint64_t interval_missed_sequence_root_us = + stats.missed_sequence_root_sum_us + - esp_load_print_baseline.missed_sequence_root_sum_us; + uint64_t interval_missed_sequence_control_us = + stats.missed_sequence_control_sum_us + - esp_load_print_baseline.missed_sequence_control_sum_us; + uint64_t interval_missed_sequence_event_us = + stats.missed_sequence_event_sum_us + - esp_load_print_baseline.missed_sequence_event_sum_us; + uint64_t interval_missed_sequence_ticks = + stats.missed_sequence_tick_sum + - esp_load_print_baseline.missed_sequence_tick_sum; uint64_t interval_audible_osc[2]; uint64_t interval_missed_audible_osc[2]; for (uint8_t core = 0; core < 2; ++core) { @@ -463,7 +516,11 @@ void amy_esp_load_diagnostics_print(void) { "audible_oscs_avg core0=%u core1=%u " "audible_oscs_max core0=%u core1=%u " "miss_audible_oscs_avg core0=%u core1=%u " - "miss_audible_oscs_max core0=%u core1=%u\n", + "miss_audible_oscs_max core0=%u core1=%u " + "sequence_tick_avg_us root=%u control=%u event=%u " + "sequence_stage_max_us root=%u control=%u event=%u ticks=%u " + "miss_sequence_tick_avg_us root=%u control=%u event=%u " + "miss_sequence_stage_max_us root=%u control=%u event=%u ticks=%u\n", (unsigned)blocks, (unsigned)(stats.execute_sum_us / blocks), (unsigned)(stats.render_sum_us / blocks), @@ -525,7 +582,30 @@ void amy_esp_load_diagnostics_print(void) { (unsigned)(interval_misses ? interval_missed_audible_osc[1] / interval_misses : 0), (unsigned)stats.missed_audible_osc_max[0], - (unsigned)stats.missed_audible_osc_max[1]); + (unsigned)stats.missed_audible_osc_max[1], + (unsigned)(interval_sequence_ticks + ? interval_sequence_root_us / interval_sequence_ticks : 0), + (unsigned)(interval_sequence_ticks + ? interval_sequence_control_us / interval_sequence_ticks : 0), + (unsigned)(interval_sequence_ticks + ? interval_sequence_event_us / interval_sequence_ticks : 0), + (unsigned)stats.sequence_root_max_us, + (unsigned)stats.sequence_control_max_us, + (unsigned)stats.sequence_event_max_us, + (unsigned)stats.sequence_tick_max, + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_root_us + / interval_missed_sequence_ticks : 0), + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_control_us + / interval_missed_sequence_ticks : 0), + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_event_us + / interval_missed_sequence_ticks : 0), + (unsigned)stats.missed_sequence_root_max_us, + (unsigned)stats.missed_sequence_control_max_us, + (unsigned)stats.missed_sequence_event_max_us, + (unsigned)stats.missed_sequence_tick_max); esp_load_print_baseline = stats; } #else diff --git a/src/sequencer.c b/src/sequencer.c index 198e6e08..f7956f46 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -9,6 +9,13 @@ uint32_t sequencer_ticks() { return amy_global.sequencer_tick_count; } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +uint32_t amy_last_sequence_root_us; +uint32_t amy_last_sequence_control_us; +uint32_t amy_last_sequence_event_us; +uint32_t amy_last_sequence_tick_count; +#endif + // Sequenced ticks events are stored as the raw wire-message string (with its // leading 'H' command stripped) plus the scheduling metadata needed to play // it back. The string is only parsed when the entry comes due. @@ -977,6 +984,10 @@ static void stored_sequence_process_events(uint32_t tick) { } static void sequencer_process_tick(void) { +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t diagnostic_stage_started_us = amy_get_us(); + ++amy_last_sequence_tick_count; +#endif // External sequence controls take their next-tick snapshot under this same // lock, so current-tick versus next-tick activation has one ordering point. amy_grab_lock(); @@ -1044,10 +1055,24 @@ static void sequencer_process_tick(void) { } tag = next; } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_root_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); + diagnostic_stage_started_us = amy_get_us(); +#endif // Composed controls take effect before ordinary stored-sequence events on // the same tick. This lets a parent stop a child without one extra onset. stored_sequence_process_controls(tick); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_control_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); + diagnostic_stage_started_us = amy_get_us(); +#endif stored_sequence_process_events(tick); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_event_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); +#endif wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(tick); @@ -1127,6 +1152,12 @@ void sequencer_external_clock_disable() { // amy_sysclock(), which counts rendered samples, so the sequencer advances on // AMY time in any rendering context (live, offline, tests). void sequencer_check_and_fill() { +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_root_us = 0; + amy_last_sequence_control_us = 0; + amy_last_sequence_event_us = 0; + amy_last_sequence_tick_count = 0; +#endif if (sequences == NULL) return; // sequencer_init hasn't run if (sequencer_external_clock) return; if (wire_firing) return; // nested via a fired message's own parse From 13c411d8beb2908266ef9e1becf37b8d118d990a Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 19:00:35 +0200 Subject: [PATCH 092/112] Avoid rescanning finite sequence events each tick --- src/sequencer.c | 101 +++++++++++++++++++++++++++---- tests/test_sequencer_sequences.c | 21 +++++++ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index f7956f46..94fd6f1f 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -64,9 +64,15 @@ typedef struct stored_sequence_event_t { typedef struct stored_sequence_definition_t { stored_sequence_event_t *events; + // One-shot events stay in append order above for compatibility. This + // separate stable tick order lets finite executions advance cursors + // instead of rescanning every event on every sequencer tick. + uint32_t *one_shot_order; uint32_t event_count; + uint32_t one_shot_event_count; uint32_t last_one_shot_tick; bool has_periodic_event; + bool has_control_event; uint32_t refs; // Zero-reference definitions are linked here by the render path. A // non-rendering sequence API call detaches the complete list under the @@ -89,6 +95,8 @@ typedef struct stored_sequence_execution_t { bool gate_change_pending; bool gated; bool controls_processed; + uint32_t next_control_order; + uint32_t next_event_order; } stored_sequence_execution_t; static stored_sequence_definition_t **stored_sequences = NULL; @@ -96,6 +104,7 @@ static stored_sequence_execution_t *sequence_executions = NULL; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; +static size_t stored_sequence_order_bytes = 0; static stored_sequence_definition_t *retired_sequence_definitions = NULL; #ifdef AMY_SEQUENCE_TESTING @@ -136,6 +145,7 @@ static void stored_sequence_definition_destroy( for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); + free(definition->one_shot_order); free(definition); } @@ -204,10 +214,19 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { free(definition); return NULL; } + definition->one_shot_order = (uint32_t *)stored_sequence_allocate( + stored_sequence_order_bytes, amy_global.config.ram_caps_synth); + if (definition->one_shot_order == NULL) { + free(definition->events); + free(definition); + return NULL; + } memset(definition->events, 0, stored_sequence_event_bytes); definition->event_count = 0; + definition->one_shot_event_count = 0; definition->last_one_shot_tick = 0; definition->has_periodic_event = false; + definition->has_control_event = false; definition->refs = 1; definition->next_retired = NULL; return definition; @@ -227,8 +246,12 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( if (copy == NULL) return NULL; if (source == NULL) return copy; copy->event_count = source->event_count; + copy->one_shot_event_count = source->one_shot_event_count; copy->last_one_shot_tick = source->last_one_shot_tick; copy->has_periodic_event = source->has_periodic_event; + copy->has_control_event = source->has_control_event; + memcpy(copy->one_shot_order, source->one_shot_order, + source->one_shot_event_count * sizeof(*copy->one_shot_order)); for (uint32_t i = 0; i < source->event_count; ++i) { const stored_sequence_event_t *from = &source->events[i]; copy->events[i].wire = stored_sequence_wire_copy(from->wire); @@ -279,6 +302,7 @@ static void stored_sequences_deinit(void) { max_stored_sequence_events = 0; max_stored_sequence_executions = 0; stored_sequence_event_bytes = 0; + stored_sequence_order_bytes = 0; stored_sequence_definition_t *retired = retired_sequence_definitions; retired_sequence_definitions = NULL; stored_sequence_definition_destroy_list(retired); @@ -295,6 +319,8 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), &stored_sequence_event_bytes) + || !checked_array_size(events, sizeof(uint32_t), + &stored_sequence_order_bytes) || !checked_array_size(executions, sizeof(stored_sequence_execution_t), &execution_bytes)) { @@ -555,14 +581,32 @@ static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { static void stored_sequence_definition_append_owned( stored_sequence_definition_t *definition, uint32_t tick, uint32_t period, char *wire) { - stored_sequence_event_t *event = - &definition->events[definition->event_count++]; + uint32_t event_index = definition->event_count++; + stored_sequence_event_t *event = &definition->events[event_index]; event->wire = wire; event->tick = tick; event->period = period; - if (period != 0) definition->has_periodic_event = true; - else if (tick > definition->last_one_shot_tick) - definition->last_one_shot_tick = tick; + if (wire[0] == 'H' && wire[1] == 'C') + definition->has_control_event = true; + if (period != 0) { + definition->has_periodic_event = true; + } else { + // Insert after existing events at the same tick. Event storage remains + // in caller append order, while this index gives finite executions a + // stable chronological walk without changing same-tick dispatch. + uint32_t order_index = definition->one_shot_event_count; + while (order_index != 0) { + uint32_t previous = + definition->one_shot_order[order_index - 1]; + if (definition->events[previous].tick <= tick) break; + definition->one_shot_order[order_index] = previous; + --order_index; + } + definition->one_shot_order[order_index] = event_index; + definition->one_shot_event_count++; + if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; + } } // A candidate owns the incoming wire in its final event. If publication loses @@ -913,6 +957,10 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, return false; } if (controls) { + if (!definition->has_control_event) { + amy_release_lock(); + return false; + } if (execution->controls_processed && execution->controls_processed_tick == tick) { amy_release_lock(); @@ -934,16 +982,47 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, execution->gated = false; } bool suppress = !controls && execution->gated; + uint32_t ordered_start = 0; + uint32_t ordered_end = 0; + if (!definition->has_periodic_event) { + uint32_t *cursor = controls ? &execution->next_control_order + : &execution->next_event_order; + ordered_start = *cursor; + ordered_end = ordered_start; + while (ordered_end < definition->one_shot_event_count) { + uint32_t event_index = definition->one_shot_order[ordered_end]; + if (definition->events[event_index].tick > elapsed) break; + ++ordered_end; + } + // Advance before dispatch because an HC payload can recursively stop + // or recycle this slot. A gate deliberately consumes suppressed due + // events rather than replaying them after the gate opens. + *cursor = ordered_end; + } definition->refs++; amy_release_lock(); if (!suppress) { - for (uint32_t event_index = 0; - event_index < definition->event_count; ++event_index) { - stored_sequence_event_t *event = &definition->events[event_index]; - if (stored_sequence_event_is_control(event) == controls - && stored_sequence_event_hits(event, elapsed)) - stored_sequence_play_wire(event->wire, tick); + if (!definition->has_periodic_event) { + for (uint32_t order_index = ordered_start; + order_index < ordered_end; ++order_index) { + uint32_t event_index = + definition->one_shot_order[order_index]; + stored_sequence_event_t *event = + &definition->events[event_index]; + if (event->tick == elapsed + && stored_sequence_event_is_control(event) == controls) + stored_sequence_play_wire(event->wire, tick); + } + } else { + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = + &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire, tick); + } } } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 012f1032..bbdbef4a 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -121,6 +121,26 @@ static void test_repeated_tag_and_one_shot_lifetime(void) { "period-zero sequence events fire once and execution retires"); } +static void test_out_of_order_one_shots_keep_musical_order(void) { + printf("finite events use tick order and preserve same-tick append order\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H4,0,15zPtailZ"); + amy_add_message("H0,0,15zPheadZ"); + amy_add_message("H2,0,15zPmiddle-firstZ"); + amy_add_message("H2,0,15zPmiddle-secondZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(mark_count == 4, "all four out-of-order events fired once"); + CHECK(mark_count == 4 + && !strcmp(marks[0].name, "head") + && !strcmp(marks[1].name, "middle-first") + && !strcmp(marks[2].name, "middle-second") + && !strcmp(marks[3].name, "tail"), + "tick order is chronological and same-tick order is stable"); +} + static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { printf("empty tick-zero reset remains distinct from a tick-zero event\n"); sequencer_reset(); @@ -655,6 +675,7 @@ int main(void) { test_untagged_ticks_and_cumulative_tags(); test_legacy_c_event_wire_is_unchanged(); test_repeated_tag_and_one_shot_lifetime(); + test_out_of_order_one_shots_keep_musical_order(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_append_while_active_uses_copy_on_write(); From 2d6b78ed629b790f1b6d6f5e3553a78545fbe1da Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 19:07:55 +0200 Subject: [PATCH 093/112] Skip irrelevant stored sequence event classes --- src/sequencer.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/sequencer.c b/src/sequencer.c index 94fd6f1f..97523d92 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -73,6 +73,7 @@ typedef struct stored_sequence_definition_t { uint32_t last_one_shot_tick; bool has_periodic_event; bool has_control_event; + bool has_regular_event; uint32_t refs; // Zero-reference definitions are linked here by the render path. A // non-rendering sequence API call detaches the complete list under the @@ -227,6 +228,7 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { definition->last_one_shot_tick = 0; definition->has_periodic_event = false; definition->has_control_event = false; + definition->has_regular_event = false; definition->refs = 1; definition->next_retired = NULL; return definition; @@ -250,6 +252,7 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( copy->last_one_shot_tick = source->last_one_shot_tick; copy->has_periodic_event = source->has_periodic_event; copy->has_control_event = source->has_control_event; + copy->has_regular_event = source->has_regular_event; memcpy(copy->one_shot_order, source->one_shot_order, source->one_shot_event_count * sizeof(*copy->one_shot_order)); for (uint32_t i = 0; i < source->event_count; ++i) { @@ -588,6 +591,8 @@ static void stored_sequence_definition_append_owned( event->period = period; if (wire[0] == 'H' && wire[1] == 'C') definition->has_control_event = true; + else + definition->has_regular_event = true; if (period != 0) { definition->has_periodic_event = true; } else { @@ -971,6 +976,13 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, execution->controls_processed = true; execution->controls_processed_tick = tick; } else { + if (!definition->has_regular_event) { + if (!definition->has_periodic_event + && elapsed == definition->last_one_shot_tick) + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } if (execution->gate_change_pending && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { execution->gate_change_pending = false; From e1c989216a6df5d77738df0d68e92543d14a3f8b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 19:15:59 +0200 Subject: [PATCH 094/112] Report active stored sequence shapes --- src/sequencer.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/sequencer.c b/src/sequencer.c index 97523d92..2a8996cd 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -418,6 +418,28 @@ void sequencer_debug() { sequences[tag].wire); } } + if (sequence_executions == NULL) return; + uint32_t stored_active = 0; + for (uint32_t slot = 0; slot < max_stored_sequence_executions; ++slot) { + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied) continue; + stored_sequence_definition_t *definition = execution->definition; + ++stored_active; + fprintf(stderr, + "stored execution slot %" PRIu32 " tag %" PRIu32 + " events %" PRIu32 " one_shot %" PRIu32 + " periodic %u controls %u regular %u start %" PRIu32 + " elapsed %" PRIu32 "\n", + slot, execution->tag, definition->event_count, + definition->one_shot_event_count, + definition->has_periodic_event ? 1u : 0u, + definition->has_control_event ? 1u : 0u, + definition->has_regular_event ? 1u : 0u, + execution->start_tick, + amy_global.sequencer_tick_count - execution->start_tick); + } + fprintf(stderr, "stored executions active %" PRIu32 "/%" PRIu32 "\n", + stored_active, max_stored_sequence_executions); } /* The occupied slots, threaded through the table as an ASCENDING list. From 09ab4555273f71a1b59fb45550287b184f2fd703 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 22:33:03 +0200 Subject: [PATCH 095/112] Avoid rescanning uniform periodic sequences --- src/sequencer.c | 102 +++++++++++++++++++------------ tests/test_sequencer_sequences.c | 42 +++++++++++++ 2 files changed, 104 insertions(+), 40 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 2a8996cd..2dde7a53 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -64,13 +64,17 @@ typedef struct stored_sequence_event_t { typedef struct stored_sequence_definition_t { stored_sequence_event_t *events; - // One-shot events stay in append order above for compatibility. This - // separate stable tick order lets finite executions advance cursors - // instead of rescanning every event on every sequencer tick. - uint32_t *one_shot_order; + // Events stay in append order above for compatibility. This separate + // stable tick order lets finite sequences and the common case where all + // events share one period advance cursors instead of rescanning every + // event on every sequencer tick. + uint32_t *event_order; uint32_t event_count; - uint32_t one_shot_event_count; uint32_t last_one_shot_tick; + // UINT32_MAX means the definition mixes periods and uses the generic + // append-order scan. Zero is a finite sequence; any other value is the + // period shared by every event in the fast path. + uint32_t schedule_period; bool has_periodic_event; bool has_control_event; bool has_regular_event; @@ -146,7 +150,7 @@ static void stored_sequence_definition_destroy( for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); - free(definition->one_shot_order); + free(definition->event_order); free(definition); } @@ -215,17 +219,17 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { free(definition); return NULL; } - definition->one_shot_order = (uint32_t *)stored_sequence_allocate( + definition->event_order = (uint32_t *)stored_sequence_allocate( stored_sequence_order_bytes, amy_global.config.ram_caps_synth); - if (definition->one_shot_order == NULL) { + if (definition->event_order == NULL) { free(definition->events); free(definition); return NULL; } memset(definition->events, 0, stored_sequence_event_bytes); definition->event_count = 0; - definition->one_shot_event_count = 0; definition->last_one_shot_tick = 0; + definition->schedule_period = 0; definition->has_periodic_event = false; definition->has_control_event = false; definition->has_regular_event = false; @@ -248,13 +252,13 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( if (copy == NULL) return NULL; if (source == NULL) return copy; copy->event_count = source->event_count; - copy->one_shot_event_count = source->one_shot_event_count; copy->last_one_shot_tick = source->last_one_shot_tick; + copy->schedule_period = source->schedule_period; copy->has_periodic_event = source->has_periodic_event; copy->has_control_event = source->has_control_event; copy->has_regular_event = source->has_regular_event; - memcpy(copy->one_shot_order, source->one_shot_order, - source->one_shot_event_count * sizeof(*copy->one_shot_order)); + memcpy(copy->event_order, source->event_order, + source->event_count * sizeof(*copy->event_order)); for (uint32_t i = 0; i < source->event_count; ++i) { const stored_sequence_event_t *from = &source->events[i]; copy->events[i].wire = stored_sequence_wire_copy(from->wire); @@ -427,11 +431,11 @@ void sequencer_debug() { ++stored_active; fprintf(stderr, "stored execution slot %" PRIu32 " tag %" PRIu32 - " events %" PRIu32 " one_shot %" PRIu32 + " events %" PRIu32 " schedule_period %" PRIu32 " periodic %u controls %u regular %u start %" PRIu32 " elapsed %" PRIu32 "\n", slot, execution->tag, definition->event_count, - definition->one_shot_event_count, + definition->schedule_period, definition->has_periodic_event ? 1u : 0u, definition->has_control_event ? 1u : 0u, definition->has_regular_event ? 1u : 0u, @@ -606,7 +610,12 @@ static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { static void stored_sequence_definition_append_owned( stored_sequence_definition_t *definition, uint32_t tick, uint32_t period, char *wire) { - uint32_t event_index = definition->event_count++; + uint32_t event_index = definition->event_count; + if (event_index == 0) + definition->schedule_period = period; + else if (definition->schedule_period != period) + definition->schedule_period = UINT32_MAX; + definition->event_count++; stored_sequence_event_t *event = &definition->events[event_index]; event->wire = wire; event->tick = tick; @@ -618,22 +627,21 @@ static void stored_sequence_definition_append_owned( if (period != 0) { definition->has_periodic_event = true; } else { - // Insert after existing events at the same tick. Event storage remains - // in caller append order, while this index gives finite executions a - // stable chronological walk without changing same-tick dispatch. - uint32_t order_index = definition->one_shot_event_count; - while (order_index != 0) { - uint32_t previous = - definition->one_shot_order[order_index - 1]; - if (definition->events[previous].tick <= tick) break; - definition->one_shot_order[order_index] = previous; - --order_index; - } - definition->one_shot_order[order_index] = event_index; - definition->one_shot_event_count++; if (tick > definition->last_one_shot_tick) definition->last_one_shot_tick = tick; } + // Insert after existing events at the same tick. Event storage remains in + // caller append order. The order is used only when all periods match, so + // tick order is the exact chronological order within each finite run or + // periodic cycle. + uint32_t order_index = event_index; + while (order_index != 0) { + uint32_t previous = definition->event_order[order_index - 1]; + if (definition->events[previous].tick <= tick) break; + definition->event_order[order_index] = previous; + --order_index; + } + definition->event_order[order_index] = event_index; } // A candidate owns the incoming wire in its final event. If publication loses @@ -1018,14 +1026,20 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, bool suppress = !controls && execution->gated; uint32_t ordered_start = 0; uint32_t ordered_end = 0; - if (!definition->has_periodic_event) { - uint32_t *cursor = controls ? &execution->next_control_order - : &execution->next_event_order; + bool ordered_schedule = definition->schedule_period != UINT32_MAX; + uint32_t schedule_tick = elapsed; + uint32_t *cursor = controls ? &execution->next_control_order + : &execution->next_event_order; + if (ordered_schedule && definition->schedule_period != 0) { + schedule_tick %= definition->schedule_period; + if (schedule_tick == 0) *cursor = 0; + } + if (ordered_schedule) { ordered_start = *cursor; ordered_end = ordered_start; - while (ordered_end < definition->one_shot_event_count) { - uint32_t event_index = definition->one_shot_order[ordered_end]; - if (definition->events[event_index].tick > elapsed) break; + while (ordered_end < definition->event_count) { + uint32_t event_index = definition->event_order[ordered_end]; + if (definition->events[event_index].tick > schedule_tick) break; ++ordered_end; } // Advance before dispatch because an HC payload can recursively stop @@ -1036,17 +1050,20 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, definition->refs++; amy_release_lock(); + bool dispatched_control = false; if (!suppress) { - if (!definition->has_periodic_event) { + if (ordered_schedule) { for (uint32_t order_index = ordered_start; order_index < ordered_end; ++order_index) { uint32_t event_index = - definition->one_shot_order[order_index]; + definition->event_order[order_index]; stored_sequence_event_t *event = &definition->events[event_index]; - if (event->tick == elapsed - && stored_sequence_event_is_control(event) == controls) + if (event->tick == schedule_tick + && stored_sequence_event_is_control(event) == controls) { + if (controls) dispatched_control = true; stored_sequence_play_wire(event->wire, tick); + } } } else { for (uint32_t event_index = 0; @@ -1054,8 +1071,10 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, stored_sequence_event_t *event = &definition->events[event_index]; if (stored_sequence_event_is_control(event) == controls - && stored_sequence_event_hits(event, elapsed)) + && stored_sequence_event_hits(event, elapsed)) { + if (controls) dispatched_control = true; stored_sequence_play_wire(event->wire, tick); + } } } } @@ -1069,7 +1088,10 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, && execution->start_tick == tick - elapsed) stored_sequence_execution_release_deferred(execution); amy_release_lock(); - return true; + // The control traversal needs another pass only when a due control may + // have started an execution in an already-visited slot. Merely visiting a + // periodic control definition must not force a second full slot scan. + return controls && dispatched_control; } static void stored_sequence_process_controls(uint32_t tick) { diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index bbdbef4a..83858ff0 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -141,6 +141,46 @@ static void test_out_of_order_one_shots_keep_musical_order(void) { "tick order is chronological and same-tick order is stable"); } +static void test_uniform_periodic_events_keep_musical_order(void) { + printf("uniform periodic events use tick order and stable ties\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H3,8,15zPlateZ"); + amy_add_message("H1,8,15zPearly-firstZ"); + amy_add_message("H1,8,15zPearly-secondZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 11); + CHECK(mark_count == 6, "three periodic events fired in two cycles"); + CHECK(mark_count == 6 + && !strcmp(marks[0].name, "early-first") + && !strcmp(marks[1].name, "early-second") + && !strcmp(marks[2].name, "late") + && !strcmp(marks[3].name, "early-first") + && !strcmp(marks[4].name, "early-second") + && !strcmp(marks[5].name, "late"), + "periodic tick order is chronological and same-tick order is stable"); +} + +static void test_mixed_periods_retain_generic_append_order(void) { + printf("mixed periodic schedules retain generic append order\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,15zPtwoZ"); + amy_add_message("H1,3,15zPthreeZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(mark_count == 5, "both mixed periods keep repeating"); + CHECK(mark_count == 5 + && !strcmp(marks[0].name, "two") + && !strcmp(marks[1].name, "three") + && !strcmp(marks[2].name, "two") + && !strcmp(marks[3].name, "two") + && !strcmp(marks[4].name, "three"), + "coincident mixed-period events retain caller append order"); +} + static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { printf("empty tick-zero reset remains distinct from a tick-zero event\n"); sequencer_reset(); @@ -676,6 +716,8 @@ int main(void) { test_legacy_c_event_wire_is_unchanged(); test_repeated_tag_and_one_shot_lifetime(); test_out_of_order_one_shots_keep_musical_order(); + test_uniform_periodic_events_keep_musical_order(); + test_mixed_periods_retain_generic_append_order(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_append_while_active_uses_copy_on_write(); From 97b970b358b4a0d0d1bd901cb4841f4ca9ddf887 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 22:43:07 +0200 Subject: [PATCH 096/112] Index active sequence executions --- src/sequencer.c | 112 +++++++++++++++++++++++++++---- tests/test_sequencer_sequences.c | 24 +++++++ 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 2dde7a53..53b57a45 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -106,6 +106,10 @@ typedef struct stored_sequence_execution_t { static stored_sequence_definition_t **stored_sequences = NULL; static stored_sequence_execution_t *sequence_executions = NULL; +static uint32_t *occupied_execution_bits = NULL; +static uint32_t *control_execution_bits = NULL; +static uint32_t *regular_execution_bits = NULL; +static uint32_t execution_bit_words = 0; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; @@ -275,6 +279,15 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( static void stored_sequence_execution_release_deferred( stored_sequence_execution_t *execution) { if (!execution->occupied) return; + uint32_t slot = (uint32_t)(execution - sequence_executions); + uint32_t word = slot / 32; + uint32_t mask = 1u << (slot % 32); + if (occupied_execution_bits != NULL) + occupied_execution_bits[word] &= ~mask; + if (control_execution_bits != NULL) + control_execution_bits[word] &= ~mask; + if (regular_execution_bits != NULL) + regular_execution_bits[word] &= ~mask; stored_sequence_definition_t *definition = execution->definition; memset(execution, 0, sizeof(*execution)); stored_sequence_definition_retire_locked(definition); @@ -306,6 +319,19 @@ static void stored_sequences_deinit(void) { free(sequence_executions); sequence_executions = NULL; } + if (occupied_execution_bits != NULL) { + free(occupied_execution_bits); + occupied_execution_bits = NULL; + } + if (control_execution_bits != NULL) { + free(control_execution_bits); + control_execution_bits = NULL; + } + if (regular_execution_bits != NULL) { + free(regular_execution_bits); + regular_execution_bits = NULL; + } + execution_bit_words = 0; max_stored_sequence_events = 0; max_stored_sequence_executions = 0; stored_sequence_event_bytes = 0; @@ -322,6 +348,8 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { size_t slot_bytes = 0; size_t execution_bytes = 0; + size_t execution_bit_bytes = 0; + execution_bit_words = executions / 32 + (executions % 32 != 0); if (!checked_array_size(max_sequences, sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), @@ -330,7 +358,9 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { &stored_sequence_order_bytes) || !checked_array_size(executions, sizeof(stored_sequence_execution_t), - &execution_bytes)) { + &execution_bytes) + || !checked_array_size(execution_bit_words, sizeof(uint32_t), + &execution_bit_bytes)) { fprintf(stderr, "stored sequence configuration exceeds addressable memory: " "tags=%" PRIu32 ", events=%" PRIu32 @@ -347,7 +377,21 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { execution_bytes, amy_global.config.ram_caps_synth); if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); - if (stored_sequences == NULL || sequence_executions == NULL) { + occupied_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + control_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + regular_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + if (occupied_execution_bits != NULL) + memset(occupied_execution_bits, 0, execution_bit_bytes); + if (control_execution_bits != NULL) + memset(control_execution_bits, 0, execution_bit_bytes); + if (regular_execution_bits != NULL) + memset(regular_execution_bits, 0, execution_bit_bytes); + if (stored_sequences == NULL || sequence_executions == NULL + || occupied_execution_bits == NULL || control_execution_bits == NULL + || regular_execution_bits == NULL) { amy_oom("stored sequences: out of memory\n"); stored_sequences_deinit(); return; @@ -891,9 +935,20 @@ uint8_t sequencer_sequence_control_with_origin( uint32_t start_tick = sequence_control_tick( alignment_period, origin, current_tick); stored_sequence_execution_t *available = NULL; - for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { - stored_sequence_execution_t *execution = &sequence_executions[i]; - if (!execution->occupied && available == NULL) available = execution; + uint32_t available_slot = 0; + for (uint32_t word = 0; + word < execution_bit_words && available == NULL; ++word) { + uint32_t occupied = occupied_execution_bits[word]; + if (occupied == UINT32_MAX) continue; + for (uint32_t bit = 0; bit < 32; ++bit) { + uint32_t slot_index = word * 32 + bit; + if (slot_index >= max_stored_sequence_executions) break; + if ((occupied & (1u << bit)) == 0) { + available_slot = slot_index; + available = &sequence_executions[slot_index]; + break; + } + } } if (available == NULL) { fprintf(stderr, "cannot start sequence %" PRIu32 @@ -906,6 +961,13 @@ uint8_t sequencer_sequence_control_with_origin( available->tag = tag; available->start_tick = start_tick; available->occupied = true; + uint32_t word = available_slot / 32; + uint32_t mask = 1u << (available_slot % 32); + occupied_execution_bits[word] |= mask; + if (available->definition->has_control_event) + control_execution_bits[word] |= mask; + if (available->definition->has_regular_event) + regular_execution_bits[word] |= mask; result = 1; } } @@ -1094,6 +1156,14 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, return controls && dispatched_control; } +static uint32_t stored_sequence_active_word(bool controls, uint32_t word) { + amy_grab_lock(); + uint32_t bits = controls ? control_execution_bits[word] + : regular_execution_bits[word]; + amy_release_lock(); + return bits; +} + static void stored_sequence_process_controls(uint32_t tick) { // A control can start an execution in a lower-numbered slot already passed // by this scan. Repeat until no due execution remains unvisited. At most one @@ -1103,19 +1173,37 @@ static void stored_sequence_process_controls(uint32_t tick) { bool progressed; do { progressed = false; - for (uint32_t i = 0; - i < max_stored_sequence_executions && visits_left != 0; ++i) { - if (stored_sequence_process_slot(i, tick, true)) { - visits_left--; - progressed = true; + for (uint32_t word = 0; + word < execution_bit_words && visits_left != 0; ++word) { + uint32_t bits = stored_sequence_active_word(true, word); + for (uint32_t bit = 0; + bit < 32 && bits != 0 && visits_left != 0; ++bit) { + uint32_t mask = 1u << bit; + if ((bits & mask) == 0) continue; + bits &= ~mask; + uint32_t slot = word * 32 + bit; + if (slot >= max_stored_sequence_executions) break; + if (stored_sequence_process_slot(slot, tick, true)) { + visits_left--; + progressed = true; + } } } } while (progressed && visits_left != 0); } static void stored_sequence_process_events(uint32_t tick) { - for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) - stored_sequence_process_slot(i, tick, false); + for (uint32_t word = 0; word < execution_bit_words; ++word) { + uint32_t bits = stored_sequence_active_word(false, word); + for (uint32_t bit = 0; bit < 32 && bits != 0; ++bit) { + uint32_t mask = 1u << bit; + if ((bits & mask) == 0) continue; + bits &= ~mask; + uint32_t slot = word * 32 + bit; + if (slot >= max_stored_sequence_executions) break; + stored_sequence_process_slot(slot, tick, false); + } + } } static void sequencer_process_tick(void) { diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 83858ff0..1de10137 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -699,6 +699,29 @@ static void test_disabled_configuration(void) { } } +static void test_execution_bitsets_cross_machine_words(void) { + printf("execution activity indexes cross 32-bit word boundaries\n"); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 2; + config.max_sequence_events = 1; + config.max_sequence_executions = 70; + amy_start(config); + clear_marks(); + amy_add_message("H0,0,1zPwideZ"); + int starts = 0; + for (int i = 0; i < 70; ++i) + starts += sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0); + CHECK(starts == 70, "all 70 execution slots are addressable"); + clock_to(sequencer_ticks() + 1); + CHECK(marks_named("wide") == 70, + "activity traversal reaches executions beyond slot 63"); + amy_stop(); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -741,6 +764,7 @@ int main(void) { test_wire_control_shape_is_strict(); amy_stop(); + test_execution_bitsets_cross_machine_words(); test_disabled_configuration(); if (failures) { printf("\n%d check(s) FAILED\n", failures); From 16b37fe867ba21d5a3ab74010db1d196b44f5556 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 7 Sep 2026 22:48:58 +0200 Subject: [PATCH 097/112] Keep sequence runtime state in render memory --- src/sequencer.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sequencer.c b/src/sequencer.c index 53b57a45..39ac5d9b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -374,7 +374,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { if (stored_sequences != NULL) memset(stored_sequences, 0, slot_bytes); sequence_executions = (stored_sequence_execution_t *)stored_sequence_allocate( - execution_bytes, amy_global.config.ram_caps_synth); + execution_bytes, amy_global.config.ram_caps_block); if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); occupied_execution_bits = (uint32_t *)stored_sequence_allocate( From b959b86ec23769976572b93f476ec18fe374a723 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 00:37:11 +0200 Subject: [PATCH 098/112] Absorb isolated ESP render jitter in DMA debt --- src/amy.h | 3 +++ src/i2s.c | 78 +++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/amy.h b/src/amy.h index 80dff420..b182dbb4 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1103,6 +1103,9 @@ typedef struct amy_esp_load_diagnostic { uint32_t missed_executed_delta_max; uint64_t missed_audible_osc_sum[2]; uint32_t missed_audible_osc_max[2]; + uint64_t i2s_unpaced_blocks; + uint32_t overload_debt_max_us; + uint32_t overload_yields; uint32_t blocks; } amy_esp_load_diagnostic_t; diff --git a/src/i2s.c b/src/i2s.c index 4e9d0591..e44cc36d 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -302,7 +302,10 @@ extern uint32_t amy_last_sequence_tick_count; static void esp_load_diagnostic_record(uint32_t execute_us, uint32_t render_us, uint32_t fill_us, - uint32_t total_us) { + uint32_t total_us, + uint32_t blocked_us, + uint32_t overload_debt_us, + bool overload_yielded) { amy_esp_load_diagnostic_t *stats = &esp_load_diagnostic; ++esp_load_diagnostic_seq; __sync_synchronize(); @@ -326,6 +329,10 @@ static void esp_load_diagnostic_record(uint32_t execute_us, stats->fill_sum_us += fill_us; stats->total_sum_us += total_us; stats->executed_delta_sum += amy_last_executed_delta_count; + if (blocked_us < 150) ++stats->i2s_unpaced_blocks; + if (overload_debt_us > stats->overload_debt_max_us) + stats->overload_debt_max_us = overload_debt_us; + if (overload_yielded) ++stats->overload_yields; if (amy_last_executed_delta_count > stats->executed_delta_max) stats->executed_delta_max = amy_last_executed_delta_count; if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; @@ -435,6 +442,11 @@ void amy_esp_load_diagnostics_print(void) { uint32_t interval_misses = stats.total_deadline_misses - esp_load_print_baseline.total_deadline_misses; + uint64_t interval_unpaced = + stats.i2s_unpaced_blocks + - esp_load_print_baseline.i2s_unpaced_blocks; + uint32_t interval_yields = + stats.overload_yields - esp_load_print_baseline.overload_yields; uint64_t interval_missed_execute_us = stats.missed_execute_sum_us - esp_load_print_baseline.missed_execute_sum_us; @@ -503,6 +515,9 @@ void amy_esp_load_diagnostics_print(void) { "interval_blocks=%u " "interval_avg_us execute=%u render=%u fill=%u total=%u " "interval_near_deadline=%u interval_deadline_misses=%u " + "i2s_unpaced=%" PRIu64 " interval_i2s_unpaced=%" PRIu64 " " + "overload_debt_max_us=%u overload_yields=%u " + "interval_overload_yields=%u " "miss_avg_us execute=%u render=%u fill=%u total=%u " "miss_stage_max_us execute=%u render=%u fill=%u " "interval_render_core_avg_us core0=%u core1=%u " @@ -539,6 +554,11 @@ void amy_esp_load_diagnostics_print(void) { ? interval_fill_us / interval_blocks : 0), (unsigned)(interval_blocks ? interval_total_us / interval_blocks : 0), (unsigned)interval_near, (unsigned)interval_misses, + stats.i2s_unpaced_blocks, + interval_unpaced, + (unsigned)stats.overload_debt_max_us, + (unsigned)stats.overload_yields, + (unsigned)interval_yields, (unsigned)(interval_misses ? interval_missed_execute_us / interval_misses : 0), (unsigned)(interval_misses @@ -720,6 +740,11 @@ static int32_t _rl_render_us = 0; void esp_fill_audio_buffer_task(void *pvParameters) { (void)pvParameters; + // A single expensive block is not proof of sustained overload. DMA can + // absorb that jitter, provided a cheaper following block earns the time + // back. Track only the unpaced render-time debt so the overload escape + // below is reserved for a workload whose average really cannot keep up. + uint32_t overload_debt_us = 0; while(1) { int64_t t; uint32_t blocked_us = 0; @@ -760,9 +785,6 @@ void esp_fill_audio_buffer_task(void *pvParameters) { uint32_t fill_us = (uint32_t)(amy_get_us() - stage_started_us); #endif uint32_t busy_us = (uint32_t)(amy_get_us() - t); -#ifdef AMY_ESP_LOAD_DIAGNOSTIC - esp_load_diagnostic_record(execute_us, render_us, fill_us, busy_us); -#endif AMY_PROFILE_STOP(AMY_ESP_FILL_BUFFER) last_audio_buffer = block; @@ -797,18 +819,42 @@ void esp_fill_audio_buffer_task(void *pvParameters) { // i2s DMA write (or the update-sync wait) above, which is when lower-priority // tasks on this core get to run. amy_overload_check(busy_us); - // If rendering genuinely can't keep up (a block costs at least its own - // real-time budget) AND the audio output didn't block, we're past - // overloaded, and this max-priority task would starve everything else - // on this core (USB, MIDI, the host app). Audio is already breaking - // up, so give the rest of the system a tick. - // - // Both conditions matter: with a small DMA ring a healthy just-in-time - // iteration can also see blocked_us == 0, and one tick here (10 ms at - // a 100 Hz tick rate) can be bigger than the whole ring -- a single - // spurious delay underruns it, the drained ring makes the next write - // not block either, and the delay re-arms forever (#1118). - if (busy_us >= AMY_BLOCK_US && blocked_us < 150) vTaskDelay(1); + // A blocked write means DMA is full and therefore clears any prior + // render-time debt. While the write is unpaced, accumulate only the + // amount over budget and repay it with subsequent under-budget blocks. + // This lets DMA absorb isolated sequencer/event bursts instead of + // turning each one into a much larger scheduler-induced dropout. + if (blocked_us >= 150) { + overload_debt_us = 0; + } else if (busy_us > AMY_BLOCK_US) { + uint32_t overrun_us = busy_us - AMY_BLOCK_US; + if (UINT32_MAX - overload_debt_us < overrun_us) + overload_debt_us = UINT32_MAX; + else + overload_debt_us += overrun_us; + } else { + uint32_t recovered_us = AMY_BLOCK_US - busy_us; + overload_debt_us = recovered_us >= overload_debt_us + ? 0 : overload_debt_us - recovered_us; + } + + // Yield only after sustained unpaced overload has accumulated at least + // the delay we are about to impose. At that point audio is already + // falling behind on average; yielding prevents this max-priority loop + // from starving USB/MIDI and the host application. + const uint32_t scheduler_tick_us = + (1000000u + configTICK_RATE_HZ - 1u) / configTICK_RATE_HZ; + bool overload_yielded = overload_debt_us >= scheduler_tick_us; + uint32_t recorded_overload_debt_us = overload_debt_us; + if (overload_yielded) { + overload_debt_us = 0; + vTaskDelay(1); + } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_load_diagnostic_record(execute_us, render_us, fill_us, busy_us, + blocked_us, recorded_overload_debt_us, + overload_yielded); +#endif } } From e9a96c20da31b4130a243bf75b984408c1dff5e0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 00:55:58 +0200 Subject: [PATCH 099/112] Use portable fences for diagnostics --- src/amy.c | 8 ++++---- src/amy.h | 8 ++++++++ src/i2s.c | 8 ++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/amy.c b/src/amy.c index 0736f123..8c6f2b91 100644 --- a/src/amy.c +++ b/src/amy.c @@ -432,13 +432,13 @@ static void reverb_diagnostic_record(volatile uint32_t *seq, amy_reverb_diagnostic_t *diagnostic, uint32_t elapsed_us) { ++*seq; - __sync_synchronize(); + amy_memory_fence(); ++diagnostic->calls; diagnostic->total_us += elapsed_us; if (elapsed_us > diagnostic->max_us) diagnostic->max_us = elapsed_us; if (elapsed_us > AMY_BLOCK_US) ++diagnostic->deadline_misses; diagnostic->core_mask |= reverb_current_core_mask(); - __sync_synchronize(); + amy_memory_fence(); ++*seq; } @@ -449,9 +449,9 @@ static bool reverb_diagnostic_snapshot(volatile uint32_t *seq, for (int attempt = 0; attempt < 8; ++attempt) { uint32_t before = *seq; if (before & 1u) continue; - __sync_synchronize(); + amy_memory_fence(); *result = *source; - __sync_synchronize(); + amy_memory_fence(); if (before == *seq) return true; } return false; diff --git a/src/amy.h b/src/amy.h index b182dbb4..5eea3fdf 100644 --- a/src/amy.h +++ b/src/amy.h @@ -39,6 +39,14 @@ extern pthread_mutex_t amy_queue_lock; #endif #endif +static inline void amy_memory_fence(void) { +#ifdef _WIN32 + MemoryBarrier(); +#else + __sync_synchronize(); +#endif +} + #ifdef ESP_PLATFORM // PRIu8 is normally hu, but the clang we're using doesn't seem to understand it. #undef PRIu8 diff --git a/src/i2s.c b/src/i2s.c index e44cc36d..d80ceb03 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -308,7 +308,7 @@ static void esp_load_diagnostic_record(uint32_t execute_us, bool overload_yielded) { amy_esp_load_diagnostic_t *stats = &esp_load_diagnostic; ++esp_load_diagnostic_seq; - __sync_synchronize(); + amy_memory_fence(); stats->execute_sum_us += execute_us; stats->sequencer_sum_us += amy_last_sequencer_us; stats->flush_sum_us += amy_last_flush_us; @@ -398,7 +398,7 @@ static void esp_load_diagnostic_record(uint32_t execute_us, } } ++stats->blocks; - __sync_synchronize(); + amy_memory_fence(); ++esp_load_diagnostic_seq; } @@ -407,9 +407,9 @@ bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { for (int attempt = 0; attempt < 8; ++attempt) { uint32_t before = esp_load_diagnostic_seq; if (before & 1u) continue; - __sync_synchronize(); + amy_memory_fence(); *result = esp_load_diagnostic; - __sync_synchronize(); + amy_memory_fence(); if (before == esp_load_diagnostic_seq) return true; } return false; From 40a76fb5851c21d2ca13f695049e32ae1b8370b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:01:32 +0000 Subject: [PATCH 100/112] Bump version to 1.2.164, rebuild web + Godot API --- amy/__init__.py | 2 +- docs/amy.js | 4 ++-- library.properties | 2 +- pyproject.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..2a7cc1a7 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -13,7 +13,7 @@ # .github/workflows/release.yml, which rewrites the line below in the same commit # it tags -- so amy.version always matches the release tag it shipped in. Edit # with the workflow, not by hand. -version = '1.2.163' +version = '1.2.164' # BEGIN GENERATED - scripts/gen_amy_c_api.py # One backend resolver per C API function: prefer the CPython c_amy diff --git a/docs/amy.js b/docs/amy.js index c2755adf..707ee2db 100644 --- a/docs/amy.js +++ b/docs/amy.js @@ -440,7 +440,6 @@ function amy_send(params, log) { // Constants from amy/constants.py (mirrors amy.SINE, amy.FILTER_LPF, etc.) var AMY = { MAX_FILENAME_LEN: 127, - AMY_BLOCK_SIZE: 256, BLOCK_SIZE_BITS: 8, AMY_SAMPLE_RATE: 44100, PCM_AMY_SAMPLE_RATE: 22050, @@ -595,7 +594,8 @@ var AMY = { AMYBOARD_MIDI_IN: 21, AMY_AUDIO_DEVICE_OUT: 0, AMY_AUDIO_DEVICE_IN: 1, - AMY_NUM_MIDI_CHANNELS: 16 + AMY_NUM_MIDI_CHANNELS: 16, + AMY_BLOCK_SIZE: 256 }; if (typeof globalThis !== "undefined") { diff --git a/library.properties b/library.properties index a5f94b67..e413db6d 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=AMY Synthesizer -version=1.2.163 +version=1.2.164 author=Brian Whitman , DAn Ellis maintainer=Brian Whitman sentence=AMY, the Music Synthesizer Library diff --git a/pyproject.toml b/pyproject.toml index 51a808f1..90620e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amy" -version = "1.2.163" +version = "1.2.164" description = "AMY synthesizer" readme = "README.md" dependencies = ['numpy', 'soundfile'] From f1c7f318cdaf79eb8918f46891eee6d09fc97755 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 18:31:45 +0200 Subject: [PATCH 101/112] Reuse AMY bus summation for shared routing --- src/amy.c | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/amy.c b/src/amy.c index 8c6f2b91..8e928a01 100644 --- a/src/amy.c +++ b/src/amy.c @@ -2444,6 +2444,23 @@ void mix_with_pan(SAMPLE *stereo_dest, SAMPLE *mono_src, float pan_start, float AMY_PROFILE_STOP(MIX_WITH_PAN) } +// The common bus-routing primitive: scale one non-interleaved block into a +// destination block. replace=true starts a mix; false adds another member of +// the same weighted subset. Keeping dry mix, aux sends and effect returns on +// this one path preserves their fixed-point summation semantics and gives +// targets one kernel to optimize. +static AMY_IRAM_ATTR void mix_bus_block(SAMPLE *dest, const SAMPLE *source, + SAMPLE gain, bool replace) { + int samples = AMY_BLOCK_SIZE * AMY_NCHANS; + if (replace) { + for (int i = 0; i < samples; ++i) + dest[i] = MUL8_SS(gain, source[i]); + } else { + for (int i = 0; i < samples; ++i) + dest[i] += MUL8_SS(gain, source[i]); + } +} + // Test if the specified osc is in its release phase (i.e., note-off has been received). #define OSC_IN_RELEASE(osc) (AMY_IS_SET(synth[osc]->note_off_clock)) @@ -2776,6 +2793,11 @@ int16_t * amy_fill_buffer() { // Apply global processing only if there is some signal. //if (max_val > 0) { // NO - see #629 // apply the eq filters if there is some signal and EQ is non-default. + // Global volume is the existing per-bus gain for both the dry summation + // and post-fader aux subsets. Compute it once for this block. + SAMPLE *volume_scale = amy_global.volume_scale; + for (int bus = 0; bus <= amy_global.highest_bus; ++bus) + volume_scale[bus] = MUL4_SS(F2S(0.1f), F2S(amy_global.volume[bus])); for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { if (amy_global.reverb_rooms[room].block != NULL) bzero(amy_global.reverb_rooms[room].block, @@ -2818,12 +2840,9 @@ int16_t * amy_fill_buffer() { uint16_t room = amy_global.bus[bus]->reverb_send_room; SAMPLE send = amy_global.bus[bus]->reverb_send_level; if (room < amy_global.config.max_reverb_rooms && send != 0) { - SAMPLE gain = MUL8_SS(send, - MUL4_SS(F2S(0.1f), - F2S(amy_global.volume[bus]))); + SAMPLE gain = MUL8_SS(send, volume_scale[bus]); SAMPLE *room_block = amy_global.reverb_rooms[room].block; - for (int16_t i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) - room_block[i] += MUL8_SS(gain, fbl[0][bus][i]); + mix_bus_block(room_block, fbl[0][bus], gain, false); } if(AMY_HAS_REVERB) { // apply per-bus reverb. @@ -2868,23 +2887,21 @@ int16_t * amy_fill_buffer() { &reverb_stage_diagnostic, (uint32_t)(amy_get_us() - reverb_stage_started)); } - // global volume is supposed to max out at 10, so scale by 0.1. - SAMPLE *volume_scale = amy_global.volume_scale; // max_buses long, allocated at start. + // Reuse bus 0's now-consumed render buffer as the standard AMY master bus. + // The dry buses and wet returns are just weighted subsets through the same + // kernel; no second application-specific mixer or extra block copy exists. + SAMPLE *master_bus = fbl[0][AMY_DEFAULT_BUS]; for (int bus = 0; bus <= amy_global.highest_bus; ++bus) - volume_scale[bus] = MUL4_SS(F2S(0.1f), F2S(amy_global.volume[bus])); + mix_bus_block(master_bus, fbl[0][bus], volume_scale[bus], bus == 0); + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + SAMPLE *room_block = amy_global.reverb_rooms[room].block; + if (room_block != NULL) + mix_bus_block(master_bus, room_block, F2S(1.0f), false); + } + for(int16_t i=0; i < AMY_BLOCK_SIZE; ++i) { for (int16_t c=0; c < AMY_NCHANS; ++c) { - - SAMPLE fsample = 0; - for (int bus = 0; bus <= amy_global.highest_bus; ++bus) { - // Convert the mixed sample into the int16 range, applying overall gain. - fsample += MUL8_SS(volume_scale[bus], fbl[0][bus][i + c * AMY_BLOCK_SIZE]); - } - for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { - SAMPLE *room_block = amy_global.reverb_rooms[room].block; - if (room_block != NULL) - fsample += room_block[i + c * AMY_BLOCK_SIZE]; - } + SAMPLE fsample = master_bus[i + c * AMY_BLOCK_SIZE]; // One-pole high-pass filter to remove large low-frequency excursions from // some FM patches. b = [1 -1]; a = [1 -0.995] From 3b990b4db2b7be0ed7c989f9c1c6b8b9ad0fb7c9 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 19:38:11 +0200 Subject: [PATCH 102/112] Process shared reverb bus subsets on matching cores --- src/amy.c | 184 ++++++++++++++++++++++++++++++++++-------------------- src/amy.h | 2 + src/i2s.c | 19 +++++- 3 files changed, 135 insertions(+), 70 deletions(-) diff --git a/src/amy.c b/src/amy.c index 8e928a01..c134bb0a 100644 --- a/src/amy.c +++ b/src/amy.c @@ -2741,6 +2741,104 @@ void amy_block_processed(void) { #endif } +// Process AMY's existing effect chain for one bus, then add that bus to its +// configured shared-reverb input. Buses are independent up to this point, so +// a platform may divide complete subsets over render cores without changing +// the DSP or routing model. +static AMY_IRAM_ATTR void amy_process_bus_builtin(uint16_t bus) { + if (amy_global.bus[bus]->dist.stages) + dist_process_bus(bus, fbl[0][bus]); + + if (amy_global.bus[bus]->eq.eq[0] != F2S(1.0f) + || amy_global.bus[bus]->eq.eq[1] != F2S(1.0f) + || amy_global.bus[bus]->eq.eq[2] != F2S(1.0f)) + parametric_eq_process(bus, fbl[0][bus]); + + if (AMY_HAS_CHORUS + && amy_global.bus[bus]->chorus.level > 0 + && amy_global.bus[bus]->chorus.chorus_delay_lines[0] != NULL) { + SAMPLE scale = F2S(1.0f); + for (int16_t c = 0; c < AMY_NCHANS; ++c) { + apply_variable_delay( + fbl[0][bus] + c * AMY_BLOCK_SIZE, + amy_global.bus[bus]->chorus.chorus_delay_lines[c], + amy_global.bus[bus]->chorus.delay_mod, scale, + amy_global.bus[bus]->chorus.level, 0); + scale = -scale; + } + } + + if (AMY_HAS_ECHO + && amy_global.bus[bus]->echo.level > 0 + && amy_global.bus[bus]->echo.echo_delay_lines[0] != NULL) { + for (int16_t c = 0; c < AMY_NCHANS; ++c) + apply_fixed_delay( + fbl[0][bus] + c * AMY_BLOCK_SIZE, + amy_global.bus[bus]->echo.echo_delay_lines[c], + amy_global.bus[bus]->echo.delay_samples, + amy_global.bus[bus]->echo.level, + amy_global.bus[bus]->echo.feedback, + amy_global.bus[bus]->echo.filter_coef); + } + + uint16_t room = amy_global.bus[bus]->reverb_send_room; + SAMPLE send = amy_global.bus[bus]->reverb_send_level; + if (room < amy_global.config.max_reverb_rooms && send != 0) { + SAMPLE gain = MUL8_SS(send, amy_global.volume_scale[bus]); + mix_bus_block(amy_global.reverb_rooms[room].block, + fbl[0][bus], gain, false); + } + + if (AMY_HAS_REVERB + && amy_global.bus[bus]->reverb.level > 0 + && amy_global.bus[bus]->reverb.rev != NULL + && amy_global.bus[bus]->reverb.rev->delay_1 != NULL) { + if (AMY_NCHANS == 1) { + stereo_reverb(amy_global.bus[bus]->reverb.rev, + fbl[0][bus], NULL, fbl[0][bus], NULL, + AMY_BLOCK_SIZE, + amy_global.bus[bus]->reverb.level); + } else { + stereo_reverb(amy_global.bus[bus]->reverb.rev, + fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, + fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, + AMY_BLOCK_SIZE, + amy_global.bus[bus]->reverb.level); + } + } +} + +static uint8_t amy_bus_partition(uint16_t bus, uint8_t partitions) { + uint16_t room = amy_global.bus[bus]->reverb_send_room; + if (room < amy_global.config.max_reverb_rooms) + return (uint8_t)(room % partitions); + return (uint8_t)(bus % partitions); +} + +void AMY_IRAM_ATTR amy_process_bus_subset(uint8_t partition, + uint8_t partitions) { + if (partitions == 0 || partitions > AMY_MAX_CORES + || partition >= partitions) return; + for (uint16_t bus = 0; bus <= amy_global.highest_bus; ++bus) { + if (amy_bus_partition(bus, partitions) == partition) + amy_process_bus_builtin(bus); + } +} + +static void amy_process_bus_post_hook(uint16_t bus) { + if (amy_global.config.amy_external_bus_postprocess_hook != NULL) + amy_global.config.amy_external_bus_postprocess_hook( + bus, fbl[0][bus], AMY_BLOCK_SIZE); +#ifdef __EMSCRIPTEN__ + EM_ASM({ + if (typeof amy_bus_postprocess_js_hook === 'function') { + if (!Module.wasmMemory) Module.wasmMemory = wasmMemory; + amy_bus_postprocess_js_hook($0, $1, $2, $3, Module); + } + }, bus, fbl[0][bus], AMY_BLOCK_SIZE, AMY_NCHANS); +#endif +} + int16_t * amy_fill_buffer() { AMY_PROFILE_START(AMY_FILL_BUFFER) // A requested timebase reset lands here, between blocks on the render @@ -2803,76 +2901,24 @@ int16_t * amy_fill_buffer() { bzero(amy_global.reverb_rooms[room].block, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); } - for (int bus=0; bus <= amy_global.highest_bus; ++bus) { - // Per-bus distortion, first so echo/reverb take clean tails. - if (amy_global.bus[bus]->dist.stages) { - dist_process_bus(bus, fbl[0][bus]); - } - // Per-bus EQ - if (amy_global.bus[bus]->eq.eq[0] != F2S(1.0f) || amy_global.bus[bus]->eq.eq[1] != F2S(1.0f) || amy_global.bus[bus]->eq.eq[2] != F2S(1.0f)) { - parametric_eq_process(bus, fbl[0][bus]); - } - if(AMY_HAS_CHORUS) { - // apply per-bus chorus. - if(amy_global.bus[bus]->chorus.level > 0 && amy_global.bus[bus]->chorus.chorus_delay_lines[0] != NULL) { - // apply time-varying delays to both chans. - // delay_mod_val, the modulated delay amount, is set up before calling render_*. - SAMPLE scale = F2S(1.0f); - for (int16_t c=0; c < AMY_NCHANS; ++c) { - apply_variable_delay(fbl[0][bus] + c * AMY_BLOCK_SIZE, amy_global.bus[bus]->chorus.chorus_delay_lines[c], - amy_global.bus[bus]->chorus.delay_mod, scale, amy_global.bus[bus]->chorus.level, 0); - // flip delay direction for alternating channels. - scale = -scale; - } - } - } - //} - if (AMY_HAS_ECHO) { - // Apply per-bus echo. - if (amy_global.bus[bus]->echo.level > 0 && amy_global.bus[bus]->echo.echo_delay_lines[0] != NULL ) { - for (int16_t c=0; c < AMY_NCHANS; ++c) { - apply_fixed_delay(fbl[0][bus] + c * AMY_BLOCK_SIZE, amy_global.bus[bus]->echo.echo_delay_lines[c], amy_global.bus[bus]->echo.delay_samples, amy_global.bus[bus]->echo.level, amy_global.bus[bus]->echo.feedback, amy_global.bus[bus]->echo.filter_coef); - } - } - } - // Shared reverbs are post-fader aux sends. The source bus remains in - // the dry mix; only its scaled copy enters the selected room. - uint16_t room = amy_global.bus[bus]->reverb_send_room; - SAMPLE send = amy_global.bus[bus]->reverb_send_level; - if (room < amy_global.config.max_reverb_rooms && send != 0) { - SAMPLE gain = MUL8_SS(send, volume_scale[bus]); - SAMPLE *room_block = amy_global.reverb_rooms[room].block; - mix_bus_block(room_block, fbl[0][bus], gain, false); - } - if(AMY_HAS_REVERB) { - // apply per-bus reverb. - if(amy_global.bus[bus]->reverb.level > 0 && amy_global.bus[bus]->reverb.rev != NULL && amy_global.bus[bus]->reverb.rev->delay_1 != NULL) { - if(AMY_NCHANS == 1) { - stereo_reverb(amy_global.bus[bus]->reverb.rev, fbl[0][bus], NULL, fbl[0][bus], NULL, AMY_BLOCK_SIZE, amy_global.bus[bus]->reverb.level); - } else { - stereo_reverb(amy_global.bus[bus]->reverb.rev, fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, AMY_BLOCK_SIZE, amy_global.bus[bus]->reverb.level); - } - } + bool serial_bus_hooks = + amy_global.config.amy_external_bus_postprocess_hook != NULL; +#ifdef __EMSCRIPTEN__ + // A JS hook can only be discovered inside the worklet call itself. + serial_bus_hooks = true; +#endif + if (!serial_bus_hooks) { +#ifdef ESP_PLATFORM + amy_platform_process_bus_subsets(); +#else + amy_process_bus_subset(0, 1); +#endif + } else { + for (int bus = 0; bus <= amy_global.highest_bus; ++bus) { + amy_process_bus_builtin(bus); + amy_process_bus_post_hook(bus); } - if(amy_global.config.amy_external_bus_postprocess_hook != NULL) { - amy_global.config.amy_external_bus_postprocess_hook(bus, fbl[0][bus], AMY_BLOCK_SIZE); - } - #ifdef __EMSCRIPTEN__ - // Web version of the bus postprocess hook (see the hooks table in - // docs/api.md): a JS function may process the bus buffer in place - // (buf is nchans sequential channel blocks of len samples). Runs on - // the AudioWorklet thread; Module is this scope's instance (its - // wasmMemory/exports let hook JS reach this module's memory). - EM_ASM({ - if (typeof amy_bus_postprocess_js_hook === 'function') { - // In worker/worklet scopes the glue never attaches the - // wasmMemory runtime export to Module; hook JS needs it. - if (!Module.wasmMemory) Module.wasmMemory = wasmMemory; - amy_bus_postprocess_js_hook($0, $1, $2, $3, Module); - } - }, bus, fbl[0][bus], AMY_BLOCK_SIZE, AMY_NCHANS); - #endif - } // end of per-bus FX + } if (amy_global.config.max_reverb_rooms > 0) { uint64_t reverb_stage_started = diff --git a/src/amy.h b/src/amy.h index 3b1cf781..7999fbf4 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1278,7 +1278,9 @@ void config_reverb_room(uint16_t room, float level, float liveness, void config_reverb_send(uint16_t bus, uint16_t room, float level); void amy_process_reverb_room(uint16_t room); void amy_process_reverb_rooms(void); +void amy_process_bus_subset(uint8_t partition, uint8_t partitions); #ifdef ESP_PLATFORM +void amy_platform_process_bus_subsets(void); void amy_platform_process_reverb_rooms(void); #endif bool amy_reverb_diagnostics_get(uint16_t room, diff --git a/src/i2s.c b/src/i2s.c index d80ceb03..f6c8a47e 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -280,6 +280,7 @@ static SemaphoreHandle_t esp_render_done_sem = NULL; typedef enum { AMY_WORKER_RENDER_OSCS = 0, + AMY_WORKER_BUS_SUBSET_0, AMY_WORKER_REVERB_ROOM_0, } amy_worker_job_t; @@ -643,7 +644,9 @@ void amy_esp_load_diagnostics_print(void) { void esp_render_task( void * pvParameters) { while(1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // from esp_render_on_cores - if (amy_worker_job == AMY_WORKER_REVERB_ROOM_0) + if (amy_worker_job == AMY_WORKER_BUS_SUBSET_0) + amy_process_bus_subset(0, 2); + else if (amy_worker_job == AMY_WORKER_REVERB_ROOM_0) amy_process_reverb_room(0); else { #ifdef AMY_ESP_LOAD_DIAGNOSTIC @@ -683,6 +686,20 @@ void esp_render_on_cores() { } } +void amy_platform_process_bus_subsets(void) { + if (amy_global.config.platform.multicore) { + // Keep every room's complete source subset on one core. This avoids + // shared accumulator writes and leaves its room input hot for the + // matching reverb job that follows. + amy_worker_job = AMY_WORKER_BUS_SUBSET_0; + xTaskNotifyGive(amy_render_handle); + amy_process_bus_subset(1, 2); + xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); + } else { + amy_process_bus_subset(0, 1); + } +} + void amy_platform_process_reverb_rooms(void) { uint16_t rooms = amy_global.config.max_reverb_rooms; if (rooms == 0) return; From ad8d40c5bc1c3ad38d40b139034268669c98ae82 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 21:28:09 +0200 Subject: [PATCH 103/112] Follow upstream block size build option --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ccddda6d..ac910b80 100644 --- a/Makefile +++ b/Makefile @@ -183,7 +183,7 @@ build-config-test: tests/test_build_config.c -o tests/test_build_config_default ./tests/test_build_config_default $(CC) $(CFLAGS) -Isrc \ - -DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 \ + -DBLOCK_SIZE_BITS=7 -DAMY_SAMPLE_RATE=48000 \ -DEXPECT_AMY_BLOCK_SIZE=128 -DEXPECT_BLOCK_SIZE_BITS=7 \ -DEXPECT_AMY_SAMPLE_RATE=48000 \ tests/test_build_config.c -o tests/test_build_config_embedded From b6266f33f585e46c3d208c4183aab667331dac93 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 21:30:30 +0200 Subject: [PATCH 104/112] Keep embedded block size recipes compatible --- Makefile | 8 +++++++- src/amy.h | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ac910b80..1439a8d4 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,12 @@ build-config-test: -DEXPECT_AMY_SAMPLE_RATE=48000 \ tests/test_build_config.c -o tests/test_build_config_embedded ./tests/test_build_config_embedded + $(CC) $(CFLAGS) -Isrc \ + -DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 \ + -DEXPECT_AMY_BLOCK_SIZE=128 -DEXPECT_BLOCK_SIZE_BITS=7 \ + -DEXPECT_AMY_SAMPLE_RATE=48000 \ + tests/test_build_config.c -o tests/test_build_config_legacy + ./tests/test_build_config_legacy ctest: build-config-test $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done @@ -271,4 +277,4 @@ clean: -rm -f amy/constants.py -rm -f $(TARGET) -rm -f tests/*.o $(CTESTS) - -rm -f tests/test_build_config_default tests/test_build_config_embedded + -rm -f tests/test_build_config_default tests/test_build_config_embedded tests/test_build_config_legacy diff --git a/src/amy.h b/src/amy.h index 7999fbf4..c4874ecb 100644 --- a/src/amy.h +++ b/src/amy.h @@ -84,7 +84,25 @@ extern const uint32_t pcm_wavetable_len; // The block is a POWER OF TWO -- the per-block amplitude and pan ramps are // SHIFTR(delta, BLOCK_SIZE_BITS), not a divide -- so a host chooses it in // BITS, at compile time: -DBLOCK_SIZE_BITS=7 is a 128-sample block, 6 is 64. +// AMY_BLOCK_SIZE remains accepted for existing embedded build recipes. // Left alone it is 8 (256 samples), or 7 (128) on Daisy, exactly as before. +#if defined(AMY_BLOCK_SIZE) && !defined(BLOCK_SIZE_BITS) +#if AMY_BLOCK_SIZE == 32 +#define BLOCK_SIZE_BITS 5 +#elif AMY_BLOCK_SIZE == 64 +#define BLOCK_SIZE_BITS 6 +#elif AMY_BLOCK_SIZE == 128 +#define BLOCK_SIZE_BITS 7 +#elif AMY_BLOCK_SIZE == 256 +#define BLOCK_SIZE_BITS 8 +#elif AMY_BLOCK_SIZE == 512 +#define BLOCK_SIZE_BITS 9 +#elif AMY_BLOCK_SIZE == 1024 +#define BLOCK_SIZE_BITS 10 +#else +#error "AMY_BLOCK_SIZE must be a power of two from 32 through 1024" +#endif +#endif #ifndef BLOCK_SIZE_BITS #ifdef AMY_DAISY #define BLOCK_SIZE_BITS 7 @@ -95,7 +113,11 @@ extern const uint32_t pcm_wavetable_len; #if BLOCK_SIZE_BITS < 5 || BLOCK_SIZE_BITS > 10 #error "BLOCK_SIZE_BITS must be 5..10 (a block of 32..1024 samples)" #endif +#ifndef AMY_BLOCK_SIZE #define AMY_BLOCK_SIZE (1 << BLOCK_SIZE_BITS) +#elif AMY_BLOCK_SIZE != (1 << BLOCK_SIZE_BITS) +#error "AMY_BLOCK_SIZE and BLOCK_SIZE_BITS describe different block sizes" +#endif #ifndef AMY_SAMPLE_RATE #if defined(AMY_DAISY) || defined(__EMSCRIPTEN__) From 2af2c5d4efc1b6501679832cabac93bd057a61f9 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 21:32:42 +0200 Subject: [PATCH 105/112] Cover dual shared rooms and bus hook fallback --- tests/test_shared_reverb.c | 62 +++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c index b432663f..99257eda 100644 --- a/tests/test_shared_reverb.c +++ b/tests/test_shared_reverb.c @@ -10,6 +10,7 @@ static int failures; static uint8_t room_memory[2][ROOM_BYTES]; static void *room_arenas[2] = { room_memory[0], room_memory[1] }; +static unsigned bus_hook_calls[4]; #define CHECK(c, fmt, ...) do { \ if (c) printf(" ok " fmt "\n", ##__VA_ARGS__); \ @@ -22,7 +23,14 @@ static bool inside_room(const void *pointer, int room) { return p >= first && p < first + ROOM_BYTES; } -static void start_shared(void) { +static void count_bus_hook(uint16_t bus, SAMPLE *buf, uint16_t len) { + (void)buf; + CHECK(bus < 4, "postprocess hook bus is in range (%u)", bus); + CHECK(len == AMY_BLOCK_SIZE, "postprocess hook receives one block (%u)", len); + if (bus < 4) ++bus_hook_calls[bus]; +} + +static void start_shared_with_hook(bool hook) { amy_stop(); amy_config_t config = amy_default_config(); config.features.startup_bleep = 0; @@ -31,9 +39,12 @@ static void start_shared(void) { config.reverb_room_memory = room_arenas; config.reverb_room_memory_bytes = ROOM_BYTES; config.reverb_diagnostics = 1; + config.amy_external_bus_postprocess_hook = hook ? count_bus_hook : NULL; amy_start(config); } +static void start_shared(void) { start_shared_with_hook(false); } + static void test_arena_and_wire_routing(void) { puts("fixed rooms and hR/hS routing"); start_shared(); @@ -72,26 +83,46 @@ static void test_audio_and_deferred_diagnostics(void) { // Configured storage is cheap while its return level is disabled: it must // not walk the delay lines merely because a room exists. for (int i = 0; i < 2; ++i) amy_simple_fill_buffer(); - amy_reverb_diagnostic_t room, stage; - CHECK(amy_reverb_diagnostics_get(0, &room), "disabled-room snapshot succeeds"); - CHECK(room.calls == 0, "disabled room performs no DSP work"); - - amy_add_message("hR0,0.8,0.85,0.5,3000Zy0hS0,1Zv0w0n60l1Z"); + amy_reverb_diagnostic_t room0, room1, stage; + CHECK(amy_reverb_diagnostics_get(0, &room0), "disabled-room snapshot succeeds"); + CHECK(room0.calls == 0, "disabled room performs no DSP work"); + + amy_add_message("hR0,0.8,0.85,0.5,3000Z" + "hR1,0.6,0.75,0.4,2600Z" + "y0hS0,1Zy1hS1,0.7Z" + "v0w0n60l1y0Zv1w0n67l1y1Z"); for (int i = 0; i < 48; ++i) amy_simple_fill_buffer(); - CHECK(amy_reverb_diagnostics_get(0, &room), "room snapshot succeeds"); + CHECK(amy_reverb_diagnostics_get(0, &room0), "room 0 snapshot succeeds"); + CHECK(amy_reverb_diagnostics_get(1, &room1), "room 1 snapshot succeeds"); CHECK(amy_reverb_stage_diagnostics_get(&stage), "stage snapshot succeeds"); - CHECK(room.calls == 48, "room measured once per rendered block (%llu)", - (unsigned long long)room.calls); + CHECK(room0.calls == 48, "room 0 ran once per rendered block (%llu)", + (unsigned long long)room0.calls); + CHECK(room1.calls == 48, "room 1 ran once per rendered block (%llu)", + (unsigned long long)room1.calls); CHECK(stage.calls == 50, "stage measured once per rendered block (%llu)", (unsigned long long)stage.calls); - CHECK(room.core_mask == 1, "host room ran on its one render core"); + CHECK(room0.core_mask == 1 && room1.core_mask == 1, + "both host rooms ran on the host render core"); - bool wet_nonzero = false; - SAMPLE *wet = amy_global.reverb_rooms[0].block; - for (int i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) - if (wet[i] != 0) wet_nonzero = true; - CHECK(wet_nonzero, "shared room produced a wet return"); + for (int room = 0; room < 2; ++room) { + bool wet_nonzero = false; + SAMPLE *wet = amy_global.reverb_rooms[room].block; + for (int i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) + if (wet[i] != 0) wet_nonzero = true; + CHECK(wet_nonzero, "shared room %d produced a wet return", room); + } +} + +static void test_external_hook_serial_fallback(void) { + puts("external bus hooks retain one ordered callback per bus"); + for (int bus = 0; bus < 4; ++bus) bus_hook_calls[bus] = 0; + start_shared_with_hook(true); + amy_add_message("y3V1Z"); + amy_execute_deltas(); + amy_simple_fill_buffer(); + for (int bus = 0; bus < 4; ++bus) + CHECK(bus_hook_calls[bus] == 1, "bus %d hook ran once", bus); } static void test_legacy_default(void) { @@ -117,6 +148,7 @@ int main(void) { amy_start(config); test_arena_and_wire_routing(); test_audio_and_deferred_diagnostics(); + test_external_hook_serial_fallback(); test_legacy_default(); amy_stop(); if (failures) return 1; From 9be3c6d8886751b7edb50e7aa16a8bafcc20e4f4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 21:33:27 +0200 Subject: [PATCH 106/112] Keep compatibility defines out of generated constants --- src/amy.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/amy.h b/src/amy.h index c4874ecb..86cee1b1 100644 --- a/src/amy.h +++ b/src/amy.h @@ -88,17 +88,17 @@ extern const uint32_t pcm_wavetable_len; // Left alone it is 8 (256 samples), or 7 (128) on Daisy, exactly as before. #if defined(AMY_BLOCK_SIZE) && !defined(BLOCK_SIZE_BITS) #if AMY_BLOCK_SIZE == 32 -#define BLOCK_SIZE_BITS 5 +#define BLOCK_SIZE_BITS (5) #elif AMY_BLOCK_SIZE == 64 -#define BLOCK_SIZE_BITS 6 +#define BLOCK_SIZE_BITS (6) #elif AMY_BLOCK_SIZE == 128 -#define BLOCK_SIZE_BITS 7 +#define BLOCK_SIZE_BITS (7) #elif AMY_BLOCK_SIZE == 256 -#define BLOCK_SIZE_BITS 8 +#define BLOCK_SIZE_BITS (8) #elif AMY_BLOCK_SIZE == 512 -#define BLOCK_SIZE_BITS 9 +#define BLOCK_SIZE_BITS (9) #elif AMY_BLOCK_SIZE == 1024 -#define BLOCK_SIZE_BITS 10 +#define BLOCK_SIZE_BITS (10) #else #error "AMY_BLOCK_SIZE must be a power of two from 32 through 1024" #endif From a9e322975c4d0df715d2ef93eb563d15228828f4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 21:35:41 +0200 Subject: [PATCH 107/112] Regenerate Godot API for shared reverbs --- godot/amy.gd | 64 ++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/godot/amy.gd b/godot/amy.gd index 214b870e..844aefd7 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -347,6 +347,8 @@ var _KW_MAP: Dictionary = { "disk_sample": ["zF", "L"], "algorithm": ["o", "I"], "chorus": ["k", "L"], + "reverb_room": ["hR", "L"], + "reverb_send": ["hS", "L"], "reverb": ["h", "L"], "echo": ["M", "L"], "patch": ["K", "I"], @@ -424,36 +426,38 @@ var _KW_PRIORITY: Dictionary = { "disk_sample": 41, "algorithm": 42, "chorus": 43, - "reverb": 44, - "echo": 45, - "patch": 46, - "sequence_reset": 47, - "sequence_control": 48, - "external_channel": 49, - "portamento": 50, - "tempo": 51, - "sequencer_run": 52, - "external_midi_sync": 53, - "synth": 54, - "pedal": 55, - "synth_flags": 56, - "num_voices": 57, - "oscs_per_voice": 58, - "synth_level": 59, - "to_synth": 60, - "grab_midi_notes": 61, - "note_source_channel": 62, - "synth_delay": 63, - "preset": 64, - "num_partials": 65, - "start_sample": 66, - "stop_sample": 67, - "bus": 68, - "mode": 69, - "midi_cc": 70, - "midi_note_cmd": 71, - "cv_trigger": 72, - "patch_string": 73, + "reverb_room": 44, + "reverb_send": 45, + "reverb": 46, + "echo": 47, + "patch": 48, + "sequence_reset": 49, + "sequence_control": 50, + "external_channel": 51, + "portamento": 52, + "tempo": 53, + "sequencer_run": 54, + "external_midi_sync": 55, + "synth": 56, + "pedal": 57, + "synth_flags": 58, + "num_voices": 59, + "oscs_per_voice": 60, + "synth_level": 61, + "to_synth": 62, + "grab_midi_notes": 63, + "note_source_channel": 64, + "synth_delay": 65, + "preset": 66, + "num_partials": 67, + "start_sample": 68, + "stop_sample": 69, + "bus": 70, + "mode": 71, + "midi_cc": 72, + "midi_note_cmd": 73, + "cv_trigger": 74, + "patch_string": 75, } ## The control coefficient inputs, in wire order. Prefer naming these in a From 3863da24a5fb7fcca2153c2d54d3c47e21b2b065 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 22:05:01 +0200 Subject: [PATCH 108/112] Generalize shared rooms as aux returns --- Makefile | 22 +++++++-- docs/api.md | 45 ++++++++++++------ src/amy.c | 95 ++++++++++++++++++++++++++++++++++++-- src/amy.h | 32 +++++++++++-- src/api.c | 3 ++ tests/test_reverb_limit.c | 68 +++++++++++++++++++++++++++ tests/test_shared_reverb.c | 53 +++++++++++++++++++++ 7 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 tests/test_reverb_limit.c diff --git a/Makefile b/Makefile index 1439a8d4..32062694 100644 --- a/Makefile +++ b/Makefile @@ -140,15 +140,17 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope \ - tests/test_ignore_note_offs tests/test_shared_reverb + tests/test_ignore_note_offs tests/test_shared_reverb \ + tests/test_reverb_limit # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency INSTRUMENT_SPECIAL_TEST = tests/test_ignore_note_offs -SPECIAL_TESTS = $(SEQUENCE_SPECIAL_TESTS) $(INSTRUMENT_SPECIAL_TEST) +REVERB_SPECIAL_TEST = tests/test_reverb_limit +SPECIAL_TESTS = $(SEQUENCE_SPECIAL_TESTS) $(INSTRUMENT_SPECIAL_TEST) $(REVERB_SPECIAL_TEST) -$(addsuffix .o,$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h +$(addsuffix .o,$(filter-out $(SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ $(filter-out $(SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS) @@ -168,11 +170,25 @@ tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS $(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ +# Compile amy.c once with a small embedded-style built-in reverb ceiling. The +# rest of AMY is unchanged because the ceiling only guards effect allocation. +tests/amy_reverb_limit_impl.o: src/amy.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_MAX_REVERBS=1 -c $< -o $@ + +tests/test_reverb_limit.o: tests/test_reverb_limit.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_MAX_REVERBS=1 -Isrc -c $< -o $@ + +tests/test_reverb_limit: tests/test_reverb_limit.o tests/amy_reverb_limit_impl.o $(filter-out src/amy.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/amy.o,$(OBJECTS)) tests/amy_reverb_limit_impl.o $< -Wall $(LIBS) -o $@ + # Read internal pool occupancy in this test without parsing stderr or relying # on platform-specific file-descriptor redirection. tests/instrument_testing_impl.o: src/instrument.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -DAMY_INSTRUMENT_TESTING -c $< -o $@ +tests/test_ignore_note_offs.o: tests/test_ignore_note_offs.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_INSTRUMENT_TESTING -Isrc -c $< -o $@ + $(INSTRUMENT_SPECIAL_TEST): %: %.o tests/instrument_testing_impl.o $(filter-out src/instrument.o,$(OBJECTS)) $(CC) $(CFLAGS) $(filter-out src/instrument.o,$(OBJECTS)) tests/instrument_testing_impl.o $< -Wall $(LIBS) -o $@ diff --git a/docs/api.md b/docs/api.md index 3e26198b..bafb9ffc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,10 +203,13 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_reverb_rooms` | Int | 0 | Number of optional shared aux-return reverbs. Zero preserves the historical per-bus reverb path. | -| `reverb_room_memory` | `void **` | `NULL` | Optional array of one caller-owned arena per shared room. A null entry uses AMY's configured heaps. This lets embedded hosts keep each room in a dedicated SRAM bank. | -| `reverb_room_memory_bytes` | bytes | 0 | Size of every supplied room arena. A 128 KiB arena holds the current stereo reverb network and its block workspace. | -| `reverb_diagnostics` | `0=off, 1=on` | Off | Store per-room and total-stage timing counters for later retrieval. Nothing is printed in the realtime path. | +| `max_reverb_rooms` | Int | 0 | Number of optional shared aux returns. The historical name is retained for source compatibility. Zero preserves the historical per-bus reverb path. | +| `reverb_room_memory` | `void **` | `NULL` | Optional array of one caller-owned arena per aux return. A null entry uses AMY's configured heaps. This lets embedded hosts keep each built-in reverb in a dedicated SRAM bank. | +| `reverb_room_memory_bytes` | bytes | 0 | Size of every supplied return arena. A 128 KiB arena holds the current stereo reverb network and its block workspace. An external return only uses the block workspace. | +| `reverb_diagnostics` | `0=off, 1=on` | Off | Store per-return and total-stage timing counters for later retrieval. Nothing is printed in the realtime path. | +| `aux_return_external` | `uint8_t *` | `NULL` | Optional `max_reverb_rooms`-element selector. A nonzero entry replaces that return's built-in reverb with the host callback below. | +| `amy_external_aux_return_process_hook` | fn ptr | `NULL` | Realtime host callback that replaces selected return blocks in place. It must not block, allocate or perform I/O. | +| `amy_external_aux_return_user_data` | pointer | `NULL` | Opaque host value passed to the external aux-return callback. | | `max_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | | `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | | `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | @@ -490,12 +493,12 @@ Default AMY has 4 buses, 0..3. Set `max_buses` in `amy_config_t` before `amy_st | `M` | `echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef` | `echo` | float[,int,int,float,float] | Echo parameters -- level, delay_ms, max_delay_ms, feedback, filter_coef (-1 is HPF, 0 is flat, +1 is LPF). | | `x` | `eq_l, eq_m, eq_h` |`eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Hz) -15 to 15. 0 is off. default 0. | -#### Shared reverb rooms +#### Shared aux returns Per-bus `reverb`/`h` remains the default and is unchanged. A host that needs -many buses but only a few acoustic spaces can instead enable shared rooms in -`amy_config_t`. Each room owns one reverb delay network; any number of buses -can feed it: +many buses but only a few end-effect instances can instead enable shared +returns in `amy_config_t`. By default each return owns one reverb delay +network; any number of buses can feed it: ```python amy.send(reverb_room=[0, 0.6, 0.85, 0.5, 3000]) @@ -507,11 +510,27 @@ amy.send(bus=2, reverb_send=[0, 0.0]) # dry bus; room selection retained The equivalent wire messages are `hR0,0.6,0.85,0.5,3000Z`, `y0hS0,1Z`, `y1hS0,0.35Z`, and `y2hS0,0Z`. Sends are post-fader: changing a bus volume changes both its dry signal and what it contributes to the room. -The room return is added once to the final mix, so buses sharing a room also -share its tail and room parameters. - -On ESP with multicore rendering, rooms 0 and 1 are processed concurrently on -the existing two pinned audio/render tasks. Additional rooms are processed +The return is added once to the final mix, so buses sharing a built-in reverb +also share its tail and room parameters. + +The routing is deliberately an aux-send/return abstraction, not a requirement +that every return be a room simulation. A C host can mark an entry in +`aux_return_external` and process that return's accumulated block in place with +`amy_external_aux_return_process_hook`. That permits a lighter reverb or a +different end effect without changing AMY's bus summation. External returns +do not allocate an AMY reverb network; their parameters and wet level belong +to the host callback. `hS` still controls each bus's weighted send. `hR` only +configures built-in AMY reverbs. + +`AMY_MAX_REVERBS` is an optional compile-time ceiling for memory-intensive +built-in reverb networks. It counts both shared built-in returns and legacy +per-bus reverbs; external returns do not count. Its default (`UINT16_MAX`) +places no practical restriction on desktop hosts. A constrained target can, +for example, compile with `-DAMY_MAX_REVERBS=2` while retaining any number of +external returns supported by its runtime configuration. + +On ESP with multicore rendering, returns 0 and 1 are processed concurrently on +the existing two pinned audio/render tasks. Additional returns are processed serially. `amy_reverb_diagnostics_get()` and `amy_reverb_stage_diagnostics_get()` take lock-free snapshots of counters collected by those tasks; `amy_reverb_diagnostics_print()` is intended to be diff --git a/src/amy.c b/src/amy.c index c134bb0a..56f79876 100644 --- a/src/amy.c +++ b/src/amy.c @@ -403,9 +403,23 @@ void config_chorus(uint16_t bus, float level, uint16_t max_delay, float lfo_freq } bool alloc_reverb_delay_lines(uint16_t bus) { - if (amy_global.bus[bus]->reverb.rev == NULL) + if (amy_global.bus[bus]->reverb.rev == NULL) { + if (amy_global.allocated_reverbs >= AMY_MAX_REVERBS) { + fprintf(stderr, + "cannot allocate reverb on bus %u: AMY_MAX_REVERBS=%u\n", + bus, (unsigned)AMY_MAX_REVERBS); + return false; + } amy_global.bus[bus]->reverb.rev = new_reverb(); - return init_stereo_reverb(amy_global.bus[bus]->reverb.rev); + if (amy_global.bus[bus]->reverb.rev == NULL + || !init_stereo_reverb(amy_global.bus[bus]->reverb.rev)) { + delete_reverb(amy_global.bus[bus]->reverb.rev); + amy_global.bus[bus]->reverb.rev = NULL; + return false; + } + ++amy_global.allocated_reverbs; + } + return true; } void dealloc_reverb_delay_lines(uint16_t bus) { @@ -413,6 +427,7 @@ void dealloc_reverb_delay_lines(uint16_t bus) { deinit_stereo_reverb(amy_global.bus[bus]->reverb.rev); delete_reverb(amy_global.bus[bus]->reverb.rev); amy_global.bus[bus]->reverb.rev = NULL; + if (amy_global.allocated_reverbs > 0) --amy_global.allocated_reverbs; } } @@ -463,10 +478,50 @@ static bool init_reverb_room(uint16_t room) { state->effect.liveness = REVERB_DEFAULT_LIVENESS; state->effect.damping = REVERB_DEFAULT_DAMPING; state->effect.xover_hz = REVERB_DEFAULT_XOVER_HZ; + state->external_effect = amy_global.config.aux_return_external != NULL + && amy_global.config.aux_return_external[room] != 0; void *arena = NULL; if (amy_global.config.reverb_room_memory != NULL) arena = amy_global.config.reverb_room_memory[room]; + if (state->external_effect) { + size_t block_bytes = sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS; + if (amy_global.config.amy_external_aux_return_process_hook == NULL) { + fprintf(stderr, + "aux return %u is external but has no process hook\n", room); + return false; + } + if (arena != NULL) { + if (amy_global.config.reverb_room_memory_bytes < block_bytes) { + fprintf(stderr, + "external aux return %u needs at least %zu arena bytes\n", + room, block_bytes); + return false; + } + state->arena = arena; + state->arena_bytes = amy_global.config.reverb_room_memory_bytes; + state->arena_used = block_bytes; + state->block = (SAMPLE *)arena; + } else { + state->block = (SAMPLE *)malloc_caps( + block_bytes, amy_global.config.ram_caps_block); + state->block_heap_owned = 1; + if (state->block == NULL) { + fprintf(stderr, "unable to allocate external aux return %u\n", + room); + return false; + } + } + bzero(state->block, block_bytes); + return true; + } + + if (amy_global.allocated_reverbs >= AMY_MAX_REVERBS) { + fprintf(stderr, + "cannot allocate shared reverb %u: AMY_MAX_REVERBS=%u\n", + room, (unsigned)AMY_MAX_REVERBS); + return false; + } if (arena != NULL) { state->arena = arena; state->arena_bytes = amy_global.config.reverb_room_memory_bytes; @@ -487,11 +542,23 @@ static bool init_reverb_room(uint16_t room) { if (state->effect.rev == NULL || state->block == NULL || !init_stereo_reverb(state->effect.rev)) { fprintf(stderr, "unable to allocate shared reverb room %u\n", room); + if (state->effect.rev != NULL) { + deinit_stereo_reverb(state->effect.rev); + delete_reverb(state->effect.rev); + state->effect.rev = NULL; + } + if (state->block_heap_owned) { + free(state->block); + state->block = NULL; + state->block_heap_owned = 0; + } return false; } bzero(state->block, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); } + ++amy_global.allocated_reverbs; + state->reverb_counted = 1; config_stereo_reverb(state->effect.rev, state->effect.liveness, state->effect.xover_hz, state->effect.damping); return true; @@ -504,6 +571,8 @@ static void deinit_reverb_room(shared_reverb_state_t *state) { delete_reverb(state->effect.rev); } if (state->block_heap_owned) free(state->block); + if (state->reverb_counted && amy_global.allocated_reverbs > 0) + --amy_global.allocated_reverbs; *state = (shared_reverb_state_t){0}; } @@ -515,6 +584,12 @@ void config_reverb_room(uint16_t room, float level, float liveness, room, amy_global.config.max_reverb_rooms); return; } + if (amy_global.reverb_rooms[room].external_effect) { + fprintf(stderr, + "aux return %u is externally processed, not a built-in reverb\n", + room); + return; + } reverb_state_t *effect = &amy_global.reverb_rooms[room].effect; if (AMY_IS_UNSET(level)) level = S2F(effect->level); if (AMY_IS_UNSET(liveness)) liveness = effect->liveness; @@ -549,12 +624,23 @@ void config_reverb_send(uint16_t bus, uint16_t room, float level) { void amy_process_reverb_room(uint16_t room) { if (room >= amy_global.config.max_reverb_rooms) return; shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; - if (state->effect.rev == NULL || state->block == NULL) return; + if (state->block == NULL) return; + uint64_t started = amy_global.config.reverb_diagnostics ? amy_get_us() : 0; + if (state->external_effect) { + amy_global.config.amy_external_aux_return_process_hook( + room, state->block, AMY_BLOCK_SIZE, + amy_global.config.amy_external_aux_return_user_data); + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&state->diagnostic_seq, + &state->diagnostic, + (uint32_t)(amy_get_us() - started)); + return; + } + if (state->effect.rev == NULL) return; // A disabled return cannot contribute to the mix. Avoid walking all of // its delay memory, but keep processing an enabled room through silent // input so an existing tail decays naturally. if (state->effect.level == 0) return; - uint64_t started = amy_global.config.reverb_diagnostics ? amy_get_us() : 0; stereo_reverb_wet(state->effect.rev, state->block, AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, state->block, @@ -726,6 +812,7 @@ int8_t global_init(amy_config_t c) { amy_global.i2s_is_in_background = 0; amy_global.delta_queue = NULL; amy_global.delta_qsize = 0; + amy_global.allocated_reverbs = 0; // The per-bus tables are sized from max_buses; nothing about a bus is a // fixed-width array any more. amy_global.volume = (float *)malloc_caps(sizeof(float) * amy_global.config.max_buses, diff --git a/src/amy.h b/src/amy.h index 86cee1b1..3b9e8626 100644 --- a/src/amy.h +++ b/src/amy.h @@ -167,10 +167,19 @@ extern void amy_set_gamma9001_pcm(const int16_t * data); #define AMY_DEFAULT_NUM_BUSES 4 #define AMY_DEFAULT_BUS 0 -// Shared reverbs are optional aux-send rooms. With max_reverb_rooms == 0, -// AMY retains its historical inline per-bus reverb behavior exactly. +// Shared reverbs are optional aux-return rooms. With max_reverb_rooms == 0, +// AMY retains its historical inline per-bus reverb behavior exactly. A host +// may mark individual returns as external and replace the built-in reverb +// with another in-place effect through amy_external_aux_return_process_hook. #define AMY_REVERB_ROOM_NONE UINT16_MAX +// Compile-time ceiling for allocated built-in reverb networks, including both +// shared returns and historical inline per-bus reverbs. Embedded hosts can set +// this lower to make their memory/performance envelope explicit. +#ifndef AMY_MAX_REVERBS +#define AMY_MAX_REVERBS UINT16_MAX +#endif + // How many external CV inputs to contemplate. #define AMY_MAX_CV_IN 2 @@ -1021,10 +1030,11 @@ typedef struct { uint32_t max_sequence_events; uint32_t max_sequence_executions; - // Optional shared reverb rooms. reverb_room_memory may point to + // Optional shared aux-return rooms. reverb_room_memory may point to // max_reverb_rooms caller-owned arenas, each reverb_room_memory_bytes // long. A NULL entry falls back to AMY's configured heaps. Supplying - // fixed arenas lets an embedded host reserve isolated SRAM banks. + // fixed arenas lets an embedded host reserve isolated SRAM banks. The + // historical max_reverb_rooms name is retained for source compatibility. uint16_t max_reverb_rooms; void **reverb_room_memory; size_t reverb_room_memory_bytes; @@ -1032,6 +1042,17 @@ typedef struct { // default so production builds pay no timer-read cost in the audio path. uint8_t reverb_diagnostics; + // Optional max_reverb_rooms-byte selector. A nonzero entry makes that + // return externally processed instead of allocating AMY's built-in + // reverb. The realtime callback receives the accumulated post-fader send + // block and replaces it in place with the return signal. + const uint8_t *aux_return_external; + void (*amy_external_aux_return_process_hook)(uint16_t return_index, + SAMPLE *block, + uint16_t frames, + void *user_data); + void *amy_external_aux_return_user_data; + } amy_config_t; typedef struct eq_state { @@ -1134,6 +1155,8 @@ typedef struct shared_reverb_state { size_t arena_bytes; size_t arena_used; uint8_t block_heap_owned; + uint8_t external_effect; + uint8_t reverb_counted; // One realtime writer updates these counters; a low-priority reader uses // diagnostic_seq as a sequence lock and never blocks the audio task. volatile uint32_t diagnostic_seq; @@ -1183,6 +1206,7 @@ typedef struct global_state { float pitch_bend; // Legacy global pitch bend, will be subsumed per-synth (instrument). uint16_t delta_qsize; + uint16_t allocated_reverbs; struct delta * delta_queue; // start of the sorted queue of deltas to execute. int16_t latency_ms; float tempo; diff --git a/src/api.c b/src/api.c index b7d40e9d..83def091 100644 --- a/src/api.c +++ b/src/api.c @@ -55,6 +55,9 @@ amy_config_t amy_default_config() { c.reverb_room_memory = NULL; c.reverb_room_memory_bytes = 0; c.reverb_diagnostics = 0; + c.aux_return_external = NULL; + c.amy_external_aux_return_process_hook = NULL; + c.amy_external_aux_return_user_data = NULL; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; diff --git a/tests/test_reverb_limit.c b/tests/test_reverb_limit.c new file mode 100644 index 00000000..b82a5a5a --- /dev/null +++ b/tests/test_reverb_limit.c @@ -0,0 +1,68 @@ +// Compile-time ceiling for memory-intensive built-in reverb networks. + +#include +#include "amy.h" + +static int failures; +static uint8_t external_selector[1] = { 1 }; + +#define CHECK(c, message) do { \ + if (c) printf(" ok %s\n", message); \ + else { printf(" FAIL %s\n", message); ++failures; } \ +} while (0) + +static void passthrough_return(uint16_t return_index, SAMPLE *block, + uint16_t frames, void *user_data) { + (void)return_index; + (void)block; + (void)frames; + (void)user_data; +} + +static amy_config_t test_config(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_reverb_rooms = 1; + return config; +} + +static void test_builtin_ceiling(void) { + puts("built-in shared return consumes the configured ceiling"); + amy_config_t config = test_config(); + amy_start(config); + CHECK(amy_global.allocated_reverbs == 1, + "one shared built-in reverb is allocated"); + config_reverb(0, 0.5f, 0.8f, 0.4f, 3000.0f); + CHECK(amy_global.bus[0]->reverb.rev == NULL, + "a legacy per-bus reverb cannot exceed the ceiling"); + CHECK(amy_global.bus[0]->reverb.level == 0, + "a rejected per-bus reverb remains disabled"); + amy_stop(); +} + +static void test_external_return_does_not_count(void) { + puts("external return leaves the built-in allowance available"); + amy_config_t config = test_config(); + config.aux_return_external = external_selector; + config.amy_external_aux_return_process_hook = passthrough_return; + amy_start(config); + CHECK(amy_global.allocated_reverbs == 0, + "external return consumes no built-in reverb slot"); + config_reverb(0, 0.5f, 0.8f, 0.4f, 3000.0f); + CHECK(amy_global.bus[0]->reverb.rev != NULL, + "legacy per-bus reverb can use the remaining slot"); + CHECK(amy_global.allocated_reverbs == 1, + "per-bus allocation is counted"); + amy_stop(); +} + +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + CHECK(AMY_MAX_REVERBS == 1, "test uses an embedded-style ceiling of one"); + test_builtin_ceiling(); + test_external_return_does_not_count(); + if (failures) return 1; + puts("all reverb ceiling checks passed"); + return 0; +} diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c index 99257eda..4a6bf024 100644 --- a/tests/test_shared_reverb.c +++ b/tests/test_shared_reverb.c @@ -11,6 +11,9 @@ static int failures; static uint8_t room_memory[2][ROOM_BYTES]; static void *room_arenas[2] = { room_memory[0], room_memory[1] }; static unsigned bus_hook_calls[4]; +static unsigned external_return_calls; +static bool external_return_received_audio; +static uint8_t external_return_selector[2] = { 0, 1 }; #define CHECK(c, fmt, ...) do { \ if (c) printf(" ok " fmt "\n", ##__VA_ARGS__); \ @@ -30,6 +33,18 @@ static void count_bus_hook(uint16_t bus, SAMPLE *buf, uint16_t len) { if (bus < 4) ++bus_hook_calls[bus]; } +static void process_external_return(uint16_t return_index, SAMPLE *block, + uint16_t frames, void *user_data) { + unsigned *calls = (unsigned *)user_data; + CHECK(return_index == 1, "external callback receives return index"); + CHECK(frames == AMY_BLOCK_SIZE, "external callback receives one block"); + ++*calls; + for (int i = 0; i < frames * AMY_NCHANS; ++i) { + if (block[i] != 0) external_return_received_audio = true; + block[i] /= 2; + } +} + static void start_shared_with_hook(bool hook) { amy_stop(); amy_config_t config = amy_default_config(); @@ -125,6 +140,43 @@ static void test_external_hook_serial_fallback(void) { CHECK(bus_hook_calls[bus] == 1, "bus %d hook ran once", bus); } +static void test_external_aux_return(void) { + puts("host-selected aux-return effect"); + amy_stop(); + external_return_calls = 0; + external_return_received_audio = false; + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_buses = 4; + config.max_reverb_rooms = 2; + config.reverb_room_memory = room_arenas; + config.reverb_room_memory_bytes = ROOM_BYTES; + config.aux_return_external = external_return_selector; + config.amy_external_aux_return_process_hook = process_external_return; + config.amy_external_aux_return_user_data = &external_return_calls; + amy_start(config); + + CHECK(amy_global.allocated_reverbs == 1, + "only the built-in return allocates a reverb"); + CHECK(amy_global.reverb_rooms[0].effect.rev != NULL, + "return 0 uses AMY's built-in effect"); + CHECK(amy_global.reverb_rooms[1].external_effect, + "return 1 is host processed"); + CHECK(amy_global.reverb_rooms[1].effect.rev == NULL, + "external return allocates no built-in reverb"); + CHECK(amy_global.reverb_rooms[1].arena_used + == sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + "external return arena contains only its block"); + + amy_add_message("y2hS1,1Zv0w0n60l1y2Z"); + amy_execute_deltas(); + for (int i = 0; i < 4; ++i) amy_simple_fill_buffer(); + CHECK(external_return_calls == 4, + "external effect ran once per block (%u)", external_return_calls); + CHECK(external_return_received_audio, + "external effect received the selected bus audio"); +} + static void test_legacy_default(void) { puts("legacy per-bus behavior remains the default"); amy_stop(); @@ -149,6 +201,7 @@ int main(void) { test_arena_and_wire_routing(); test_audio_and_deferred_diagnostics(); test_external_hook_serial_fallback(); + test_external_aux_return(); test_legacy_default(); amy_stop(); if (failures) return 1; From c638d594bbae244a8d75c9a43fa3373f7a4467ef Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 22:06:59 +0200 Subject: [PATCH 109/112] Prove aux return count remains runtime configurable --- tests/test_shared_reverb.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c index 4a6bf024..5032fac5 100644 --- a/tests/test_shared_reverb.c +++ b/tests/test_shared_reverb.c @@ -177,6 +177,25 @@ static void test_external_aux_return(void) { "external effect received the selected bus audio"); } +static void test_runtime_room_count_is_not_fixed_at_two(void) { + puts("runtime return count is not fixed at two"); + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_reverb_rooms = 3; + config.reverb_diagnostics = 1; + amy_start(config); + CHECK(amy_global.allocated_reverbs == 3, + "three configured built-in returns are allocated"); + amy_add_message("hR2,0.5,0.8,0.4,3000Zy0hS2,1Zv0w0n60l1y0Z"); + amy_execute_deltas(); + amy_simple_fill_buffer(); + amy_reverb_diagnostic_t room2; + CHECK(amy_reverb_diagnostics_get(2, &room2), + "third-return diagnostic snapshot succeeds"); + CHECK(room2.calls == 1, "third return is processed"); +} + static void test_legacy_default(void) { puts("legacy per-bus behavior remains the default"); amy_stop(); @@ -202,6 +221,7 @@ int main(void) { test_audio_and_deferred_diagnostics(); test_external_hook_serial_fallback(); test_external_aux_return(); + test_runtime_room_count_is_not_fixed_at_two(); test_legacy_default(); amy_stop(); if (failures) return 1; From f5999c08517c16a96bcee057f2285bbb05617565 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 22:14:27 +0200 Subject: [PATCH 110/112] Clarify generic aux return semantics --- docs/api.md | 4 ++-- src/amy.c | 10 +++++----- src/amy.h | 7 +++---- src/parse.c | 11 ++++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/api.md b/docs/api.md index bafb9ffc..4f0398e4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -487,8 +487,8 @@ Default AMY has 4 buses, 0..3. Set `max_buses` in `amy_config_t` before `amy_st | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `h` | `reverb_level, reverb_liveness, reverb_damping, reverb_xover_hz` | `reverb` | float[,float,float,float] | Reverb parameters -- level, liveness, damping, xover: Level is for output mix; -| `hR` | `reverb_room, reverb_room_level, reverb_room_liveness, reverb_room_damping, reverb_room_xover_hz` | `reverb_room` | int,float[,float,float,float] | Configure a shared reverb room: room, return level, liveness, damping and crossover. Shared rooms must first be enabled with `max_reverb_rooms`. | -| `hS` | `reverb_send_room, reverb_send_level` | `reverb_send` | int,float | Route the selected bus to a shared room with a weighted post-fader send. A send of zero excludes the bus while retaining its room selection. | +| `hR` | `reverb_room, reverb_room_level, reverb_room_liveness, reverb_room_damping, reverb_room_xover_hz` | `reverb_room` | int,float[,float,float,float] | Configure a built-in shared reverb: return index, level, liveness, damping and crossover. Shared returns must first be enabled with `max_reverb_rooms`. | +| `hS` | `reverb_send_room, reverb_send_level` | `reverb_send` | int,float | Route the selected bus to a shared aux return with a weighted post-fader send. A send of zero excludes the bus while retaining its return selection. | | `k` | `chorus_level, chorus_max_delay, chorus_lfo_freq, chorus_depth` | `chorus` | float[,float,float,float] | Chorus parameters -- level, delay, freq, depth: Level is for output mix (0 to turn off); delay is max in samples (320); freq is LFO rate in Hz (0.5); depth is proportion of max delay (0.5). | | `M` | `echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef` | `echo` | float[,int,int,float,float] | Echo parameters -- level, delay_ms, max_delay_ms, feedback, filter_coef (-1 is HPF, 0 is flat, +1 is LPF). | | `x` | `eq_l, eq_m, eq_h` |`eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Hz) -15 to 15. 0 is off. default 0. | diff --git a/src/amy.c b/src/amy.c index 56f79876..1e86b891 100644 --- a/src/amy.c +++ b/src/amy.c @@ -558,7 +558,6 @@ static bool init_reverb_room(uint16_t room) { sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); } ++amy_global.allocated_reverbs; - state->reverb_counted = 1; config_stereo_reverb(state->effect.rev, state->effect.liveness, state->effect.xover_hz, state->effect.damping); return true; @@ -566,12 +565,13 @@ static bool init_reverb_room(uint16_t room) { static void deinit_reverb_room(shared_reverb_state_t *state) { if (state == NULL) return; + bool built_in_reverb = state->effect.rev != NULL; if (state->effect.rev != NULL) { deinit_stereo_reverb(state->effect.rev); delete_reverb(state->effect.rev); } if (state->block_heap_owned) free(state->block); - if (state->reverb_counted && amy_global.allocated_reverbs > 0) + if (built_in_reverb && amy_global.allocated_reverbs > 0) --amy_global.allocated_reverbs; *state = (shared_reverb_state_t){0}; } @@ -580,7 +580,7 @@ void config_reverb_room(uint16_t room, float level, float liveness, float damping, float xover_hz) { if (room >= amy_global.config.max_reverb_rooms || amy_global.reverb_rooms == NULL) { - fprintf(stderr, "shared reverb room %u is not configured (max %u)\n", + fprintf(stderr, "aux return %u is not configured (max %u)\n", room, amy_global.config.max_reverb_rooms); return; } @@ -607,13 +607,13 @@ void config_reverb_send(uint16_t bus, uint16_t room, float level) { bus = amy_validate_bus(bus); if (room >= amy_global.config.max_reverb_rooms || amy_global.reverb_rooms == NULL) { - fprintf(stderr, "shared reverb room %u is not configured (max %u)\n", + fprintf(stderr, "aux return %u is not configured (max %u)\n", room, amy_global.config.max_reverb_rooms); return; } if (AMY_IS_UNSET(level)) level = S2F(amy_global.bus[bus]->reverb_send_level); if (!isfinite(level)) { - fprintf(stderr, "shared reverb send level must be finite\n"); + fprintf(stderr, "aux send level must be finite\n"); return; } if (level < 0) level = 0; diff --git a/src/amy.h b/src/amy.h index 3b9e8626..d25df5da 100644 --- a/src/amy.h +++ b/src/amy.h @@ -747,14 +747,14 @@ typedef struct amy_event { float reverb_liveness; float reverb_damping; float reverb_xover_hz; - // hRroom,level,liveness,damping,xover configures a shared room. + // hRroom,level,liveness,damping,xover configures a built-in shared reverb. uint16_t reverb_room; float reverb_room_level; float reverb_room_liveness; float reverb_room_damping; float reverb_room_xover_hz; - // yBUS hSroom,level sends one bus to one shared room. A zero level is - // the explicit off state and does not disturb the room's existing tail. + // yBUS hSreturn,level sends one bus to one shared aux return. A zero level + // is the explicit off state and does not disturb an effect's existing tail. uint16_t reverb_send_room; float reverb_send_level; } amy_event; @@ -1156,7 +1156,6 @@ typedef struct shared_reverb_state { size_t arena_used; uint8_t block_heap_owned; uint8_t external_effect; - uint8_t reverb_counted; // One realtime writer updates these counters; a low-priority reader uses // diagnostic_seq as a sequence lock and never blocks the audio task. volatile uint32_t diagnostic_seq; diff --git a/src/parse.c b/src/parse.c index 2fea5d8c..9ec3d4ec 100644 --- a/src/parse.c +++ b/src/parse.c @@ -516,10 +516,10 @@ int amy_parse_dist_layer_message(char *message, amy_event *e) { } // Parser for the reverb family. A numeric payload keeps the historical -// per-bus h command. hR addresses one shared room and -// hS addresses the send on the event's bus. Keeping these under h makes the -// wire protocol advertise one coherent effect rather than consuming unrelated -// top-level letters. +// per-bus h command. hR configures one built-in shared +// reverb; hS addresses the aux send on the event's bus. Keeping these under h +// preserves the established wire family without consuming unrelated top-level +// letters. static int amy_parse_reverb_layer_message(char *message, amy_event *e) { if (message[0] != 'R' && message[0] != 'S') { float values[4]; @@ -539,7 +539,8 @@ static int amy_parse_reverb_layer_message(char *message, amy_event *e) { if (!isfinite(values[0]) || values[0] < 0.0f || values[0] >= (float)AMY_REVERB_ROOM_NONE || values[0] != floorf(values[0])) { - fprintf(stderr, "invalid shared reverb room: expected an integer 0..65534\n"); + fprintf(stderr, + "invalid aux return index: expected an integer 0..65534\n"); return 1; } From 8a61658879f74c1183c8dfbba5bd2090b5fe4a1c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 22:17:24 +0200 Subject: [PATCH 111/112] Enable shared aux returns in Android service --- android/amy-service/src/main/cpp/amy_android.cpp | 2 ++ tests/test_android_service_contract.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 36a5ce8c..0e62e3bd 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -58,6 +58,7 @@ constexpr int kAudioReadyTimeoutMs = 2000; constexpr int kAudioReadyPollMs = 2; constexpr uint16_t kIntegrationMaxOscillators = 336; constexpr uint16_t kIntegrationMaxBuses = 11; +constexpr uint16_t kIntegrationMaxReverbRooms = 2; constexpr uint32_t kIntegrationMaxSequencerTags = 1280; constexpr uint32_t kIntegrationMaxSequenceEvents = 64; constexpr uint32_t kIntegrationMaxSequenceExecutions = 40; @@ -81,6 +82,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, */ config.max_oscs = kIntegrationMaxOscillators; config.max_buses = kIntegrationMaxBuses; + config.max_reverb_rooms = kIntegrationMaxReverbRooms; config.max_sequencer_tags = kIntegrationMaxSequencerTags; config.max_sequence_events = kIntegrationMaxSequenceEvents; config.max_sequence_executions = kIntegrationMaxSequenceExecutions; diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 8b139ee5..4b0eb355 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -33,6 +33,10 @@ def main() -> None: "runtime oscillator configuration") require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, "runtime bus configuration") + require(r"kIntegrationMaxReverbRooms\s*=\s*2\s*;", engine, + "the two shared aux returns") + require(r"config\.max_reverb_rooms\s*=\s*kIntegrationMaxReverbRooms\s*;", + engine, "runtime shared aux-return configuration") require(r"kIntegrationMaxSequencerTags\s*=\s*1280\s*;", engine, "the shared live-event and stored-sequence tag capacity") require(r"config\.max_sequencer_tags\s*=\s*kIntegrationMaxSequencerTags\s*;", From 53a316068642ece2cb2d2bb0eedb76a039f7da1f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Tue, 8 Sep 2026 22:18:07 +0200 Subject: [PATCH 112/112] Document hosted shared aux capacity --- android/README.md | 7 ++++--- docs/lb_omnichord_release_contract.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/android/README.md b/android/README.md index fdd8a406..2bc4ff62 100644 --- a/android/README.md +++ b/android/README.md @@ -76,9 +76,10 @@ calls AMY and never participates in audio rendering. AMY is started with its internal platform audio disabled and with AMY rendering owned by the Oboe callback thread. The current Android build configuration -reserves 336 addressable oscillators, 11 runtime buses, and 16 Karplus-Strong -oscillators. These are service-host capacities, not wire-protocol extensions: -clients continue to send ordinary AMY messages and may use any smaller layout. +reserves 336 addressable oscillators, 11 runtime buses, two shared aux returns, +and 16 Karplus-Strong oscillators. These are service-host capacities, not +wire-protocol extensions: clients continue to send ordinary AMY messages and +may use any smaller layout. ## JNI boundary diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index 37e72c52..444cb8a9 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -25,7 +25,7 @@ The release layers on: - deterministic offline CPython startup for tests; - ignored-note-off bookkeeping suitable for indefinitely running one-shot percussion synths; -- 336 oscillators and 11 buses; and +- 336 oscillators, 11 buses, and two shared aux returns; and - 1,280 sequence tags, 64 events per definition and 40 active or alignment-pending executions.