From 0229cfc70426eae8843e03a8b8b42b28eb800506 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:04:59 +0200 Subject: [PATCH 01/47] Add private Unix socket transport API --- src/amy_unix_socket.h | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/amy_unix_socket.h 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 From b095cd5a657d49be8ac9d29aa00b7eed8d71138b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:05:34 +0200 Subject: [PATCH 02/47] Implement private Unix socket transport --- src/amy_unix_socket.c | 447 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 src/amy_unix_socket.c 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 From bc811b2d7bf05b8a55f411900d8c784b973fec91 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:06:14 +0200 Subject: [PATCH 03/47] Test private Unix socket transport --- tests/test_amy_unix_socket.c | 183 +++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/test_amy_unix_socket.c 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 813c51b05fa4c3197c6d1ab690c67964c3aac261 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:07:06 +0200 Subject: [PATCH 04/47] Add Unix socket transport test runner --- tests/run_amy_unix_socket_test.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/run_amy_unix_socket_test.sh 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" From 52d7351267c5c4b742c224e45e2b5db38e576e99 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:07:14 +0200 Subject: [PATCH 05/47] Add Unix socket transport CI --- .github/workflows/android-unix-socket.yml | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/android-unix-socket.yml diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/android-unix-socket.yml new file mode 100644 index 00000000..e88616c1 --- /dev/null +++ b/.github/workflows/android-unix-socket.yml @@ -0,0 +1,30 @@ +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' + push: + branches: + - feature/android-unix-socket + 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 From 77abd3aa8e366b364efdc14bd0cb62d4337e050c Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 14:07:31 +0200 Subject: [PATCH 06/47] Document Android private amy.sock transport --- docs/android_unix_socket.md | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/android_unix_socket.md 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 79e091a81f35715b3bf48b00f62e05eef854eae7 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:25:28 +0200 Subject: [PATCH 07/47] Add Android AMY service Gradle settings --- android/settings.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 android/settings.gradle.kts diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..84732c83 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "amy-android" +include(":amy-service") From a254f06331862ce867515c88533394146ff5ac13 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:25:34 +0200 Subject: [PATCH 08/47] Configure Android library build --- android/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 android/build.gradle.kts diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..83ccca12 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.library") version "8.13.2" apply false +} From 05e5d9744bb54f982cad0e055cf5aa9cac569127 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:25:46 +0200 Subject: [PATCH 09/47] Add AMY Android AAR module --- android/amy-service/build.gradle.kts | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 android/amy-service/build.gradle.kts diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts new file mode 100644 index 00000000..0e8c6250 --- /dev/null +++ b/android/amy-service/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "org.amy.audio" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + + 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") +} From f696516846c116dd1802908ed4b6aba3167d9f92 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:25:53 +0200 Subject: [PATCH 10/47] Declare private AMY audio service --- android/amy-service/src/main/AndroidManifest.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 android/amy-service/src/main/AndroidManifest.xml 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 @@ + + + + + + From 04ec5299bcca02c21e6f3984c05fb415458e1ad5 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:26:10 +0200 Subject: [PATCH 11/47] Add Android AMY service JNI lifecycle --- .../main/java/org/amy/audio/AmyService.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyService.java 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..0054068f --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -0,0 +1,111 @@ +package org.amy.audio; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +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 + * socket and sends newline-delimited AMY wire messages. JNI is only used to + * start and stop the native engine with the validated socket pathname. + */ +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 static native int nativeStart(String socketPath); + 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; + } + + if (running) { + nativeStop(); + running = false; + } + + int result = nativeStart(socketPath); + if (result != 0) { + Log.e(TAG, "nativeStart failed: " + result); + stopSelf(startId); + return START_NOT_STICKY; + } + + running = true; + Log.i(TAG, "AMY listening on private socket " + socketPath); + return START_NOT_STICKY; + } + + 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; + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} From 5b1695226d2fbaae0ce8aa7c3266e009ae814749 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:26:19 +0200 Subject: [PATCH 12/47] Add private Unix socket transport API --- .../src/main/cpp/amy_socket_transport.h | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_socket_transport.h diff --git a/android/amy-service/src/main/cpp/amy_socket_transport.h b/android/amy-service/src/main/cpp/amy_socket_transport.h new file mode 100644 index 00000000..34753437 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_socket_transport.h @@ -0,0 +1,48 @@ +#ifndef AMY_SOCKET_TRANSPORT_H +#define AMY_SOCKET_TRANSPORT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AMY_SOCKET_MAX_MESSAGE 1024u +#define AMY_SOCKET_QUEUE_CAPACITY 256u + +typedef struct amy_socket_server amy_socket_server_t; + +/** Allocate a stopped server. Allocation is startup-only, never realtime. */ +amy_socket_server_t *amy_socket_server_create(void); + +/** + * Bind a pathname AF_UNIX/SOCK_STREAM socket, chmod it to 0600, listen, and + * start the receive thread. Messages are newline-delimited byte strings. + * Returns 0 or a negative errno/pthread error value. + */ +int amy_socket_server_start(amy_socket_server_t *server, const char *path); + +/** Stop the receive thread, close descriptors, and unlink the socket path. */ +void amy_socket_server_stop(amy_socket_server_t *server); + +/** Stop and free a server created by amy_socket_server_create(). */ +void amy_socket_server_destroy(amy_socket_server_t *server); + +/** + * Realtime-safe single-consumer pop. Returns message length, 0 when empty, + * or a negative error. No allocation, file I/O, socket I/O, or locks occur. + */ +int amy_socket_server_pop( + amy_socket_server_t *server, + char *destination, + size_t destination_size); + +/** Number of complete messages dropped because the ring was full/oversized. */ +uint32_t amy_socket_server_dropped(const amy_socket_server_t *server); + +#ifdef __cplusplus +} +#endif + +#endif From da1c845c24346f161069ca85f24313e74d6403fb Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:26:52 +0200 Subject: [PATCH 13/47] Implement private Unix socket command transport --- .../src/main/cpp/amy_socket_transport.c | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_socket_transport.c diff --git a/android/amy-service/src/main/cpp/amy_socket_transport.c b/android/amy-service/src/main/cpp/amy_socket_transport.c new file mode 100644 index 00000000..b518623f --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_socket_transport.c @@ -0,0 +1,252 @@ +#include "amy_socket_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct amy_socket_entry { + uint16_t length; + char message[AMY_SOCKET_MAX_MESSAGE]; +}; + +struct amy_socket_server { + pthread_t thread; + bool thread_started; + atomic_bool running; + atomic_int listen_fd; + atomic_int client_fd; + atomic_uint write_index; + atomic_uint read_index; + atomic_uint dropped; + char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + struct amy_socket_entry queue[AMY_SOCKET_QUEUE_CAPACITY]; +}; + +static void close_socket_fd(atomic_int *slot) { + int fd = atomic_exchange_explicit(slot, -1, memory_order_acq_rel); + if (fd >= 0) { + shutdown(fd, SHUT_RDWR); + close(fd); + } +} + +static bool queue_push(amy_socket_server_t *server, const char *message, size_t length) { + if (length == 0 || length >= AMY_SOCKET_MAX_MESSAGE) { + atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); + return false; + } + + uint32_t write = atomic_load_explicit(&server->write_index, memory_order_relaxed); + uint32_t read = atomic_load_explicit(&server->read_index, memory_order_acquire); + if ((uint32_t)(write - read) >= AMY_SOCKET_QUEUE_CAPACITY) { + atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); + return false; + } + + struct amy_socket_entry *entry = &server->queue[write % AMY_SOCKET_QUEUE_CAPACITY]; + memcpy(entry->message, message, length); + entry->message[length] = '\0'; + entry->length = (uint16_t)length; + atomic_store_explicit(&server->write_index, write + 1u, memory_order_release); + return true; +} + +static void release_client_fd(amy_socket_server_t *server, int fd) { + int expected = fd; + if (atomic_compare_exchange_strong_explicit( + &server->client_fd, + &expected, + -1, + memory_order_acq_rel, + memory_order_acquire)) { + close(fd); + } +} + +static void *socket_thread_main(void *context) { + amy_socket_server_t *server = (amy_socket_server_t *)context; + + while (atomic_load_explicit(&server->running, memory_order_acquire)) { + int listen_fd = atomic_load_explicit(&server->listen_fd, memory_order_acquire); + if (listen_fd < 0) break; + + int fd = accept(listen_fd, NULL, NULL); + if (fd < 0) { + if (errno == EINTR) continue; + if (!atomic_load_explicit(&server->running, memory_order_acquire)) break; + continue; + } + + atomic_store_explicit(&server->client_fd, fd, memory_order_release); + + char message[AMY_SOCKET_MAX_MESSAGE]; + size_t message_length = 0; + bool overflow = false; + char input[512]; + + while (atomic_load_explicit(&server->running, memory_order_acquire)) { + ssize_t received = read(fd, input, sizeof(input)); + if (received == 0) break; + if (received < 0) { + if (errno == EINTR) continue; + break; + } + + for (ssize_t i = 0; i < received; ++i) { + unsigned char ch = (unsigned char)input[i]; + if (ch == '\n') { + if (overflow) { + atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); + } else if (message_length > 0) { + queue_push(server, message, message_length); + } + message_length = 0; + overflow = false; + continue; + } + if (ch == '\r') continue; + + if (overflow) continue; + if (message_length + 1u >= sizeof(message)) { + overflow = true; + continue; + } + message[message_length++] = (char)ch; + } + } + + release_client_fd(server, fd); + } + + return NULL; +} + +amy_socket_server_t *amy_socket_server_create(void) { + amy_socket_server_t *server = (amy_socket_server_t *)calloc(1, sizeof(*server)); + if (server == NULL) return NULL; + + atomic_init(&server->running, false); + atomic_init(&server->listen_fd, -1); + atomic_init(&server->client_fd, -1); + atomic_init(&server->write_index, 0u); + atomic_init(&server->read_index, 0u); + atomic_init(&server->dropped, 0u); + return server; +} + +int amy_socket_server_start(amy_socket_server_t *server, const char *path) { + if (server == NULL || path == NULL || path[0] == '\0') return -EINVAL; + if (server->thread_started || atomic_load_explicit(&server->running, memory_order_acquire)) { + return -EALREADY; + } + + size_t path_length = strlen(path); + if (path_length >= sizeof(server->path)) return -ENAMETOOLONG; + + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + memcpy(address.sun_path, path, path_length + 1u); + + unlink(path); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -errno; + + if (bind(fd, (struct sockaddr *)&address, sizeof(address)) != 0) { + int error = errno; + close(fd); + return -error; + } + + if (chmod(path, S_IRUSR | S_IWUSR) != 0) { + int error = errno; + close(fd); + unlink(path); + return -error; + } + + if (listen(fd, 1) != 0) { + int error = errno; + close(fd); + unlink(path); + return -error; + } + + memcpy(server->path, path, path_length + 1u); + atomic_store_explicit(&server->write_index, 0u, memory_order_relaxed); + atomic_store_explicit(&server->read_index, 0u, memory_order_relaxed); + atomic_store_explicit(&server->dropped, 0u, memory_order_relaxed); + atomic_store_explicit(&server->listen_fd, fd, memory_order_release); + atomic_store_explicit(&server->running, true, memory_order_release); + + int result = pthread_create(&server->thread, NULL, socket_thread_main, server); + if (result != 0) { + atomic_store_explicit(&server->running, false, memory_order_release); + close_socket_fd(&server->listen_fd); + unlink(server->path); + server->path[0] = '\0'; + return -result; + } + + server->thread_started = true; + return 0; +} + +void amy_socket_server_stop(amy_socket_server_t *server) { + if (server == NULL) return; + + atomic_store_explicit(&server->running, false, memory_order_release); + close_socket_fd(&server->client_fd); + close_socket_fd(&server->listen_fd); + + if (server->thread_started) { + pthread_join(server->thread, NULL); + server->thread_started = false; + } + + if (server->path[0] != '\0') { + unlink(server->path); + server->path[0] = '\0'; + } +} + +void amy_socket_server_destroy(amy_socket_server_t *server) { + if (server == NULL) return; + amy_socket_server_stop(server); + free(server); +} + +int amy_socket_server_pop( + amy_socket_server_t *server, + char *destination, + size_t destination_size) { + if (server == NULL || destination == NULL || destination_size == 0) return -EINVAL; + + uint32_t read_index = atomic_load_explicit(&server->read_index, memory_order_relaxed); + uint32_t write_index = atomic_load_explicit(&server->write_index, memory_order_acquire); + if (read_index == write_index) return 0; + + const struct amy_socket_entry *entry = + &server->queue[read_index % AMY_SOCKET_QUEUE_CAPACITY]; + size_t length = entry->length; + if (length + 1u > destination_size) { + atomic_store_explicit(&server->read_index, read_index + 1u, memory_order_release); + return -EMSGSIZE; + } + + memcpy(destination, entry->message, length + 1u); + atomic_store_explicit(&server->read_index, read_index + 1u, memory_order_release); + return (int)length; +} + +uint32_t amy_socket_server_dropped(const amy_socket_server_t *server) { + if (server == NULL) return 0; + return atomic_load_explicit(&server->dropped, memory_order_relaxed); +} From 6980c90f32df35cc0049c9356ebc4a6c600acf31 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:27:08 +0200 Subject: [PATCH 14/47] Add host regression test for Unix socket transport --- android/test_socket_transport.c | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 android/test_socket_transport.c diff --git a/android/test_socket_transport.c b/android/test_socket_transport.c new file mode 100644 index 00000000..47431a17 --- /dev/null +++ b/android/test_socket_transport.c @@ -0,0 +1,95 @@ +#include "amy-service/src/main/cpp/amy_socket_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static int connect_client(const char *path) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + assert(fd >= 0); + + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + assert(strlen(path) < sizeof(address.sun_path)); + strcpy(address.sun_path, path); + + for (int attempt = 0; attempt < 100; ++attempt) { + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) == 0) return fd; + if (errno != ENOENT && errno != ECONNREFUSED) break; + usleep(1000); + } + + perror("connect"); + assert(0 && "unable to connect to AMY socket"); + return -1; +} + +static int wait_pop(amy_socket_server_t *server, char *buffer, size_t size) { + for (int attempt = 0; attempt < 500; ++attempt) { + int result = amy_socket_server_pop(server, buffer, size); + if (result != 0) return result; + usleep(1000); + } + return 0; +} + +int main(void) { + char path[96]; + snprintf(path, sizeof(path), "/tmp/amy-socket-%ld.sock", (long)getpid()); + + amy_socket_server_t *server = amy_socket_server_create(); + assert(server != NULL); + assert(amy_socket_server_start(server, path) == 0); + + struct stat st; + assert(stat(path, &st) == 0); + assert((st.st_mode & 0777) == 0600); + + int client = connect_client(path); + const char *batch = "n60l1i2Z\nv0F1200i2Z\n"; + assert(write(client, batch, strlen(batch)) == (ssize_t)strlen(batch)); + + char command[AMY_SOCKET_MAX_MESSAGE]; + int length = wait_pop(server, command, sizeof(command)); + assert(length == (int)strlen("n60l1i2Z")); + assert(strcmp(command, "n60l1i2Z") == 0); + + length = wait_pop(server, command, sizeof(command)); + assert(length == (int)strlen("v0F1200i2Z")); + assert(strcmp(command, "v0F1200i2Z") == 0); + + /* Stream framing must survive a command split across separate writes. */ + assert(write(client, "K28", 3) == 3); + usleep(1000); + assert(amy_socket_server_pop(server, command, sizeof(command)) == 0); + assert(write(client, "i2Z\r\n", 5) == 5); + length = wait_pop(server, command, sizeof(command)); + assert(length == 6); + assert(strcmp(command, "K28i2Z") == 0); + + /* Oversized lines are discarded as a unit and counted, not truncated. */ + char oversized[AMY_SOCKET_MAX_MESSAGE + 80]; + memset(oversized, 'x', sizeof(oversized)); + oversized[sizeof(oversized) - 1] = '\n'; + uint32_t dropped_before = amy_socket_server_dropped(server); + assert(write(client, oversized, sizeof(oversized)) == (ssize_t)sizeof(oversized)); + for (int attempt = 0; attempt < 500 && amy_socket_server_dropped(server) == dropped_before; ++attempt) { + usleep(1000); + } + assert(amy_socket_server_dropped(server) == dropped_before + 1u); + assert(amy_socket_server_pop(server, command, sizeof(command)) == 0); + + close(client); + amy_socket_server_stop(server); + assert(access(path, F_OK) != 0); + amy_socket_server_destroy(server); + + printf("android socket transport: ok\n"); + return 0; +} From 4ade8cd6542b1c054ac74650df2495a074ba7af3 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:27:19 +0200 Subject: [PATCH 15/47] Add host socket transport test target --- android/Makefile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 android/Makefile diff --git a/android/Makefile b/android/Makefile new file mode 100644 index 00000000..9fbff2cc --- /dev/null +++ b/android/Makefile @@ -0,0 +1,21 @@ +CC ?= cc +CFLAGS ?= -O2 -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE + +BUILD_DIR := build-host +TRANSPORT_SRC := amy-service/src/main/cpp/amy_socket_transport.c +TRANSPORT_HDR := amy-service/src/main/cpp/amy_socket_transport.h +TEST_SRC := test_socket_transport.c +TEST_BIN := $(BUILD_DIR)/test_socket_transport + +.PHONY: host-test clean + +host-test: $(TEST_BIN) + ./$(TEST_BIN) + +$(TEST_BIN): $(TEST_SRC) $(TRANSPORT_SRC) $(TRANSPORT_HDR) + mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) -pthread -Iamy-service/src/main/cpp \ + $(TEST_SRC) $(TRANSPORT_SRC) -o $(TEST_BIN) + +clean: + rm -rf $(BUILD_DIR) From 3f1189b3a8ad18a77cef7b4e24745728535385bb Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:28:05 +0200 Subject: [PATCH 16/47] Add native AMY Oboe engine and JNI lifecycle --- .../amy-service/src/main/cpp/amy_android.cpp | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_android.cpp 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..08cd0928 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -0,0 +1,244 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "amy.h" +#include "amy_socket_transport.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; } + +namespace { + +constexpr int kMaxCommandsPerBlock = 64; + +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; + /* Omnichord Physical Strings can require fourteen simultaneous KS voices. */ + config.ks_oscs = 16; + + amy_start(config); + mAmyStarted = true; + + mSocket = amy_socket_server_create(); + if (mSocket == nullptr) { + stopAmy(); + return -ENOMEM; + } + int socketResult = amy_socket_server_start(mSocket, socketPath); + if (socketResult != 0) { + amy_socket_server_destroy(mSocket); + mSocket = nullptr; + stopAmy(); + return socketResult; + } + + 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)); + cleanupSocketAndAmy(); + 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(); + cleanupSocketAndAmy(); + return -ERANGE; + } + + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + 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(); + cleanupSocketAndAmy(); + return static_cast(result); + } + + LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, socket=%s", + AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, socketPath); + return 0; + } + + void stop() { + mRunning.store(false, std::memory_order_release); + + if (mStream) { + mStream->requestStop(); + mStream->close(); + mStream.reset(); + } + + cleanupSocketAndAmy(); + 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; + } + + 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() { + if (mSocket == nullptr) return; + char command[AMY_SOCKET_MAX_MESSAGE]; + for (int count = 0; count < kMaxCommandsPerBlock; ++count) { + int length = amy_socket_server_pop(mSocket, command, sizeof(command)); + if (length <= 0) break; + amy_add_message(command); + } + } + + void stopAmy() { + if (mAmyStarted) { + amy_stop(); + mAmyStarted = false; + } + } + + void cleanupSocketAndAmy() { + if (mSocket != nullptr) { + uint32_t dropped = amy_socket_server_dropped(mSocket); + if (dropped != 0) LOGE("AMY socket dropped %u messages", dropped); + amy_socket_server_destroy(mSocket); + mSocket = nullptr; + } + stopAmy(); + } + + std::atomic mRunning{false}; + bool mAmyStarted = false; + amy_socket_server_t *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 void JNICALL +Java_org_amy_audio_AmyService_nativeStop(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + if (gEngine) { + gEngine->stop(); + gEngine.reset(); + } +} From 5454155b5805b9176f1e84efd35e8942c3b2821c Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:28:26 +0200 Subject: [PATCH 17/47] Build AMY with Oboe for Android --- .../amy-service/src/main/cpp/CMakeLists.txt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 android/amy-service/src/main/cpp/CMakeLists.txt 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..18430bdd --- /dev/null +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -0,0 +1,64 @@ +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}/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_socket_transport.c + ${AMY_SOURCES} +) + +target_include_directories(amy_android PRIVATE + ${AMY_SRC} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# AMY_DAISY currently selects AMY's existing 48 kHz / 128-frame build profile. +# No Daisy platform code is linked; AMY_NO_MINIAUDIO makes Oboe the sole audio +# backend. AMY_ANDROID is available for future Android-specific compile guards. +target_compile_definitions(amy_android PRIVATE + AMY_ANDROID=1 + AMY_DAISY=1 + AMY_NO_MINIAUDIO=1 + AMY_WAVETABLE=1 +) + +target_compile_options(amy_android PRIVATE + $<$:-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 +) From b3a34e52fc07b5b92aa5fefd0b96d219786e1a09 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:29:03 +0200 Subject: [PATCH 18/47] Add Android AMY build and socket CI --- .github/workflows/android.yml | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..8194067d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,52 @@ +name: Android AMY + +on: + pull_request: + paths: + - "android/**" + - "src/**" + - ".github/workflows/android.yml" + push: + branches: + - feature/android-unix-socket + paths: + - "android/**" + - "src/**" + - ".github/workflows/android.yml" + +jobs: + socket-transport: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and run host socket test + run: make -C android host-test + + android-aar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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 + working-directory: android + run: gradle :amy-service:assembleDebug --stacktrace From 0091870e61f343cca6f3cf7427d3d03a1f6d544e Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:29:24 +0200 Subject: [PATCH 19/47] Document AMY Android Oboe service --- android/README.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 android/README.md diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..7c8a1d41 --- /dev/null +++ b/android/README.md @@ -0,0 +1,158 @@ +# AMY Android service + +This directory builds AMY as an Android AAR whose native engine runs in an +unexported `:amy` service process. The service owns Oboe/AAudio output and +accepts native AMY wire messages over a private pathname Unix socket. + +The design deliberately keeps musical control out of JNI: + +```text +Qt/Python process + | + | AF_UNIX/SOCK_STREAM + | /amy.sock + | newline-delimited AMY wire messages + v +Android :amy service process + | + +-- blocking Unix socket receive thread + +-- fixed SPSC command ring + +-- AMY C engine + +-- Oboe low-latency output callback + | + v + AAudio +``` + +The service is declared `android:exported="false"` and runs as `:amy`, so it +has the same application UID as the Qt host but a separate process. The Java +wrapper only accepts the exact filename `amy.sock` directly below the app's +private `filesDir`. The native server additionally creates the socket node as +mode `0600`. + +## Audio profile + +The Android build uses AMY's existing 48 kHz / 128-frame profile and does not +link AMY's miniaudio backend. Oboe requests: + +- stereo signed 16-bit output +- 48 kHz +- `PerformanceMode::LowLatency` +- `SharingMode::Exclusive` +- callback-driven output + +Oboe callback sizes are not assumed to equal the AMY block size. The callback +keeps only the unconsumed tail of the current AMY block and renders a new +128-frame block exactly when needed. There is no extra 128-frame output ring. + +All ordinary AMY access occurs on the Oboe callback thread. The socket thread +only copies complete wire messages into a preallocated SPSC ring. At each AMY +block boundary the callback consumes at most 64 queued commands, then calls +`amy_simple_fill_buffer()`. + +## Wire transport + +The socket is `AF_UNIX/SOCK_STREAM`. One client is accepted at a time. +Messages are framed by newline, matching the existing serial host writer: + +```text +K28i2Z\n +n60l1i2Z\n +n60l0i2Z\n +``` + +A message may contain multiple AMY `Z`-terminated events if the normal AMY +parser accepts that payload; the socket framing itself only cares about the +newline. + +Transport limits are intentionally fixed: + +- maximum complete line: 1023 bytes plus NUL +- command ring: 256 messages +- oversized messages: discarded as a unit +- full ring: new message discarded + +Dropped-message count is logged when the engine shuts down. + +## Building + +Requirements: + +- JDK 17 +- Android SDK platform 36 +- Android NDK 27.0.12077973 +- CMake 3.22.1 +- Gradle 8.13 + +From the repository root: + +```bash +cd android +gradle :amy-service:assembleDebug +``` + +The AAR is produced below: + +```text +android/amy-service/build/outputs/aar/ +``` + +The module uses the Oboe 1.10.0 Prefab dependency from Google's Maven +repository. + +## Host transport regression test + +The socket server and SPSC ring are plain POSIX C and can be tested without an +Android toolchain: + +```bash +make -C android host-test +``` + +The test verifies: + +- socket node mode `0600` +- ordered message delivery +- multiple messages from one stream read +- a message split over multiple writes +- CRLF handling +- oversized-line rejection/counting +- socket pathname cleanup on shutdown + +## Embedding in the Qt Android application + +Package the `amy-service` AAR into the Qt APK. Its manifest contributes the +unexported `org.amy.audio.AmyService` in the `:amy` process. + +The Android host starts the service with `AmyService.start(context)`. This uses: + +```text +/amy.sock +``` + +The Qt/Python transport then connects to that pathname with a Unix stream +socket and writes the same AMY wire lines used by the Raspberry Pi serial +transport. + +The Qt integration belongs in the Omnichord repository; this AMY module knows +nothing about Qt, Python, presets, or Omnichord UI policy. + +## Current scope + +This branch implements one-way AMY wire command input. The stream socket is +bidirectional by construction, so the compact AMY introspection response path +can be added without changing IPC architecture once that AMY branch is merged +or rebased here. + +The first hardware tests should measure: + +1. touch-to-audio latency +2. Oboe callback size and device buffer size +3. xrun/glitch behavior during patch changes and heavy reverb/delay patches +4. service suspend/resume and audio-device changes + +Patch allocation and other unusually heavy AMY commands are deliberately not +moved to a second AMY thread in this first version: maintaining a single owner +for AMY state avoids cross-thread locking in the normal render path. Hardware +measurements should determine whether those rare control operations need a +separate preparation mechanism. From 7d3ffa94ae882f06bc6d796464823c0c06f74967 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:30:08 +0200 Subject: [PATCH 20/47] Target Android ARM64 for AMY service --- android/amy-service/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts index 0e8c6250..be969e7b 100644 --- a/android/amy-service/build.gradle.kts +++ b/android/amy-service/build.gradle.kts @@ -9,6 +9,10 @@ android { defaultConfig { minSdk = 26 + ndk { + abiFilters += listOf("arm64-v8a") + } + externalNativeBuild { cmake { arguments += "-DANDROID_STL=c++_shared" From a977d34fc3c682110b47d7c2d8672eaa22008b91 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:31:28 +0200 Subject: [PATCH 21/47] Reuse validated AMY Unix transport in Android engine --- .../amy-service/src/main/cpp/amy_android.cpp | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 08cd0928..875fff54 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -9,11 +9,10 @@ #include #include #include -#include extern "C" { #include "amy.h" -#include "amy_socket_transport.h" +#include "amy_unix_socket.h" } #define LOG_TAG "AmyAndroid" @@ -56,15 +55,8 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, amy_start(config); mAmyStarted = true; - mSocket = amy_socket_server_create(); - if (mSocket == nullptr) { - stopAmy(); - return -ENOMEM; - } - int socketResult = amy_socket_server_start(mSocket, socketPath); + int socketResult = amy_unix_socket_start(&mSocket, socketPath); if (socketResult != 0) { - amy_socket_server_destroy(mSocket); - mSocket = nullptr; stopAmy(); return socketResult; } @@ -139,7 +131,8 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, 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)); + std::memset(output, 0, + static_cast(numFrames) * AMY_NCHANS * sizeof(int16_t)); return oboe::DataCallbackResult::Stop; } @@ -179,9 +172,9 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, private: void drainCommands() { if (mSocket == nullptr) return; - char command[AMY_SOCKET_MAX_MESSAGE]; + char command[MAX_MESSAGE_LEN]; for (int count = 0; count < kMaxCommandsPerBlock; ++count) { - int length = amy_socket_server_pop(mSocket, command, sizeof(command)); + int length = amy_unix_socket_receive(mSocket, command, sizeof(command)); if (length <= 0) break; amy_add_message(command); } @@ -196,9 +189,14 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, void cleanupSocketAndAmy() { if (mSocket != nullptr) { - uint32_t dropped = amy_socket_server_dropped(mSocket); - if (dropped != 0) LOGE("AMY socket dropped %u messages", dropped); - amy_socket_server_destroy(mSocket); + uint32_t overruns = amy_unix_socket_queue_overruns(mSocket); + uint32_t oversize = amy_unix_socket_oversize_packets(mSocket); + uint32_t rejected = amy_unix_socket_rejected_peers(mSocket); + if (overruns || oversize || rejected) { + LOGE("AMY socket diagnostics: overruns=%u oversize=%u rejected=%u", + overruns, oversize, rejected); + } + amy_unix_socket_stop(mSocket); mSocket = nullptr; } stopAmy(); @@ -206,7 +204,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, std::atomic mRunning{false}; bool mAmyStarted = false; - amy_socket_server_t *mSocket = nullptr; + amy_unix_socket_server_t *mSocket = nullptr; std::shared_ptr mStream; int16_t *mBlock = nullptr; int32_t mBlockFrame = AMY_BLOCK_SIZE; From 344fec5cbfa4269a0376777e1a5aca6e78d5be8f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:31:46 +0200 Subject: [PATCH 22/47] Link validated AMY Unix socket transport --- android/amy-service/src/main/cpp/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 18430bdd..a246e237 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -9,6 +9,7 @@ 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 @@ -30,7 +31,6 @@ set(AMY_SOURCES add_library(amy_android SHARED amy_android.cpp - amy_socket_transport.c ${AMY_SOURCES} ) From 2cdeed2c01532a15d1c42771639d4ae3bb4db172 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:32:03 +0200 Subject: [PATCH 23/47] Remove duplicate Android socket transport --- .../src/main/cpp/amy_socket_transport.c | 252 ------------------ 1 file changed, 252 deletions(-) delete mode 100644 android/amy-service/src/main/cpp/amy_socket_transport.c diff --git a/android/amy-service/src/main/cpp/amy_socket_transport.c b/android/amy-service/src/main/cpp/amy_socket_transport.c deleted file mode 100644 index b518623f..00000000 --- a/android/amy-service/src/main/cpp/amy_socket_transport.c +++ /dev/null @@ -1,252 +0,0 @@ -#include "amy_socket_transport.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -struct amy_socket_entry { - uint16_t length; - char message[AMY_SOCKET_MAX_MESSAGE]; -}; - -struct amy_socket_server { - pthread_t thread; - bool thread_started; - atomic_bool running; - atomic_int listen_fd; - atomic_int client_fd; - atomic_uint write_index; - atomic_uint read_index; - atomic_uint dropped; - char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; - struct amy_socket_entry queue[AMY_SOCKET_QUEUE_CAPACITY]; -}; - -static void close_socket_fd(atomic_int *slot) { - int fd = atomic_exchange_explicit(slot, -1, memory_order_acq_rel); - if (fd >= 0) { - shutdown(fd, SHUT_RDWR); - close(fd); - } -} - -static bool queue_push(amy_socket_server_t *server, const char *message, size_t length) { - if (length == 0 || length >= AMY_SOCKET_MAX_MESSAGE) { - atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); - return false; - } - - uint32_t write = atomic_load_explicit(&server->write_index, memory_order_relaxed); - uint32_t read = atomic_load_explicit(&server->read_index, memory_order_acquire); - if ((uint32_t)(write - read) >= AMY_SOCKET_QUEUE_CAPACITY) { - atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); - return false; - } - - struct amy_socket_entry *entry = &server->queue[write % AMY_SOCKET_QUEUE_CAPACITY]; - memcpy(entry->message, message, length); - entry->message[length] = '\0'; - entry->length = (uint16_t)length; - atomic_store_explicit(&server->write_index, write + 1u, memory_order_release); - return true; -} - -static void release_client_fd(amy_socket_server_t *server, int fd) { - int expected = fd; - if (atomic_compare_exchange_strong_explicit( - &server->client_fd, - &expected, - -1, - memory_order_acq_rel, - memory_order_acquire)) { - close(fd); - } -} - -static void *socket_thread_main(void *context) { - amy_socket_server_t *server = (amy_socket_server_t *)context; - - while (atomic_load_explicit(&server->running, memory_order_acquire)) { - int listen_fd = atomic_load_explicit(&server->listen_fd, memory_order_acquire); - if (listen_fd < 0) break; - - int fd = accept(listen_fd, NULL, NULL); - if (fd < 0) { - if (errno == EINTR) continue; - if (!atomic_load_explicit(&server->running, memory_order_acquire)) break; - continue; - } - - atomic_store_explicit(&server->client_fd, fd, memory_order_release); - - char message[AMY_SOCKET_MAX_MESSAGE]; - size_t message_length = 0; - bool overflow = false; - char input[512]; - - while (atomic_load_explicit(&server->running, memory_order_acquire)) { - ssize_t received = read(fd, input, sizeof(input)); - if (received == 0) break; - if (received < 0) { - if (errno == EINTR) continue; - break; - } - - for (ssize_t i = 0; i < received; ++i) { - unsigned char ch = (unsigned char)input[i]; - if (ch == '\n') { - if (overflow) { - atomic_fetch_add_explicit(&server->dropped, 1u, memory_order_relaxed); - } else if (message_length > 0) { - queue_push(server, message, message_length); - } - message_length = 0; - overflow = false; - continue; - } - if (ch == '\r') continue; - - if (overflow) continue; - if (message_length + 1u >= sizeof(message)) { - overflow = true; - continue; - } - message[message_length++] = (char)ch; - } - } - - release_client_fd(server, fd); - } - - return NULL; -} - -amy_socket_server_t *amy_socket_server_create(void) { - amy_socket_server_t *server = (amy_socket_server_t *)calloc(1, sizeof(*server)); - if (server == NULL) return NULL; - - atomic_init(&server->running, false); - atomic_init(&server->listen_fd, -1); - atomic_init(&server->client_fd, -1); - atomic_init(&server->write_index, 0u); - atomic_init(&server->read_index, 0u); - atomic_init(&server->dropped, 0u); - return server; -} - -int amy_socket_server_start(amy_socket_server_t *server, const char *path) { - if (server == NULL || path == NULL || path[0] == '\0') return -EINVAL; - if (server->thread_started || atomic_load_explicit(&server->running, memory_order_acquire)) { - return -EALREADY; - } - - size_t path_length = strlen(path); - if (path_length >= sizeof(server->path)) return -ENAMETOOLONG; - - struct sockaddr_un address; - memset(&address, 0, sizeof(address)); - address.sun_family = AF_UNIX; - memcpy(address.sun_path, path, path_length + 1u); - - unlink(path); - int fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) return -errno; - - if (bind(fd, (struct sockaddr *)&address, sizeof(address)) != 0) { - int error = errno; - close(fd); - return -error; - } - - if (chmod(path, S_IRUSR | S_IWUSR) != 0) { - int error = errno; - close(fd); - unlink(path); - return -error; - } - - if (listen(fd, 1) != 0) { - int error = errno; - close(fd); - unlink(path); - return -error; - } - - memcpy(server->path, path, path_length + 1u); - atomic_store_explicit(&server->write_index, 0u, memory_order_relaxed); - atomic_store_explicit(&server->read_index, 0u, memory_order_relaxed); - atomic_store_explicit(&server->dropped, 0u, memory_order_relaxed); - atomic_store_explicit(&server->listen_fd, fd, memory_order_release); - atomic_store_explicit(&server->running, true, memory_order_release); - - int result = pthread_create(&server->thread, NULL, socket_thread_main, server); - if (result != 0) { - atomic_store_explicit(&server->running, false, memory_order_release); - close_socket_fd(&server->listen_fd); - unlink(server->path); - server->path[0] = '\0'; - return -result; - } - - server->thread_started = true; - return 0; -} - -void amy_socket_server_stop(amy_socket_server_t *server) { - if (server == NULL) return; - - atomic_store_explicit(&server->running, false, memory_order_release); - close_socket_fd(&server->client_fd); - close_socket_fd(&server->listen_fd); - - if (server->thread_started) { - pthread_join(server->thread, NULL); - server->thread_started = false; - } - - if (server->path[0] != '\0') { - unlink(server->path); - server->path[0] = '\0'; - } -} - -void amy_socket_server_destroy(amy_socket_server_t *server) { - if (server == NULL) return; - amy_socket_server_stop(server); - free(server); -} - -int amy_socket_server_pop( - amy_socket_server_t *server, - char *destination, - size_t destination_size) { - if (server == NULL || destination == NULL || destination_size == 0) return -EINVAL; - - uint32_t read_index = atomic_load_explicit(&server->read_index, memory_order_relaxed); - uint32_t write_index = atomic_load_explicit(&server->write_index, memory_order_acquire); - if (read_index == write_index) return 0; - - const struct amy_socket_entry *entry = - &server->queue[read_index % AMY_SOCKET_QUEUE_CAPACITY]; - size_t length = entry->length; - if (length + 1u > destination_size) { - atomic_store_explicit(&server->read_index, read_index + 1u, memory_order_release); - return -EMSGSIZE; - } - - memcpy(destination, entry->message, length + 1u); - atomic_store_explicit(&server->read_index, read_index + 1u, memory_order_release); - return (int)length; -} - -uint32_t amy_socket_server_dropped(const amy_socket_server_t *server) { - if (server == NULL) return 0; - return atomic_load_explicit(&server->dropped, memory_order_relaxed); -} From 83746ecf0b5a0e876234ef8590bbb3cf24b96e5b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:32:10 +0200 Subject: [PATCH 24/47] Remove duplicate Android socket header --- .../src/main/cpp/amy_socket_transport.h | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 android/amy-service/src/main/cpp/amy_socket_transport.h diff --git a/android/amy-service/src/main/cpp/amy_socket_transport.h b/android/amy-service/src/main/cpp/amy_socket_transport.h deleted file mode 100644 index 34753437..00000000 --- a/android/amy-service/src/main/cpp/amy_socket_transport.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef AMY_SOCKET_TRANSPORT_H -#define AMY_SOCKET_TRANSPORT_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define AMY_SOCKET_MAX_MESSAGE 1024u -#define AMY_SOCKET_QUEUE_CAPACITY 256u - -typedef struct amy_socket_server amy_socket_server_t; - -/** Allocate a stopped server. Allocation is startup-only, never realtime. */ -amy_socket_server_t *amy_socket_server_create(void); - -/** - * Bind a pathname AF_UNIX/SOCK_STREAM socket, chmod it to 0600, listen, and - * start the receive thread. Messages are newline-delimited byte strings. - * Returns 0 or a negative errno/pthread error value. - */ -int amy_socket_server_start(amy_socket_server_t *server, const char *path); - -/** Stop the receive thread, close descriptors, and unlink the socket path. */ -void amy_socket_server_stop(amy_socket_server_t *server); - -/** Stop and free a server created by amy_socket_server_create(). */ -void amy_socket_server_destroy(amy_socket_server_t *server); - -/** - * Realtime-safe single-consumer pop. Returns message length, 0 when empty, - * or a negative error. No allocation, file I/O, socket I/O, or locks occur. - */ -int amy_socket_server_pop( - amy_socket_server_t *server, - char *destination, - size_t destination_size); - -/** Number of complete messages dropped because the ring was full/oversized. */ -uint32_t amy_socket_server_dropped(const amy_socket_server_t *server); - -#ifdef __cplusplus -} -#endif - -#endif From 5d12507c6ff2905ece941ceedca48212b3cfa84e Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:32:17 +0200 Subject: [PATCH 25/47] Remove duplicate Android socket regression test --- android/test_socket_transport.c | 95 --------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 android/test_socket_transport.c diff --git a/android/test_socket_transport.c b/android/test_socket_transport.c deleted file mode 100644 index 47431a17..00000000 --- a/android/test_socket_transport.c +++ /dev/null @@ -1,95 +0,0 @@ -#include "amy-service/src/main/cpp/amy_socket_transport.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -static int connect_client(const char *path) { - int fd = socket(AF_UNIX, SOCK_STREAM, 0); - assert(fd >= 0); - - struct sockaddr_un address; - memset(&address, 0, sizeof(address)); - address.sun_family = AF_UNIX; - assert(strlen(path) < sizeof(address.sun_path)); - strcpy(address.sun_path, path); - - for (int attempt = 0; attempt < 100; ++attempt) { - if (connect(fd, (struct sockaddr *)&address, sizeof(address)) == 0) return fd; - if (errno != ENOENT && errno != ECONNREFUSED) break; - usleep(1000); - } - - perror("connect"); - assert(0 && "unable to connect to AMY socket"); - return -1; -} - -static int wait_pop(amy_socket_server_t *server, char *buffer, size_t size) { - for (int attempt = 0; attempt < 500; ++attempt) { - int result = amy_socket_server_pop(server, buffer, size); - if (result != 0) return result; - usleep(1000); - } - return 0; -} - -int main(void) { - char path[96]; - snprintf(path, sizeof(path), "/tmp/amy-socket-%ld.sock", (long)getpid()); - - amy_socket_server_t *server = amy_socket_server_create(); - assert(server != NULL); - assert(amy_socket_server_start(server, path) == 0); - - struct stat st; - assert(stat(path, &st) == 0); - assert((st.st_mode & 0777) == 0600); - - int client = connect_client(path); - const char *batch = "n60l1i2Z\nv0F1200i2Z\n"; - assert(write(client, batch, strlen(batch)) == (ssize_t)strlen(batch)); - - char command[AMY_SOCKET_MAX_MESSAGE]; - int length = wait_pop(server, command, sizeof(command)); - assert(length == (int)strlen("n60l1i2Z")); - assert(strcmp(command, "n60l1i2Z") == 0); - - length = wait_pop(server, command, sizeof(command)); - assert(length == (int)strlen("v0F1200i2Z")); - assert(strcmp(command, "v0F1200i2Z") == 0); - - /* Stream framing must survive a command split across separate writes. */ - assert(write(client, "K28", 3) == 3); - usleep(1000); - assert(amy_socket_server_pop(server, command, sizeof(command)) == 0); - assert(write(client, "i2Z\r\n", 5) == 5); - length = wait_pop(server, command, sizeof(command)); - assert(length == 6); - assert(strcmp(command, "K28i2Z") == 0); - - /* Oversized lines are discarded as a unit and counted, not truncated. */ - char oversized[AMY_SOCKET_MAX_MESSAGE + 80]; - memset(oversized, 'x', sizeof(oversized)); - oversized[sizeof(oversized) - 1] = '\n'; - uint32_t dropped_before = amy_socket_server_dropped(server); - assert(write(client, oversized, sizeof(oversized)) == (ssize_t)sizeof(oversized)); - for (int attempt = 0; attempt < 500 && amy_socket_server_dropped(server) == dropped_before; ++attempt) { - usleep(1000); - } - assert(amy_socket_server_dropped(server) == dropped_before + 1u); - assert(amy_socket_server_pop(server, command, sizeof(command)) == 0); - - close(client); - amy_socket_server_stop(server); - assert(access(path, F_OK) != 0); - amy_socket_server_destroy(server); - - printf("android socket transport: ok\n"); - return 0; -} From 0869a44dca344db0e485aa3ed03f0f49efb2c120 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:32:23 +0200 Subject: [PATCH 26/47] Remove duplicate Android socket test Makefile --- android/Makefile | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 android/Makefile diff --git a/android/Makefile b/android/Makefile deleted file mode 100644 index 9fbff2cc..00000000 --- a/android/Makefile +++ /dev/null @@ -1,21 +0,0 @@ -CC ?= cc -CFLAGS ?= -O2 -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE - -BUILD_DIR := build-host -TRANSPORT_SRC := amy-service/src/main/cpp/amy_socket_transport.c -TRANSPORT_HDR := amy-service/src/main/cpp/amy_socket_transport.h -TEST_SRC := test_socket_transport.c -TEST_BIN := $(BUILD_DIR)/test_socket_transport - -.PHONY: host-test clean - -host-test: $(TEST_BIN) - ./$(TEST_BIN) - -$(TEST_BIN): $(TEST_SRC) $(TRANSPORT_SRC) $(TRANSPORT_HDR) - mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) -pthread -Iamy-service/src/main/cpp \ - $(TEST_SRC) $(TRANSPORT_SRC) -o $(TEST_BIN) - -clean: - rm -rf $(BUILD_DIR) From e621f5d2d993a07ffe92265c8904dba5e0b30d2e Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:32:49 +0200 Subject: [PATCH 27/47] Run validated socket test in Android CI --- .github/workflows/android.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8194067d..d64857e2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -4,28 +4,37 @@ on: pull_request: paths: - "android/**" - - "src/**" + - "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.yml" push: branches: - feature/android-unix-socket paths: - "android/**" - - "src/**" + - "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.yml" +permissions: + contents: read + jobs: socket-transport: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Build and run host socket test - run: make -C android host-test + - uses: actions/checkout@v5 + - name: Build and run private Unix socket test + run: bash tests/run_amy_unix_socket_test.sh android-aar: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-java@v4 with: From 8d49ed5dbf36387ae7edba9914a1da5189ddf73b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:33:19 +0200 Subject: [PATCH 28/47] Document Android Oboe service using AMY socket transport --- android/README.md | 179 +++++++++++++++++++++++----------------------- 1 file changed, 89 insertions(+), 90 deletions(-) diff --git a/android/README.md b/android/README.md index 7c8a1d41..569e2536 100644 --- a/android/README.md +++ b/android/README.md @@ -1,39 +1,44 @@ -# AMY Android service +# AMY Android Oboe service -This directory builds AMY as an Android AAR whose native engine runs in an -unexported `:amy` service process. The service owns Oboe/AAudio output and -accepts native AMY wire messages over a private pathname Unix socket. - -The design deliberately keeps musical control out of JNI: +This directory builds an 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 Qt/Python process | - | AF_UNIX/SOCK_STREAM + | AF_UNIX / SOCK_SEQPACKET | /amy.sock - | newline-delimited AMY wire messages + | one AMY wire message per packet v Android :amy service process | - +-- blocking Unix socket receive thread - +-- fixed SPSC command ring + +-- amy_unix_socket receiver thread + +-- fixed 64-packet SPSC queue +-- AMY C engine - +-- Oboe low-latency output callback + +-- Oboe low-latency callback | v AAudio ``` -The service is declared `android:exported="false"` and runs as `:amy`, so it -has the same application UID as the Qt host but a separate process. The Java -wrapper only accepts the exact filename `amy.sock` directly below the app's -private `filesDir`. The native server additionally creates the socket node as -mode `0600`. +The AAR is intended to be packaged inside the same APK as the Qt application. +The service declaration uses `android:exported="false"` and +`android:process=":amy"`, so the two processes have the same application UID +but separate process heaps and runtimes. + +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 build uses AMY's existing 48 kHz / 128-frame profile and does not -link AMY's miniaudio backend. Oboe requests: +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 @@ -41,48 +46,63 @@ link AMY's miniaudio backend. Oboe requests: - `SharingMode::Exclusive` - callback-driven output -Oboe callback sizes are not assumed to equal the AMY block size. The callback -keeps only the unconsumed tail of the current AMY block and renders a new -128-frame block exactly when needed. There is no extra 128-frame output ring. +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. -All ordinary AMY access occurs on the Oboe callback thread. The socket thread -only copies complete wire messages into a preallocated SPSC ring. At each AMY -block boundary the callback consumes at most 64 queued commands, then calls -`amy_simple_fill_buffer()`. +AMY is started with its internal platform audio disabled and with AMY rendering +owned by the Oboe callback thread. The Android configuration reserves 16 +Karplus-Strong oscillators so the Omnichord Physical Strings program has the +same intended capacity as the ESP32-P4 target. -## Wire transport +## JNI boundary -The socket is `AF_UNIX/SOCK_STREAM`. One client is accepted at a time. -Messages are framed by newline, matching the existing serial host writer: +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`. + +That keeps the application-level architecture symmetric: ```text -K28i2Z\n -n60l1i2Z\n -n60l0i2Z\n +Raspberry Pi: Qt/Python -> UART -> ESP32-P4 AMY +Android: Qt/Python -> amy.sock -> local AMY/Oboe service ``` -A message may contain multiple AMY `Z`-terminated events if the normal AMY -parser accepts that payload; the socket framing itself only cares about the -newline. +## 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: -Transport limits are intentionally fixed: +```text +K28i2Z +n60l1i2Z +n60l0i2Z +``` -- maximum complete line: 1023 bytes plus NUL -- command ring: 256 messages -- oversized messages: discarded as a unit -- full ring: new message discarded +Do not add stream framing or depend on newline boundaries. Packet boundaries +are preserved by `SOCK_SEQPACKET`. -Dropped-message count is logged when the engine shuts down. +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 the introspection branch is integrated. -## Building +## Building the AAR -Requirements: +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: @@ -91,68 +111,47 @@ cd android gradle :amy-service:assembleDebug ``` -The AAR is produced below: +The current build targets `arm64-v8a`. Output is below: ```text android/amy-service/build/outputs/aar/ ``` -The module uses the Oboe 1.10.0 Prefab dependency from Google's Maven -repository. +## Tests -## Host transport regression test - -The socket server and SPSC ring are plain POSIX C and can be tested without an -Android toolchain: +The already-existing private socket regression test is: ```bash -make -C android host-test +bash tests/run_amy_unix_socket_test.sh ``` -The test verifies: - -- socket node mode `0600` -- ordered message delivery -- multiple messages from one stream read -- a message split over multiple writes -- CRLF handling -- oversized-line rejection/counting -- socket pathname cleanup on shutdown - -## Embedding in the Qt Android application - -Package the `amy-service` AAR into the Qt APK. Its manifest contributes the -unexported `org.amy.audio.AmyService` in the `:amy` process. - -The Android host starts the service with `AmyService.start(context)`. This uses: - -```text -/amy.sock -``` +It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, +oversized-packet rejection, cleanup, and protection against deleting an +existing non-socket path. -The Qt/Python transport then connects to that pathname with a Unix stream -socket and writes the same AMY wire lines used by the Raspberry Pi serial -transport. +`.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. -The Qt integration belongs in the Omnichord repository; this AMY module knows -nothing about Qt, Python, presets, or Omnichord UI policy. +## Qt integration -## Current scope +The Qt/Python-side Android adapter belongs in `LB_Omnichord`, not in AMY. It +needs to: -This branch implements one-way AMY wire command input. The stream socket is -bidirectional by construction, so the compact AMY introspection response path -can be added without changing IPC architecture once that AMY branch is merged -or rebased here. +1. start `org.amy.audio.AmyService` while the activity is foreground; +2. obtain the app's actual private files directory rather than hard-code + `/data/user/...`; +3. connect a `SOCK_SEQPACKET` Unix socket to `/amy.sock`; +4. send exactly the same AMY wire payloads currently sent over UART; +5. stop/reconnect cleanly across Android activity/audio lifecycle events. -The first hardware tests should measure: +## Hardware-test items -1. touch-to-audio latency -2. Oboe callback size and device buffer size -3. xrun/glitch behavior during patch changes and heavy reverb/delay patches -4. service suspend/resume and audio-device changes +The first device tests should measure: -Patch allocation and other unusually heavy AMY commands are deliberately not -moved to a second AMY thread in this first version: maintaining a single owner -for AMY state avoids cross-thread locking in the normal render path. Hardware -measurements should determine whether those rare control operations need a -separate preparation mechanism. +1. touch-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. From 98f70dc3c019b9beae9a54bef7fd736000a2a909 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:35:02 +0200 Subject: [PATCH 29/47] Run Android build for AMY core changes --- .github/workflows/android.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d64857e2..2f482647 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -4,8 +4,7 @@ on: pull_request: paths: - "android/**" - - "src/amy_unix_socket.c" - - "src/amy_unix_socket.h" + - "src/**" - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" - ".github/workflows/android.yml" @@ -14,8 +13,7 @@ on: - feature/android-unix-socket paths: - "android/**" - - "src/amy_unix_socket.c" - - "src/amy_unix_socket.h" + - "src/**" - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" - ".github/workflows/android.yml" From d263ca41cc1460d1d4d1bfde71352b6e614393c5 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:35:25 +0200 Subject: [PATCH 30/47] Pin Android NDK used by AMY service --- android/amy-service/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts index be969e7b..da4c528e 100644 --- a/android/amy-service/build.gradle.kts +++ b/android/amy-service/build.gradle.kts @@ -5,6 +5,7 @@ plugins { android { namespace = "org.amy.audio" compileSdk = 36 + ndkVersion = "27.0.12077973" defaultConfig { minSdk = 26 From 9956a309641cd9ccb0ff61c7cfc1c040e5af189b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:37:01 +0200 Subject: [PATCH 31/47] Publish Android AMY AAR artifact --- .github/workflows/android.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2f482647..dc388095 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -57,3 +57,10 @@ jobs: - name: Build AMY Android AAR working-directory: android run: gradle :amy-service: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 From feb18cddd727174b892ec892576f19063097494f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:40:38 +0200 Subject: [PATCH 32/47] Make Android host own AMY MIDI layer --- android/amy-service/src/main/cpp/CMakeLists.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index a246e237..063e4fd2 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -39,12 +39,14 @@ target_include_directories(amy_android PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ) -# AMY_DAISY currently selects AMY's existing 48 kHz / 128-frame build profile. -# No Daisy platform code is linked; AMY_NO_MINIAUDIO makes Oboe the sole audio -# backend. AMY_ANDROID is available for future Android-specific compile guards. +# 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. target_compile_definitions(amy_android PRIVATE AMY_ANDROID=1 AMY_DAISY=1 + AMY_HOST_MIDI=1 AMY_NO_MINIAUDIO=1 AMY_WAVETABLE=1 ) From 2eb2235e2d4b6d98ae21f8127e42b4a1b45b4368 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:41:11 +0200 Subject: [PATCH 33/47] Provide Android-owned AMY MIDI stubs --- android/amy-service/src/main/cpp/amy_android.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index 875fff54..ac8ef7d6 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -30,6 +30,20 @@ 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; From 0ab5e2d270dbffd1d19a404543187fb48db9393f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:42:40 +0200 Subject: [PATCH 34/47] Assert Android AMY audio profile --- .../amy-service/src/main/cpp/amy_android_profile.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_android_profile.cpp 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"); From a31c07a0fbc2d8aa12a2389847a9330c3464e01f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:42:56 +0200 Subject: [PATCH 35/47] Compile Android AMY profile assertions --- android/amy-service/src/main/cpp/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 063e4fd2..482ed273 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_profile.cpp ${AMY_SOURCES} ) From f985c6ffc7a35e4c7cfb4a81adfeefaab51e8165 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:47:58 +0200 Subject: [PATCH 36/47] Android: declare Daisy allocation shim --- .../src/main/cpp/amy_android_daisy_alloc.h | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_android_daisy_alloc.h 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 From 2e723ea75330124a455de29cd033854625c70b67 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:48:02 +0200 Subject: [PATCH 37/47] Android: implement Daisy allocation shim --- .../src/main/cpp/amy_android_daisy_alloc.c | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 android/amy-service/src/main/cpp/amy_android_daisy_alloc.c 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); +} From f7930dbf9d22531d04f8151322cb805e41716c1b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:48:14 +0200 Subject: [PATCH 38/47] Android: preserve Daisy profile with libc allocation shim --- android/amy-service/src/main/cpp/CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 482ed273..110392c5 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -32,6 +32,7 @@ set(AMY_SOURCES add_library(amy_android SHARED amy_android.cpp amy_android_profile.cpp + amy_android_daisy_alloc.c ${AMY_SOURCES} ) @@ -44,6 +45,10 @@ target_include_directories(amy_android PRIVATE # 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. +# pcm.c also maps its allocation helpers to Daisy qspi_malloc/qspi_free when +# AMY_DAISY is set. Android keeps that compile profile but provides a tiny +# libc-backed compatibility shim; force its declarations into C translation +# units so pcm.c remains valid C11 without modifying AMY's platform code. target_compile_definitions(amy_android PRIVATE AMY_ANDROID=1 AMY_DAISY=1 @@ -53,7 +58,7 @@ target_compile_definitions(amy_android PRIVATE ) target_compile_options(amy_android PRIVATE - $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter;-Wno-float-conversion> + $<$:-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> ) From 5abe827da35233392a549892ec19683f6f53d817 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:50:24 +0200 Subject: [PATCH 39/47] Run Android socket CI only for pull requests --- .github/workflows/android-unix-socket.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/android-unix-socket.yml b/.github/workflows/android-unix-socket.yml index e88616c1..6647320b 100644 --- a/.github/workflows/android-unix-socket.yml +++ b/.github/workflows/android-unix-socket.yml @@ -8,15 +8,6 @@ on: - 'tests/test_amy_unix_socket.c' - 'tests/run_amy_unix_socket_test.sh' - '.github/workflows/android-unix-socket.yml' - push: - branches: - - feature/android-unix-socket - 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 From 5774b4a765255d09ba4d593413f712b7a66e8b97 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 19:50:37 +0200 Subject: [PATCH 40/47] Run Android AAR CI only for pull requests --- .github/workflows/android.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index dc388095..53d7fcc3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,15 +8,6 @@ on: - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" - ".github/workflows/android.yml" - push: - branches: - - feature/android-unix-socket - paths: - - "android/**" - - "src/**" - - "tests/test_amy_unix_socket.c" - - "tests/run_amy_unix_socket_test.sh" - - ".github/workflows/android.yml" permissions: contents: read From 69fb953ea864cb185fbcb5fe97f2b8722220cb3a Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 20:39:48 +0200 Subject: [PATCH 41/47] Fix Android AMY duplicate QSPI allocator symbols --- android/amy-service/src/main/cpp/CMakeLists.txt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 110392c5..25c92db2 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -32,7 +32,6 @@ set(AMY_SOURCES add_library(amy_android SHARED amy_android.cpp amy_android_profile.cpp - amy_android_daisy_alloc.c ${AMY_SOURCES} ) @@ -45,10 +44,9 @@ target_include_directories(amy_android PRIVATE # 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. -# pcm.c also maps its allocation helpers to Daisy qspi_malloc/qspi_free when -# AMY_DAISY is set. Android keeps that compile profile but provides a tiny -# libc-backed compatibility shim; force its declarations into C translation -# units so pcm.c remains valid C11 without modifying AMY's platform code. +# 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 From 4804354b6f363e786dab75a360c9a7efda6ea1f8 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:58:11 +0200 Subject: [PATCH 42/47] Make Android AMY documentation client-agnostic --- android/README.md | 71 ++++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/android/README.md b/android/README.md index 569e2536..6f35d90f 100644 --- a/android/README.md +++ b/android/README.md @@ -1,12 +1,12 @@ # AMY Android Oboe service -This directory builds an 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 +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 -Qt/Python process +Android client process | | AF_UNIX / SOCK_SEQPACKET | /amy.sock @@ -23,10 +23,16 @@ Android :amy service process AAudio ``` -The AAR is intended to be packaged inside the same APK as the Qt application. +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"`, so the two processes have the same application UID -but separate process heaps and runtimes. +`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 @@ -56,9 +62,8 @@ 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 Android configuration reserves 16 -Karplus-Strong oscillators so the Omnichord Physical Strings program has the -same intended capacity as the ESP32-P4 target. +owned by the Oboe callback thread. The current Android build configuration +reserves 16 Karplus-Strong oscillators. ## JNI boundary @@ -67,13 +72,15 @@ 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`. -That keeps the application-level architecture symmetric: +The client-facing architecture is therefore deliberately transport-oriented: ```text -Raspberry Pi: Qt/Python -> UART -> ESP32-P4 AMY -Android: Qt/Python -> amy.sock -> local AMY/Oboe service +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. @@ -90,7 +97,25 @@ are preserved by `SOCK_SEQPACKET`. 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 the introspection branch is integrated. +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. connect an `AF_UNIX` / `SOCK_SEQPACKET` socket to `/amy.sock`; +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 @@ -111,7 +136,7 @@ cd android gradle :amy-service:assembleDebug ``` -The current build targets `arm64-v8a`. Output is below: +The production Android service build targets `arm64-v8a`. Output is below: ```text android/amy-service/build/outputs/aar/ @@ -119,7 +144,7 @@ android/amy-service/build/outputs/aar/ ## Tests -The already-existing private socket regression test is: +The private socket regression test is: ```bash bash tests/run_amy_unix_socket_test.sh @@ -133,23 +158,11 @@ existing non-socket path. AAR/NDK/Oboe build. The earlier `.github/workflows/android-unix-socket.yml` continues to isolate the transport regression itself. -## Qt integration - -The Qt/Python-side Android adapter belongs in `LB_Omnichord`, not in AMY. It -needs to: - -1. start `org.amy.audio.AmyService` while the activity is foreground; -2. obtain the app's actual private files directory rather than hard-code - `/data/user/...`; -3. connect a `SOCK_SEQPACKET` Unix socket to `/amy.sock`; -4. send exactly the same AMY wire payloads currently sent over UART; -5. stop/reconnect cleanly across Android activity/audio lifecycle events. - ## Hardware-test items The first device tests should measure: -1. touch-to-audio latency; +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; From 1047019612977f5f525bca910fde5d0dd55e1afa Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:00:03 +0200 Subject: [PATCH 43/47] Make Android AMY service start idempotent --- .../src/main/java/org/amy/audio/AmyService.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 index 0054068f..71105e1c 100644 --- a/android/amy-service/src/main/java/org/amy/audio/AmyService.java +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -13,8 +13,8 @@ * Unexported same-UID service hosting native AMY + Oboe in a separate process. * * Musical control never crosses JNI. The host opens the private pathname Unix - * socket and sends newline-delimited AMY wire messages. JNI is only used to - * start and stop the native engine with the validated socket pathname. + * SOCK_SEQPACKET socket and sends one AMY wire message per packet. JNI is only + * used to start and stop the native engine with the validated socket pathname. */ public final class AmyService extends Service { private static final String TAG = "AmyService"; @@ -27,6 +27,7 @@ public final class AmyService extends Service { } private boolean running; + private String runningSocketPath; private static native int nativeStart(String socketPath); private static native void nativeStop(); @@ -65,9 +66,18 @@ public int onStartCommand(Intent intent, int flags, int 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); @@ -78,6 +88,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { } running = true; + runningSocketPath = socketPath; Log.i(TAG, "AMY listening on private socket " + socketPath); return START_NOT_STICKY; } @@ -100,6 +111,7 @@ public void onDestroy() { if (running) { nativeStop(); running = false; + runningSocketPath = null; } super.onDestroy(); } From 963fb8bbd514475a7cbd5337f8a95b3d3c5bf7d3 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:22:46 +0200 Subject: [PATCH 44/47] Publish Android socket only after audio callback --- .../amy-service/src/main/cpp/amy_android.cpp | 86 +++++++++++++++---- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index ac8ef7d6..bd3bb60e 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include extern "C" { #include "amy.h" @@ -47,6 +49,8 @@ extern "C" void midi_out(uint8_t *bytes, uint16_t len) { namespace { constexpr int kMaxCommandsPerBlock = 64; +constexpr int kAudioReadyTimeoutMs = 2000; +constexpr int kAudioReadyPollMs = 2; class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback { @@ -63,18 +67,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ config.platform.multicore = 0; config.platform.multithread = 0; - /* Omnichord Physical Strings can require fourteen simultaneous KS voices. */ + /* Physical-string clients can require many simultaneous KS voices. */ config.ks_oscs = 16; amy_start(config); mAmyStarted = true; - int socketResult = amy_unix_socket_start(&mSocket, socketPath); - if (socketResult != 0) { - stopAmy(); - return socketResult; - } - oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output); builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); @@ -90,7 +88,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, oboe::Result result = builder.openStream(mStream); if (result != oboe::Result::OK || !mStream) { LOGE("Oboe openStream failed: %s", oboe::convertToText(result)); - cleanupSocketAndAmy(); + stopAmy(); return static_cast(result); } @@ -103,23 +101,66 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, mStream->getSampleRate()); mStream->close(); mStream.reset(); - cleanupSocketAndAmy(); + stopAmy(); return -ERANGE; } 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(); - cleanupSocketAndAmy(); + 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; @@ -135,6 +176,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, } cleanupSocketAndAmy(); + mAudioCallbackSeen.store(false, std::memory_order_release); mBlock = nullptr; mBlockFrame = AMY_BLOCK_SIZE; } @@ -150,6 +192,8 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, 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) { @@ -185,10 +229,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, private: void drainCommands() { - if (mSocket == nullptr) return; + 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(mSocket, command, sizeof(command)); + int length = amy_unix_socket_receive(socket, command, sizeof(command)); if (length <= 0) break; amy_add_message(command); } @@ -202,23 +248,25 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, } void cleanupSocketAndAmy() { - if (mSocket != nullptr) { - uint32_t overruns = amy_unix_socket_queue_overruns(mSocket); - uint32_t oversize = amy_unix_socket_oversize_packets(mSocket); - uint32_t rejected = amy_unix_socket_rejected_peers(mSocket); + 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(mSocket); - mSocket = nullptr; + amy_unix_socket_stop(socket); } stopAmy(); } std::atomic mRunning{false}; + std::atomic mAudioCallbackSeen{false}; bool mAmyStarted = false; - amy_unix_socket_server_t *mSocket = nullptr; + std::atomic mSocket{nullptr}; std::shared_ptr mStream; int16_t *mBlock = nullptr; int32_t mBlockFrame = AMY_BLOCK_SIZE; From 0727020bd2f1260d7cfe4c9376ae24f8514017aa Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:23:10 +0200 Subject: [PATCH 45/47] Document amy.sock audio readiness semantics --- android/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/android/README.md b/android/README.md index 6f35d90f..49a30900 100644 --- a/android/README.md +++ b/android/README.md @@ -95,6 +95,12 @@ 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. @@ -107,7 +113,8 @@ A client application needs to: 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. connect an `AF_UNIX` / `SOCK_SEQPACKET` socket to `/amy.sock`; +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 From 871e9022182c5e90913c9dfb620720b866bbb50d Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:49:00 +0200 Subject: [PATCH 46/47] Log actual Android Oboe output routing --- .../amy-service/src/main/cpp/amy_android.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index bd3bb60e..bfddd62f 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -105,6 +105,15 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, 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); @@ -166,6 +175,10 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, return 0; } + int32_t outputDeviceId() const { + return mStream ? mStream->getDeviceId() : -1; + } + void stop() { mRunning.store(false, std::memory_order_release); @@ -294,6 +307,12 @@ Java_org_amy_audio_AmyService_nativeStart(JNIEnv *env, jclass, jstring socketPat 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); From 0c92595df80185de1a8f72c04d6265f9bc7a18a3 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:49:16 +0200 Subject: [PATCH 47/47] Resolve Android output device type --- .../main/java/org/amy/audio/AmyService.java | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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 index 71105e1c..98ff90e7 100644 --- a/android/amy-service/src/main/java/org/amy/audio/AmyService.java +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -3,6 +3,8 @@ 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; @@ -14,7 +16,7 @@ * * 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 and stop the native engine with the validated socket pathname. + * 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"; @@ -30,6 +32,7 @@ public final class AmyService extends Service { 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. */ @@ -90,9 +93,55 @@ public int onStartCommand(Intent intent, int flags, int startId) { 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();