From 77fc89f41c13f2ab7dbd80987d46dc0cb9dc6788 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:47:19 +0200 Subject: [PATCH 01/27] Add Android AMY hello-world app module --- android/hello-world/build.gradle.kts | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 android/hello-world/build.gradle.kts diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts new file mode 100644 index 00000000..0fcb9f94 --- /dev/null +++ b/android/hello-world/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "org.amy.hello" + compileSdk = 36 + + defaultConfig { + applicationId = "org.amy.hello" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + cppFlags += "-std=c++17" + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } +} + +dependencies { + implementation(project(":amy-service")) +} From a972a2009023492845b30ac0afd95a0c598fe81d Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:47:27 +0200 Subject: [PATCH 02/27] Add hello-world activity manifest --- android/hello-world/src/main/AndroidManifest.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 android/hello-world/src/main/AndroidManifest.xml diff --git a/android/hello-world/src/main/AndroidManifest.xml b/android/hello-world/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c3f384c --- /dev/null +++ b/android/hello-world/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + From 236d80c85caac1a13b736fff894f7b1ab96adad3 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:47:42 +0200 Subject: [PATCH 03/27] Add AMY hello-world activity --- .../main/java/org/amy/hello/MainActivity.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 android/hello-world/src/main/java/org/amy/hello/MainActivity.java diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java new file mode 100644 index 00000000..cc6ac348 --- /dev/null +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -0,0 +1,93 @@ +package org.amy.hello; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.amy.audio.AmyService; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private TextView status; + private Button playButton; + + static { + System.loadLibrary("amy_hello_client"); + } + + private static native int nativePlayCScale(String socketPath); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(48, 48, 48, 48); + + TextView title = new TextView(this); + title.setText("AMY Hello World"); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + status = new TextView(this); + status.setText("Starting AMY..."); + status.setTextSize(18); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + statusParams.setMargins(0, 40, 0, 40); + root.addView(status, statusParams); + + playButton = new Button(this); + playButton.setText("Play C scale"); + playButton.setOnClickListener(v -> playScale()); + root.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + setContentView(root); + + AmyService.start(this); + playScale(); + } + + private void playScale() { + playButton.setEnabled(false); + status.setText("Playing C major scale..."); + String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + + executor.execute(() -> { + int rc = nativePlayCScale(socketPath); + runOnUiThread(() -> { + if (rc == 0) { + status.setText("C scale complete"); + } else { + status.setText("AMY/socket error: " + rc); + } + playButton.setEnabled(true); + }); + }); + } + + @Override + protected void onDestroy() { + AmyService.stop(this); + executor.shutdownNow(); + super.onDestroy(); + } +} From 114e59a2b888e8f72e0132537141a9a1345fc307 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:47:54 +0200 Subject: [PATCH 04/27] Add native socket client build --- android/hello-world/src/main/cpp/CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 android/hello-world/src/main/cpp/CMakeLists.txt diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1915bf81 --- /dev/null +++ b/android/hello-world/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_hello_client LANGUAGES CXX) + +add_library(amy_hello_client SHARED amy_hello_client.cpp) + +target_compile_features(amy_hello_client PRIVATE cxx_std_17) +target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) +target_link_libraries(amy_hello_client PRIVATE log) From f74466c44005b15140c63ef789de0182b1d9cd03 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:48:08 +0200 Subject: [PATCH 05/27] Implement AMY wire C-scale socket client --- .../src/main/cpp/amy_hello_client.cpp | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 android/hello-world/src/main/cpp/amy_hello_client.cpp diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp new file mode 100644 index 00000000..e0abf21e --- /dev/null +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -0,0 +1,93 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#define LOG_TAG "AmyHelloWorld" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +int connect_with_retry(const char *path) { + if (path == nullptr || path[0] == '\0') return -EINVAL; + if (std::strlen(path) >= sizeof(sockaddr_un::sun_path)) return -ENAMETOOLONG; + + for (int attempt = 0; attempt < 100; ++attempt) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); + if (fd < 0) return -errno; + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + return fd; + } + + int saved = errno; + close(fd); + if (saved != ENOENT && saved != ECONNREFUSED) return -saved; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return -ETIMEDOUT; +} + +int send_wire(int fd, const char *wire) { + size_t len = std::strlen(wire); + ssize_t sent = send(fd, wire, len, MSG_NOSIGNAL); + if (sent < 0) return -errno; + if (static_cast(sent) != len) return -EIO; + LOGI("wire: %s", wire); + return 0; +} + +int play_c_scale(const char *path) { + int fd = connect_with_retry(path); + if (fd < 0) return fd; + + int rc = send_wire(fd, "v0w0V0.30Z"); + if (rc < 0) { + close(fd); + return rc; + } + + static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; + char wire[64]; + + for (int note : notes) { + std::snprintf(wire, sizeof(wire), "v0n%dl1Z", note); + rc = send_wire(fd, wire); + if (rc < 0) break; + + std::this_thread::sleep_for(std::chrono::milliseconds(350)); + + rc = send_wire(fd, "v0l0Z"); + if (rc < 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + } + + close(fd); + if (rc == 0) LOGI("C scale complete"); + return rc; +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_hello_MainActivity_nativePlayCScale(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + int rc = play_c_scale(path); + env->ReleaseStringUTFChars(socketPath, path); + if (rc < 0) LOGE("C scale failed: %d", rc); + return rc; +} From f72a697112af44c0dd60f5c1116d37756b8bda8b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:48:27 +0200 Subject: [PATCH 06/27] Enable Android application plugin for hello-world app --- android/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 83ccca12..a2cc8b72 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,4 @@ plugins { + id("com.android.application") version "8.13.2" apply false id("com.android.library") version "8.13.2" apply false } From 669f3018c35a91d2bd5632f0c811cc483ca024a7 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:48:37 +0200 Subject: [PATCH 07/27] Include Android hello-world app --- android/settings.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 84732c83..d17aab50 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "amy-android" include(":amy-service") +include(":hello-world") From 2906ca9f3c891485f62811cd146e9d8c07ca631d Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:48:46 +0200 Subject: [PATCH 08/27] Add x86_64 ABI for Android emulator smoke test --- android/amy-service/build.gradle.kts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts index da4c528e..c0c0e37e 100644 --- a/android/amy-service/build.gradle.kts +++ b/android/amy-service/build.gradle.kts @@ -10,8 +10,11 @@ android { defaultConfig { minSdk = 26 + // arm64-v8a is the production target. x86_64 is included on this + // hello-world branch so CI can run the same AMY/Oboe service in the + // hardware-accelerated Android emulator. ndk { - abiFilters += listOf("arm64-v8a") + abiFilters += listOf("arm64-v8a", "x86_64") } externalNativeBuild { From 41c513ca18f348c7e4249c30a526ddb30c832ac1 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:49:05 +0200 Subject: [PATCH 09/27] Fix hello-world native socket client build --- .../hello-world/src/main/cpp/amy_hello_client.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp index e0abf21e..b8cc8b9e 100644 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -18,16 +19,16 @@ namespace { int connect_with_retry(const char *path) { if (path == nullptr || path[0] == '\0') return -EINVAL; - if (std::strlen(path) >= sizeof(sockaddr_un::sun_path)) return -ENAMETOOLONG; + + sockaddr_un addr{}; + if (std::strlen(path) >= sizeof(addr.sun_path)) return -ENAMETOOLONG; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); for (int attempt = 0; attempt < 100; ++attempt) { int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); if (fd < 0) return -errno; - sockaddr_un addr{}; - addr.sun_family = AF_UNIX; - std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); - if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { return fd; } @@ -53,6 +54,8 @@ int play_c_scale(const char *path) { int fd = connect_with_retry(path); if (fd < 0) return fd; + // Raw oscillator 0, sine wave, moderate volume. Every subsequent packet is + // an ordinary AMY wire command sent through the private amy.sock transport. int rc = send_wire(fd, "v0w0V0.30Z"); if (rc < 0) { close(fd); From b94a24c11c596a88f66ab2dea3d5f06a894f130f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:49:32 +0200 Subject: [PATCH 10/27] Build and smoke-test Android AMY hello world --- .github/workflows/android.yml | 36 ++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 53d7fcc3..5b82f29e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,7 +20,7 @@ jobs: - name: Build and run private Unix socket test run: bash tests/run_amy_unix_socket_test.sh - android-aar: + android-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -45,9 +45,9 @@ jobs: with: gradle-version: "8.13" - - name: Build AMY Android AAR + - name: Build AMY Android AAR and hello-world APK working-directory: android - run: gradle :amy-service:assembleDebug --stacktrace + run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace - name: Upload AMY Android AAR uses: actions/upload-artifact@v4 @@ -55,3 +55,33 @@ jobs: name: amy-service-debug-aar path: android/amy-service/build/outputs/aar/amy-service-debug.aar if-no-files-found: error + + - name: Upload AMY hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-hello-world-debug-apk + path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + if-no-files-found: error + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Emulator end-to-end smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + script: | + adb install -r hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 8 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee /tmp/amy-hello.log + grep -q "AMY/Oboe started" /tmp/amy-hello.log + grep -q "C scale complete" /tmp/amy-hello.log From cc632beaaac6ef45872f0cab02e88dd964e773fe Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:49:52 +0200 Subject: [PATCH 11/27] Pin hello-world Android NDK --- android/hello-world/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts index 0fcb9f94..2e77a882 100644 --- a/android/hello-world/build.gradle.kts +++ b/android/hello-world/build.gradle.kts @@ -5,6 +5,7 @@ plugins { android { namespace = "org.amy.hello" compileSdk = 36 + ndkVersion = "27.0.12077973" defaultConfig { applicationId = "org.amy.hello" From 5d7349ffec06158faa00f902c203d1c87a41a07c Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:50:05 +0200 Subject: [PATCH 12/27] Document Android AMY hello-world demo --- android/hello-world/README.md | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 android/hello-world/README.md diff --git a/android/hello-world/README.md b/android/hello-world/README.md new file mode 100644 index 00000000..c0dc435b --- /dev/null +++ b/android/hello-world/README.md @@ -0,0 +1,46 @@ +# AMY Android Hello World + +Minimal Android application proving the AMY Android service end to end. + +On launch it: + +1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; +2. connects to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket; +3. configures raw oscillator 0 as a sine wave; +4. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +5. shows `C scale complete` when all packets have been sent. + +The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. + +## Wire sequence + +Setup: + +```text +v0w0V0.30Z +``` + +Notes use MIDI note numbers and velocity, e.g. middle C: + +```text +v0n60l1Z +v0l0Z +``` + +The complete scale is MIDI notes `60, 62, 64, 65, 67, 69, 71, 72`. + +## Build + +From `android/`: + +```bash +gradle :hello-world:assembleDebug +``` + +APK: + +```text +hello-world/build/outputs/apk/debug/hello-world-debug.apk +``` + +The CI Android emulator smoke test installs this APK, starts the activity, and requires both `AMY/Oboe started` and `C scale complete` in logcat. From 6bc573619d765bce3a59b419465a32fe0ca8d5fd Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 21:55:20 +0200 Subject: [PATCH 13/27] Fix hello-world emulator APK path and enable audio --- .github/workflows/android.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5b82f29e..8eea46cc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -77,11 +77,12 @@ jobs: arch: x86_64 profile: pixel_2 disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | - adb install -r hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb install -r android/hello-world/build/outputs/apk/debug/hello-world-debug.apk adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity - sleep 8 + sleep 10 adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee /tmp/amy-hello.log grep -q "AMY/Oboe started" /tmp/amy-hello.log grep -q "C scale complete" /tmp/amy-hello.log From baf8c85fb5156033aa312855853289fbb489cca1 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:00:26 +0200 Subject: [PATCH 14/27] Keep AMY service alive across repeated start requests --- .../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 ba98036d382b61d8e1b945ad6c54b4df8eb288d7 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:01:08 +0200 Subject: [PATCH 15/27] Keep hello-world scale stable across activity recreation --- .../main/java/org/amy/hello/MainActivity.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java index cc6ac348..e1b5eb96 100644 --- a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -15,7 +15,8 @@ import java.util.concurrent.Executors; public final class MainActivity extends Activity { - private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private TextView status; private Button playButton; @@ -62,7 +63,11 @@ protected void onCreate(Bundle state) { setContentView(root); AmyService.start(this); - playScale(); + if (state == null) { + playScale(); + } else { + status.setText("AMY ready"); + } } private void playScale() { @@ -71,9 +76,10 @@ private void playScale() { String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) .getAbsolutePath(); - executor.execute(() -> { + EXECUTOR.execute(() -> { int rc = nativePlayCScale(socketPath); runOnUiThread(() -> { + if (isDestroyed()) return; if (rc == 0) { status.setText("C scale complete"); } else { @@ -83,11 +89,4 @@ private void playScale() { }); }); } - - @Override - protected void onDestroy() { - AmyService.stop(this); - executor.shutdownNow(); - super.onDestroy(); - } } From 2dc8aa5e3fd34d433cbc39b4219b24782d6b690b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:01:46 +0200 Subject: [PATCH 16/27] Make Android hello-world smoke test detect duplicate failures --- .github/workflows/android.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8eea46cc..2202ff01 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -84,5 +84,7 @@ jobs: adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee /tmp/amy-hello.log - grep -q "AMY/Oboe started" /tmp/amy-hello.log - grep -q "C scale complete" /tmp/amy-hello.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-hello.log)" -eq 1 + test "$(grep -c 'C scale complete' /tmp/amy-hello.log)" -eq 1 + ! grep -q "C scale failed" /tmp/amy-hello.log + grep -q "wire: v0n72l1Z" /tmp/amy-hello.log From 45e5845d800cb8b02aee9d98b97edcf50612fb68 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:23:44 +0200 Subject: [PATCH 17/27] Use audio-ready amy.sock on hello-world branch --- .../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 f7911f3efd21d87c3eb01ca4c003062b722bace5 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:24:10 +0200 Subject: [PATCH 18/27] Sync generic Android service docs --- android/README.md | 78 +++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/android/README.md b/android/README.md index 569e2536..49a30900 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. @@ -88,9 +95,34 @@ 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 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. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` + until the service publishes its ready socket; +5. send one ordinary AMY wire message per packet; +6. optionally receive response packets over the same bidirectional socket; +7. stop and reconnect cleanly across Android application/audio lifecycle + events. + +The transport deliberately does not prescribe a programming language or UI +framework. A minimal example client is provided separately by the Android +hello-world application. ## Building the AAR @@ -111,7 +143,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 +151,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 +165,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 de094d8a5d6372dcfe6324d26e2da4c1ba7a055f Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:24:25 +0200 Subject: [PATCH 19/27] Make first-install scale deterministic and louder --- android/hello-world/src/main/cpp/amy_hello_client.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp index b8cc8b9e..af0e0d37 100644 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -54,14 +54,18 @@ int play_c_scale(const char *path) { int fd = connect_with_retry(path); if (fd < 0) return fd; - // Raw oscillator 0, sine wave, moderate volume. Every subsequent packet is - // an ordinary AMY wire command sent through the private amy.sock transport. - int rc = send_wire(fd, "v0w0V0.30Z"); + // Raw oscillator 0, sine wave, clearly audible while retaining headroom. + // Every packet is an ordinary AMY wire command sent through amy.sock. + int rc = send_wire(fd, "v0w0V0.80Z"); if (rc < 0) { close(fd); return rc; } + // On a completely fresh AMY instance, commit oscillator setup before the + // first note-on instead of allowing both commands into the same first drain. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; char wire[64]; From b89e69908dfeea3c90185bca6cda0e91c894177b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:24:52 +0200 Subject: [PATCH 20/27] Test two fresh installs of Android hello world --- .github/workflows/android.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2202ff01..90ae1563 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -79,12 +79,22 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | - adb install -r android/hello-world/build/outputs/apk/debug/hello-world-debug.apk - adb logcat -c - adb shell am start -W -n org.amy.hello/.MainActivity - sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee /tmp/amy-hello.log - test "$(grep -c 'AMY/Oboe started' /tmp/amy-hello.log)" -eq 1 - test "$(grep -c 'C scale complete' /tmp/amy-hello.log)" -eq 1 - ! grep -q "C scale failed" /tmp/amy-hello.log - grep -q "wire: v0n72l1Z" /tmp/amy-hello.log + run_fresh_install() { + log_file="$1" + adb uninstall org.amy.hello >/dev/null 2>&1 || true + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee "$log_file" + test "$(grep -c 'AMY/Oboe started' "$log_file")" -eq 1 + test "$(grep -c 'C scale complete' "$log_file")" -eq 1 + ! grep -q "C scale failed" "$log_file" + grep -q "wire: v0w0V0.80Z" "$log_file" + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' "$log_file")" -eq 8 + grep -q "wire: v0n60l1Z" "$log_file" + grep -q "wire: v0n72l1Z" "$log_file" + } + + run_fresh_install /tmp/amy-hello-first-install.log + run_fresh_install /tmp/amy-hello-second-install.log From 7c8eaa4d7852052f41d984bf0b09958823e74250 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:25:11 +0200 Subject: [PATCH 21/27] Document louder deterministic Android hello world --- android/hello-world/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/android/hello-world/README.md b/android/hello-world/README.md index c0dc435b..1fb84699 100644 --- a/android/hello-world/README.md +++ b/android/hello-world/README.md @@ -1,14 +1,15 @@ # AMY Android Hello World -Minimal Android application proving the AMY Android service end to end. +Minimal Android application proving the generic AMY Android service end to end. On launch it: 1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; -2. connects to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket; -3. configures raw oscillator 0 as a sine wave; -4. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; -5. shows `C scale complete` when all packets have been sent. +2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; +3. configures raw oscillator 0 as a sine wave at `V0.80`; +4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; +5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +6. shows `C scale complete` when all packets have been sent. The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. @@ -17,7 +18,7 @@ The note path does not call AMY through JNI. JNI is used only for the Android cl Setup: ```text -v0w0V0.30Z +v0w0V0.80Z ``` Notes use MIDI note numbers and velocity, e.g. middle C: @@ -43,4 +44,4 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test installs this APK, starts the activity, and requires both `AMY/Oboe started` and `C scale complete` in logcat. +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, exactly one completed C scale, all eight note-on packets, and no socket failure. From b05db1b0ca88a4c3666f5a128189f4a12852df79 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:30:19 +0200 Subject: [PATCH 22/27] Fix repeated fresh-install emulator test --- .github/workflows/android.yml | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 90ae1563..41d89746 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -79,22 +79,29 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | - run_fresh_install() { - log_file="$1" - adb uninstall org.amy.hello >/dev/null 2>&1 || true - adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk - adb logcat -c - adb shell am start -W -n org.amy.hello/.MainActivity - sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' | tee "$log_file" - test "$(grep -c 'AMY/Oboe started' "$log_file")" -eq 1 - test "$(grep -c 'C scale complete' "$log_file")" -eq 1 - ! grep -q "C scale failed" "$log_file" - grep -q "wire: v0w0V0.80Z" "$log_file" - test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' "$log_file")" -eq 8 - grep -q "wire: v0n60l1Z" "$log_file" - grep -q "wire: v0n72l1Z" "$log_file" - } - - run_fresh_install /tmp/amy-hello-first-install.log - run_fresh_install /tmp/amy-hello-second-install.log + adb uninstall org.amy.hello >/dev/null 2>&1 || true + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 + test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-first.log + grep -q 'wire: v0w0V0.80Z' /tmp/amy-first.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-first.log + grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + adb uninstall org.amy.hello + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 + test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-second.log + grep -q 'wire: v0w0V0.80Z' /tmp/amy-second.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-second.log + grep -q 'wire: v0n72l1Z' /tmp/amy-second.log From f85aa7bece1d610a58b780427ba540597d3309a6 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:47:06 +0200 Subject: [PATCH 23/27] Raise Android hello-world AMY output gain --- android/hello-world/src/main/cpp/amy_hello_client.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp index af0e0d37..df83a9e6 100644 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -54,9 +54,10 @@ int play_c_scale(const char *path) { int fd = connect_with_retry(path); if (fd < 0) return fd; - // Raw oscillator 0, sine wave, clearly audible while retaining headroom. + // Raw oscillator 0, sine wave. V is AMY's global output gain, not an + // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. // Every packet is an ordinary AMY wire command sent through amy.sock. - int rc = send_wire(fd, "v0w0V0.80Z"); + int rc = send_wire(fd, "v0w0V2.0Z"); if (rc < 0) { close(fd); return rc; From 3596ff03e516df2497dee8e1f1ee1a5655fa15f7 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:47:31 +0200 Subject: [PATCH 24/27] Log 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 b790001ca266574e981ecb76a906fce605e7ae2b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:47:48 +0200 Subject: [PATCH 25/27] Resolve and log Android audio output device --- .../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(); From 877da74b75ff60368503fa17411fb825effb9602 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:48:08 +0200 Subject: [PATCH 26/27] Validate Android output route and V2 gain --- .github/workflows/android.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 41d89746..6c5bdeac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -86,9 +86,10 @@ jobs: sleep 10 adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-first.log test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-first.log - grep -q 'wire: v0w0V0.80Z' /tmp/amy-first.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-first.log grep -q 'wire: v0n72l1Z' /tmp/amy-first.log @@ -99,9 +100,10 @@ jobs: sleep 10 adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-second.log test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-second.log - grep -q 'wire: v0w0V0.80Z' /tmp/amy-second.log + grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-second.log grep -q 'wire: v0n72l1Z' /tmp/amy-second.log From ed338bf0e95b090b4baef0819f8ae9ee61cf24c4 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sat, 22 Aug 2026 22:48:17 +0200 Subject: [PATCH 27/27] Document Android hello-world gain and route diagnostics --- android/hello-world/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/android/hello-world/README.md b/android/hello-world/README.md index 1fb84699..ee44a05a 100644 --- a/android/hello-world/README.md +++ b/android/hello-world/README.md @@ -6,21 +6,25 @@ On launch it: 1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; 2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; -3. configures raw oscillator 0 as a sine wave at `V0.80`; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V2.0`; 4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; 5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; 6. shows `C scale complete` when all packets have been sent. The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. +The generic AMY Android service also logs Oboe's actual output device ID and resolves it through `AudioDeviceInfo`, so device logs identify routes such as `BUILTIN_SPEAKER`, `BUILTIN_EARPIECE`, Bluetooth, wired headphones, or USB where Android exposes a matching device. + ## Wire sequence Setup: ```text -v0w0V0.80Z +v0w0V2.0Z ``` +`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. + Notes use MIDI note numbers and velocity, e.g. middle C: ```text @@ -44,4 +48,4 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, exactly one completed C scale, all eight note-on packets, and no socket failure. +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure.