From a11421c5267b0ff809753bf245eeed4fe0378a28 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 23:11:48 +0200 Subject: [PATCH 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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 14/36] 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 15/36] 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 fe4851deab126942cdc7a72f9ba857cf5dc5dc43 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 17:59:11 +0200 Subject: [PATCH 16/36] 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 08b25fb2d0655b310c52988a1790f40800f673a2 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sun, 30 Aug 2026 20:26:11 +0200 Subject: [PATCH 17/36] 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 95b2926589e08af38b218b4f7205c019270718a9 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 08:34:01 +0200 Subject: [PATCH 18/36] 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 2e465c0f..2b9bbfe3 100644 --- a/Makefile +++ b/Makefile @@ -146,6 +146,7 @@ amy-module: amy-example test: amy-module ${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 95b9eb74..7d7fee11 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,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 cee64e20..2594b960 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); @@ -180,6 +185,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; @@ -197,7 +206,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 3472bb2cac37ef1b94582f37725e192296b039bd Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 00:18:12 +0200 Subject: [PATCH 19/36] 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 8262de2323cf66cb8f876a0df51dc72a8f54afcf Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Mon, 31 Aug 2026 00:23:17 +0200 Subject: [PATCH 20/36] 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 b6ff0389f47a1ab15eb3c6b816751a7e76a9107c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:19:38 +0200 Subject: [PATCH 21/36] Configure hosted sequencer group capacity --- android/amy-service/src/main/cpp/amy_android.cpp | 6 ++++++ tests/test_android_service_contract.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 0af7342b..f779f562 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 kIntegrationMaxSequenceGroups = 1024; +constexpr uint32_t kIntegrationMaxSequenceGroupTags = 64; +constexpr uint32_t kIntegrationMaxSequenceGroupExecutions = 32; 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_sequence_groups = kIntegrationMaxSequenceGroups; + config.max_sequence_group_tags = kIntegrationMaxSequenceGroupTags; + config.max_sequence_group_executions = kIntegrationMaxSequenceGroupExecutions; /* 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..1e719e92 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -33,6 +33,14 @@ def main() -> None: "runtime oscillator configuration") require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, "runtime bus configuration") + require(r"kIntegrationMaxSequenceGroups\s*=\s*1024\s*;", engine, + "the complete hosted group catalogue capacity") + require(r"config\.max_sequence_groups\s*=\s*kIntegrationMaxSequenceGroups\s*;", + engine, "runtime sequence-group configuration") + require(r"config\.max_sequence_group_tags\s*=\s*kIntegrationMaxSequenceGroupTags\s*;", + engine, "runtime local-tag configuration") + require(r"config\.max_sequence_group_executions\s*=\s*kIntegrationMaxSequenceGroupExecutions\s*;", + engine, "runtime group-execution 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, @@ -58,7 +66,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, 1024 sequence groups, " + "8-second test capture") if __name__ == "__main__": From 788742f5581abb4bb33b4bdec6352f145c1e2c4c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:35:49 +0200 Subject: [PATCH 22/36] Size Omnichord group execution pool --- android/amy-service/src/main/cpp/amy_android.cpp | 2 +- tests/test_python_offline_live.py | 13 ++++++------- 2 files changed, 7 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 f779f562..649ae299 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -60,7 +60,7 @@ constexpr uint16_t kIntegrationMaxOscillators = 336; constexpr uint16_t kIntegrationMaxBuses = 11; constexpr uint32_t kIntegrationMaxSequenceGroups = 1024; constexpr uint32_t kIntegrationMaxSequenceGroupTags = 64; -constexpr uint32_t kIntegrationMaxSequenceGroupExecutions = 32; +constexpr uint32_t kIntegrationMaxSequenceGroupExecutions = 40; class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback { diff --git a/tests/test_python_offline_live.py b/tests/test_python_offline_live.py index 63576a00..043520cb 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_sequence_groups=1024, + max_sequence_group_tags=64, + max_sequence_group_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 group 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.send(ticks=(0, 4, 0, 1000), osc=0, vel=0) + amy.send(sequence_control=[1000, amy.SEQUENCE_CONTROL_PUBLISH, 4]) return 0 From 3d6ec079eb73bf5d021312ff8ac07ebae8e5eae7 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:42:56 +0200 Subject: [PATCH 23/36] Document hosted sequencer capacity --- docs/lb_omnichord_release_contract.md | 8 +++++--- tests/test_android_service_contract.py | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index afc5c333..7f8c5d24 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -43,9 +43,11 @@ 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. +40 active or pending executions. The high group count stores the complete fill +catalogue; it does not create 1,024 players. The execution pool includes room +for the characterized worst case of 34 concurrent role, fill and overlapping +arpeggio executions. Event tables are allocated lazily only for definitions +that are actually authored. ## Platform boundary diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 1e719e92..8e8a116f 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -41,6 +41,8 @@ def main() -> None: engine, "runtime local-tag configuration") require(r"config\.max_sequence_group_executions\s*=\s*kIntegrationMaxSequenceGroupExecutions\s*;", engine, "runtime group-execution configuration") + require(r"kIntegrationMaxSequenceGroupExecutions\s*=\s*40\s*;", engine, + "characterized Omnichord group-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, From 109852803bd1385100448e49965dff949d3ba5dd Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:01:54 +0200 Subject: [PATCH 24/36] 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 25/36] 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 890ec66de2677db5bdf9a5dda9f53f01628d2b58 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 22:27:43 +0200 Subject: [PATCH 26/36] 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 b1e995fba8430a4210102a877df23143ff237761 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:23:49 +0200 Subject: [PATCH 27/36] 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 2b98a7edd7d906410accce8275237ad9082ceb84 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:40:22 +0200 Subject: [PATCH 28/36] 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 d28af9d3ea8f094e49b4892a2c5e5b646e51972a Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:23:49 +0200 Subject: [PATCH 29/36] 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 7d7fee11..37b3478c 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) @@ -177,6 +178,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 bb4f0f3250b3c505fce66743b6682bfde4b1443d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:25:42 +0200 Subject: [PATCH 30/36] Align release sequencer group references --- docs/lb_omnichord_release_contract.md | 6 +++++- tests/test_android_service_contract.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md index 7f8c5d24..5dcc31d4 100644 --- a/docs/lb_omnichord_release_contract.md +++ b/docs/lb_omnichord_release_contract.md @@ -12,7 +12,7 @@ it is never itself offered upstream. ## Current line -`releases/amy_omnichord_R20260903T201525` starts with: +`releases/amy_omnichord_R20260903T202802` starts with: - Shorepine main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`; - the generic sequencer-group work from `rework/sequencer`; @@ -22,6 +22,10 @@ it is never itself offered upstream. - the larger bounded sequencer-group capacity required by the rhythm catalogue. +The Unix-socket receiver applies lossless backpressure when its bounded +realtime handoff queue is full. Large startup transactions therefore remain in +the kernel socket queue instead of being read and discarded. + 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. diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py index 8e8a116f..2119a1da 100644 --- a/tests/test_android_service_contract.py +++ b/tests/test_android_service_contract.py @@ -36,7 +36,7 @@ def main() -> None: require(r"kIntegrationMaxSequenceGroups\s*=\s*1024\s*;", engine, "the complete hosted group catalogue capacity") require(r"config\.max_sequence_groups\s*=\s*kIntegrationMaxSequenceGroups\s*;", - engine, "runtime sequence-group configuration") + engine, "runtime sequencer-group configuration") require(r"config\.max_sequence_group_tags\s*=\s*kIntegrationMaxSequenceGroupTags\s*;", engine, "runtime local-tag configuration") require(r"config\.max_sequence_group_executions\s*=\s*kIntegrationMaxSequenceGroupExecutions\s*;", @@ -68,7 +68,7 @@ def main() -> None: ) print("Android service contract OK: private :amy process, socket-only client, " - "Gamma9001 PCM, 336 oscillators, 11 buses, 1024 sequence groups, " + "Gamma9001 PCM, 336 oscillators, 11 buses, 1024 sequencer groups, " "8-second test capture") From c3ebcaefbc4bea5a6631e568dca307e5c483e70d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:38:57 +0200 Subject: [PATCH 31/36] 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 32/36] 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 33/36] 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 34/36] 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 35/36] 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 36/36] 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();