From 8eb030aea784359fb575e0f5520c4c2a00aebf8b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:37:21 +0200 Subject: [PATCH 01/19] Add reusable Android client and Godot service hello world Expose a small persistent AmyClient in the Android AAR, refactor the Java hello-world to use it, and add a Godot Android backend/example that keeps AMY in the separate :amy Oboe service process. Include architecture documentation and an emulator CI path from GDScript through JavaClassWrapper, the AAR socket client, AMY and Oboe. --- .github/workflows/godot-android.yml | 107 ++++++++ .../amy-service/src/main/cpp/CMakeLists.txt | 12 + .../src/main/cpp/amy_android_client.cpp | 88 +++++++ .../main/java/org/amy/audio/AmyClient.java | 107 ++++++++ android/hello-world/build.gradle.kts | 13 - .../hello-world/src/main/cpp/CMakeLists.txt | 8 - .../src/main/cpp/amy_hello_client.cpp | 102 ------- .../main/java/org/amy/hello/MainActivity.java | 63 ++++- godot/ANDROID.md | 248 ++++++++++++++++++ godot/amy_android.gd | 82 ++++++ godot/android-hello-world/.gitignore | 6 + godot/android-hello-world/README.md | 86 ++++++ godot/android-hello-world/export_presets.cfg | 46 ++++ godot/android-hello-world/main.gd | 96 +++++++ godot/android-hello-world/main.tscn | 12 + godot/android-hello-world/prepare.sh | 20 ++ godot/android-hello-world/project.godot | 17 ++ 17 files changed, 979 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/godot-android.yml create mode 100644 android/amy-service/src/main/cpp/amy_android_client.cpp create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyClient.java delete mode 100644 android/hello-world/src/main/cpp/CMakeLists.txt delete mode 100644 android/hello-world/src/main/cpp/amy_hello_client.cpp create mode 100644 godot/ANDROID.md create mode 100644 godot/amy_android.gd create mode 100644 godot/android-hello-world/.gitignore create mode 100644 godot/android-hello-world/README.md create mode 100644 godot/android-hello-world/export_presets.cfg create mode 100644 godot/android-hello-world/main.gd create mode 100644 godot/android-hello-world/main.tscn create mode 100755 godot/android-hello-world/prepare.sh create mode 100644 godot/android-hello-world/project.godot diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml new file mode 100644 index 00000000..34f39a4a --- /dev/null +++ b/.github/workflows/godot-android.yml @@ -0,0 +1,107 @@ +name: Godot Android AMY + +on: + pull_request: + paths: + - "android/**" + - "godot/**" + - "src/amy_unix_socket.c" + - "src/amy_unix_socket.h" + - ".github/workflows/godot-android.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + godot-android: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.1" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.13" + + - name: Build AMY AAR and prepare Godot addon + run: bash godot/android-hello-world/prepare.sh + + - name: Install Godot 4.7.2 and export templates + run: | + curl -L --fail --retry 3 \ + -o /tmp/godot.zip \ + https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip + unzip -q /tmp/godot.zip -d /tmp/godot + GODOT_BIN="$(find /tmp/godot -maxdepth 1 -type f -name 'Godot*' | head -n1)" + chmod +x "$GODOT_BIN" + sudo cp "$GODOT_BIN" /usr/local/bin/godot + + curl -L --fail --retry 3 \ + -o /tmp/godot-templates.tpz \ + https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz + rm -rf /tmp/godot-templates + mkdir -p /tmp/godot-templates + unzip -q /tmp/godot-templates.tpz -d /tmp/godot-templates + mkdir -p "$HOME/.local/share/godot/export_templates/4.7.2.stable" + cp -a /tmp/godot-templates/templates/. \ + "$HOME/.local/share/godot/export_templates/4.7.2.stable/" + godot --version + + - name: Export Godot Android hello-world + run: | + mkdir -p godot/android-hello-world/build + godot --headless \ + --path godot/android-hello-world \ + --install-android-build-template \ + --export-debug Android build/godot-amy-hello.apk + test -s godot/android-hello-world/build/godot-amy-hello.apk + + - name: Upload Godot Android hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-godot-android-hello-world-apk + path: godot/android-hello-world/build/godot-amy-hello.apk + if-no-files-found: error + + - name: Emulator Godot-to-AMY smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim + script: | + adb uninstall org.amy.godothello >/dev/null 2>&1 || true + adb install godot/android-hello-world/build/godot-amy-hello.apk + adb logcat -c + adb shell monkey -p org.amy.godothello 1 >/dev/null + sleep 10 + adb logcat -d > /tmp/amy-godot.log + + grep -q 'AMY/Oboe started' /tmp/amy-godot.log + grep -q 'AMY Android service ready' /tmp/amy-godot.log + grep -q 'Godot AMY ready' /tmp/amy-godot.log + grep -q 'Godot wire: v0w0V10' /tmp/amy-godot.log + test "$(grep -Ec 'Godot wire: v0n(60|62|64|65|67|69|71|72)l1' /tmp/amy-godot.log)" -eq 8 + grep -q 'Godot wire: v0n60l1' /tmp/amy-godot.log + grep -q 'Godot wire: v0n72l1' /tmp/amy-godot.log + grep -q 'Godot C scale complete' /tmp/amy-godot.log + ! grep -q 'Godot AMY error:' /tmp/amy-godot.log diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 08fbf51c..423387ee 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -36,6 +36,13 @@ add_library(amy_android SHARED ${AMY_SOURCES} ) +# Small framework-independent client JNI library. It contains no AMY engine and +# can be loaded safely in a UI/framework process; it only owns one persistent +# AF_UNIX/SOCK_SEQPACKET descriptor to the :amy service process. +add_library(amy_android_client SHARED + amy_android_client.cpp +) + target_include_directories(amy_android PRIVATE ${AMY_SRC} ${CMAKE_CURRENT_SOURCE_DIR} @@ -61,7 +68,12 @@ target_compile_options(amy_android PRIVATE $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter> ) +target_compile_options(amy_android_client PRIVATE + -O3 -Wall -Wextra -Wno-unused-parameter +) + target_compile_features(amy_android PRIVATE c_std_11 cxx_std_17) +target_compile_features(amy_android_client PRIVATE cxx_std_17) target_link_libraries(amy_android PRIVATE oboe::oboe diff --git a/android/amy-service/src/main/cpp/amy_android_client.cpp b/android/amy-service/src/main/cpp/amy_android_client.cpp new file mode 100644 index 00000000..a407d73f --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_client.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +int connect_socket(const char *path) { + if (path == nullptr || path[0] == '\0') return -EINVAL; + + sockaddr_un addr{}; + const size_t path_len = std::strlen(path); + if (path_len >= sizeof(addr.sun_path)) return -ENAMETOOLONG; + + addr.sun_family = AF_UNIX; + std::memcpy(addr.sun_path, path, path_len + 1u); + + int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); + if (fd < 0) return -errno; + + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + const int saved = errno; + close(fd); + return -saved; + } + + // Musical control must never stall a UI/framework thread behind a full + // socket buffer. After the connection is established, make sends + // non-blocking; callers can detect -EAGAIN and decide how to recover. + const int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { + const int saved = errno; + close(fd); + return -saved; + } + + return fd; +} + +int send_wire(int fd, JNIEnv *env, jstring wire) { + if (fd < 0) return -ENOTCONN; + if (wire == nullptr) return -EINVAL; + + const jsize len = env->GetStringUTFLength(wire); + if (len <= 0) return -EINVAL; + + const char *bytes = env->GetStringUTFChars(wire, nullptr); + if (bytes == nullptr) return -ENOMEM; + + const ssize_t sent = send(fd, + bytes, + static_cast(len), + MSG_NOSIGNAL | MSG_DONTWAIT); + const int saved = sent < 0 ? errno : 0; + env->ReleaseStringUTFChars(wire, bytes); + + if (sent < 0) return -saved; + if (sent != len) return -EIO; + return 0; +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyClient_nativeConnect(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + const int result = connect_socket(path); + env->ReleaseStringUTFChars(socketPath, path); + return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyClient_nativeSend(JNIEnv *env, jclass, jint fd, jstring wire) { + return send_wire(fd, env, wire); +} + +extern "C" JNIEXPORT void JNICALL +Java_org_amy_audio_AmyClient_nativeClose(JNIEnv *, jclass, jint fd) { + if (fd >= 0) close(fd); +} diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyClient.java b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java new file mode 100644 index 00000000..01c163c4 --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java @@ -0,0 +1,107 @@ +package org.amy.audio; + +import android.content.Context; + +import java.io.File; + +/** + * Small persistent client for the private AMY Android wire socket. + * + *

This class contains no synthesizer and no audio path. The AMY engine stays + * in {@link AmyService}'s separate {@code :amy} process; AmyClient only owns a + * native AF_UNIX/SOCK_SEQPACKET descriptor in the calling process. Each + * {@link #sendWire(String)} call is one packet and therefore one AMY wire + * request.

+ * + *

{@link #connect(Context)} performs one immediate connection attempt. + * {@link #connectWithRetry(Context, int)} is a convenience for a worker thread; + * do not use the retrying form on an Android or game-engine UI thread.

+ */ +public final class AmyClient implements AutoCloseable { + private static final int CONNECT_RETRY_MS = 50; + private static final int EINVAL = 22; + private static final int EINTR = 4; + private static final int ENOTCONN = 107; + + private int nativeFd = -1; + + static { + System.loadLibrary("amy_android_client"); + } + + public AmyClient() {} + + /** Return the service socket pathname for this application. */ + public static String socketPath(Context context) { + if (context == null) return ""; + return new File(context.getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + } + + /** + * Attempt one connection. Returns 0 on success or a negative errno value. + * The service publishes amy.sock only after its Oboe callback is running. + */ + public synchronized int connect(Context context) { + if (context == null) return -EINVAL; + closeLocked(); + int fd = nativeConnect(socketPath(context)); + if (fd < 0) return fd; + nativeFd = fd; + return 0; + } + + /** + * Retry connect() every 50 ms until success or timeout. Intended for a + * worker thread. timeoutMs <= 0 means one attempt only. + */ + public int connectWithRetry(Context context, int timeoutMs) { + final long deadline = System.nanoTime() + + Math.max(timeoutMs, 0) * 1_000_000L; + int result; + do { + result = connect(context); + if (result == 0 || timeoutMs <= 0) return result; + if (System.nanoTime() >= deadline) return result; + try { + Thread.sleep(CONNECT_RETRY_MS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return -EINTR; + } + } while (true); + } + + /** + * Send exactly one AMY wire request packet. Returns 0 or negative errno. + * The native socket is non-blocking; a caller may see -EAGAIN under + * sustained backpressure instead of stalling its UI/control thread. + */ + public synchronized int sendWire(String wire) { + if (nativeFd < 0) return -ENOTCONN; + if (wire == null || wire.isEmpty()) return -EINVAL; + int result = nativeSend(nativeFd, wire); + if (result < 0) closeLocked(); + return result; + } + + public synchronized boolean isConnected() { + return nativeFd >= 0; + } + + @Override + public synchronized void close() { + closeLocked(); + } + + private void closeLocked() { + if (nativeFd >= 0) { + nativeClose(nativeFd); + nativeFd = -1; + } + } + + private static native int nativeConnect(String socketPath); + private static native int nativeSend(int fd, String wire); + private static native void nativeClose(int fd); +} diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts index 2e77a882..b63416a7 100644 --- a/android/hello-world/build.gradle.kts +++ b/android/hello-world/build.gradle.kts @@ -17,19 +17,6 @@ android { 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" - } } } diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt deleted file mode 100644 index 1915bf81..00000000 --- a/android/hello-world/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,8 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) -project(amy_hello_client LANGUAGES CXX) - -add_library(amy_hello_client SHARED amy_hello_client.cpp) - -target_compile_features(amy_hello_client PRIVATE cxx_std_17) -target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) -target_link_libraries(amy_hello_client PRIVATE log) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp deleted file mode 100644 index 8a4ab011..00000000 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include - -#define LOG_TAG "AmyHelloWorld" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -namespace { - -int connect_with_retry(const char *path) { - if (path == nullptr || path[0] == '\0') return -EINVAL; - - sockaddr_un addr{}; - if (std::strlen(path) >= sizeof(addr.sun_path)) return -ENAMETOOLONG; - addr.sun_family = AF_UNIX; - std::strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); - - for (int attempt = 0; attempt < 100; ++attempt) { - int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); - if (fd < 0) return -errno; - - if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { - return fd; - } - - int saved = errno; - close(fd); - if (saved != ENOENT && saved != ECONNREFUSED) return -saved; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - return -ETIMEDOUT; -} - -int send_wire(int fd, const char *wire) { - size_t len = std::strlen(wire); - ssize_t sent = send(fd, wire, len, MSG_NOSIGNAL); - if (sent < 0) return -errno; - if (static_cast(sent) != len) return -EIO; - LOGI("wire: %s", wire); - return 0; -} - -int play_c_scale(const char *path) { - int fd = connect_with_retry(path); - if (fd < 0) return fd; - - // Raw oscillator 0, sine wave. AMY's V control is a 0..10 bus/master - // volume scale; the final mixer multiplies V by 0.1. Use V10.0 so this - // audible hello-world exercises the full AMY output level. - // Every packet is an ordinary AMY wire command sent through amy.sock. - int rc = send_wire(fd, "v0w0V10.0Z"); - if (rc < 0) { - close(fd); - return rc; - } - - // On a completely fresh AMY instance, commit oscillator setup before the - // first note-on instead of allowing both commands into the same first drain. - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - - static constexpr int notes[] = {60, 62, 64, 65, 67, 69, 71, 72}; - char wire[64]; - - for (int note : notes) { - std::snprintf(wire, sizeof(wire), "v0n%dl1Z", note); - rc = send_wire(fd, wire); - if (rc < 0) break; - - std::this_thread::sleep_for(std::chrono::milliseconds(350)); - - rc = send_wire(fd, "v0l0Z"); - if (rc < 0) break; - std::this_thread::sleep_for(std::chrono::milliseconds(80)); - } - - close(fd); - if (rc == 0) LOGI("C scale complete"); - return rc; -} - -} // namespace - -extern "C" JNIEXPORT jint JNICALL -Java_org_amy_hello_MainActivity_nativePlayCScale(JNIEnv *env, jclass, jstring socketPath) { - if (socketPath == nullptr) return -EINVAL; - const char *path = env->GetStringUTFChars(socketPath, nullptr); - if (path == nullptr) return -ENOMEM; - int rc = play_c_scale(path); - env->ReleaseStringUTFChars(socketPath, path); - if (rc < 0) LOGE("C scale failed: %d", rc); - return rc; -} diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java index 044d530f..aadef7c3 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 @@ -9,6 +9,7 @@ import android.widget.LinearLayout; import android.widget.TextView; +import org.amy.audio.AmyClient; import org.amy.audio.AmyService; import java.io.File; @@ -19,17 +20,13 @@ public final class MainActivity extends Activity { private static final String TAG = "AmyHelloWorld"; private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; - private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final AmyClient amyClient = new AmyClient(); 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); @@ -86,23 +83,67 @@ protected void onCreate(Bundle state) { } } + private int sendLogged(String wire) { + int result = amyClient.sendWire(wire); + if (result == 0) { + Log.i(TAG, "wire: " + wire); + } + return result; + } + + private int playCScale() { + int result = amyClient.isConnected() ? 0 : amyClient.connectWithRetry(this, 5000); + if (result < 0) return result; + + // AMY's V control is a 0..10 bus/master volume scale; V10.0 gives full + // master gain. AmyClient preserves one wire request per socket packet. + result = sendLogged("v0w0V10.0Z"); + if (result < 0) return result; + + try { + Thread.sleep(30); + final int[] notes = {60, 62, 64, 65, 67, 69, 71, 72}; + for (int note : notes) { + result = sendLogged("v0n" + note + "l1Z"); + if (result < 0) return result; + Thread.sleep(350); + + result = sendLogged("v0l0Z"); + if (result < 0) return result; + Thread.sleep(80); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return -4; + } + + Log.i(TAG, "C scale complete"); + return 0; + } + 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); + executor.execute(() -> { + int rc = playCScale(); runOnUiThread(() -> { if (isDestroyed()) return; if (rc == 0) { status.setText("C scale complete"); } else { + Log.e(TAG, "C scale failed: " + rc); status.setText("AMY/socket error: " + rc); } playButton.setEnabled(true); }); }); } + + @Override + protected void onDestroy() { + amyClient.close(); + executor.shutdownNow(); + super.onDestroy(); + } } diff --git a/godot/ANDROID.md b/godot/ANDROID.md new file mode 100644 index 00000000..75003112 --- /dev/null +++ b/godot/ANDROID.md @@ -0,0 +1,248 @@ +# AMY + Godot on Android: separate service architecture + +## Recommendation + +For Android, use AMY as the `amy-service` AAR in its own `:amy` process and let +Godot be a control/UI client over the private AMY wire socket. Do not embed a +second AMY renderer in the Godot process unless an application specifically +needs in-process audio for reasons that outweigh Android latency/isolation. + +This is Android-specific advice. The existing embedded GDExtension remains a +reasonable desktop/native Godot backend, and the existing WASM backend remains +appropriate for the web. + +## Why this differs from the normal Godot integration + +The existing generic native Godot backend looks approximately like this: + +```text +Godot process + -> Amy GDScript + -> AmySynth GDExtension + -> AMY renderer + -> Godot AudioStreamGenerator + -> Godot/Android audio output +``` + +That design is portable and simple. The current generic wrapper also uses a +44.1 kHz `AudioStreamGenerator` with a 0.1 second generator buffer. For a game +that may be acceptable; for a touch musical instrument it puts Godot's audio +queue directly in the latency path and mixes AMY's scheduling fate with the +rendering/scripting process. + +The Android service backend instead uses: + +```text +Godot UI/game process + -> Amy.send(Dictionary) + -> existing Amy.message() wire serializer + -> AmyAndroid.send_raw() + -> JavaClassWrapper / AndroidRuntime + -> AmyClient from amy-service.aar + -> private AF_UNIX / SOCK_SEQPACKET amy.sock + +separate :amy process (same application UID) + -> socket receiver / bounded command queue + -> Oboe high-priority low-latency audio callback + -> amy_simple_fill_buffer() + -> AAudio / device low-latency path +``` + +No rendered PCM is transferred between Godot and AMY. Godot sends compact +control messages only. AMY renders directly on Oboe's callback thread in the +process that owns the Android audio stream. + +## Why the process separation is useful + +The separate process is primarily **isolation**, not CPU reservation. Android +and Linux schedule threads, and `android:process=":amy"` does not reserve a CPU +core for AMY. The realtime-relevant thread priority comes from the callback +stream requested through Oboe/AAudio. + +The separation is still valuable: + +- Godot frame rendering, GDScript, scene changes and Java-side UI work are not + running inside the AMY process. +- UI garbage collection and large framework allocations do not share the AMY + process heap. +- The synth/audio lifecycle has one clear owner instead of being coupled to a + Godot `AudioStreamGenerator` producer. +- An AMY service failure is isolated from most framework state, and framework + changes do not require redesigning the synth engine. +- The Android scheduler sees a small audio-oriented process whose important + work is the Oboe callback rather than a process containing the entire game/UI. +- The same service can be used by Godot, Qt, Java/Kotlin, a native Android app, + or another framework without changing AMY's audio backend. + +Again, this does not guarantee exclusive CPU time. Other Android/system work can +still preempt the audio callback. + +## Realtime path and buffering + +The service requests from Oboe: + +- `PerformanceMode::LowLatency` +- `SharingMode::Exclusive` (a request; the device may negotiate Shared) +- callback-driven stereo signed-16-bit audio +- AMY's native 48 kHz rate + +AMY is compiled at 48 kHz with 128-frame render blocks (2.67 ms per block). +Those 128 frames are the AMY synthesis/command quantum; they are **not** the +Android hardware DMA size. Android hides the hardware DMA behind AAudio/HAL and +reports its own device burst/buffer sizes through Oboe. + +The adapter accepts whatever `numFrames` Android requests, consumes the tail of +the current AMY block and renders another 128-frame block only when necessary. +It deliberately does not add another full AMY output ring between the synth and +Oboe. + +The service therefore keeps the low-latency Android audio path separate from +Godot's audio mixer and generator buffering. + +## Why use the AMY wire protocol + +AMY already has a compact wire representation for musical control. It is a good +process boundary because: + +- messages are tiny compared with audio buffers; +- `SOCK_SEQPACKET` preserves exactly one logical AMY request per packet; +- no extra stream framing or JSON parser is needed in the realtime service; +- the service can queue messages and apply them at AMY block boundaries; +- the same representation is usable from any client language/framework. + +The socket is private to the application. It lives at +`/amy.sock`, mode 0600; the server also checks +`SO_PEERCRED` and only accepts the same application UID. The service is +`android:exported="false"`. The `:amy` process has a different PID/address space +but the same Android application UID as Godot. + +## Reusable AmyClient AAR API + +The AAR exposes two framework-independent Java classes: + +```java +AmyService.start(context); + +AmyClient client = new AmyClient(); +int rc = client.connect(context); // one immediate attempt +// or from a worker thread: +rc = client.connectWithRetry(context, 5000); + +client.sendWire("v0w0V10"); +client.sendWire("v0n60l1"); +client.sendWire("v0l0"); + +client.close(); +AmyService.stop(context); +``` + +`AmyClient` loads a small native library containing only Unix socket operations; +it does **not** link or instantiate AMY in the client process. The connection is +persistent. The native descriptor is switched to non-blocking mode after +connect so a full control socket cannot stall a UI/framework thread; callers +receive a negative errno such as `-EAGAIN` instead. + +`amy.sock` is deliberately not published until Oboe has started and its first +audio callback has run. Successful connection is therefore the engine-ready +boundary; clients retry connection instead of sleeping a guessed startup time. + +## Godot API: keep the existing message builder + +Do not make Godot users hand-write wire strings for normal use. `godot/amy.gd` +already mirrors AMY's Python-style API: + +```gdscript +amy.send({ + "osc": 0, + "wave": Amy.SINE, + "freq": 440, + "vel": 1.0, +}) +``` + +and internally serializes the dictionary to the AMY wire protocol. The Android +backend in `godot/amy_android.gd` inherits that class and overrides only +lifecycle and `send_raw()`. + +The resulting platform split is: + +```text + high-level Amy GDScript API + | + Amy.send(...) / Amy.message(...) + | + +--------------------+--------------------+ + | | | + desktop web Android + | | | + GDExtension AMY WASM AMY AmyClient AAR + | + amy.sock + | + :amy / Oboe AMY +``` + +The Android hello-world uses this exact dictionary API; the test does not bypass +it with a special Godot-only native binding. + +## Why JavaClassWrapper instead of a Godot Android plugin + +Godot 4.4 introduced `JavaClassWrapper` and the built-in `AndroidRuntime` +singleton. With a Gradle Android export, Godot automatically packages `.aar` +files found below the project's `addons` directory. For this small API, a custom +Godot Android plugin would mostly duplicate a bridge Godot already provides. + +A Godot project therefore only needs the AAR plus the GDScript wrappers. The +hello-world's `prepare.sh` demonstrates the layout: + +```text +addons/amy_android/ + amy-service-debug.aar + amy.gd + amy_android.gd +``` + +Then `AmyAndroid` calls `AmyService` and `AmyClient` directly through +`JavaClassWrapper`. + +## Service configuration versus musical control + +Musical control (notes, patches, oscillator parameters, effects commands, +sequencer messages) uses the normal AMY wire protocol and belongs to clients. +Engine-start configuration belongs to the service because there is exactly one +AMY engine for the application. + +The first Android service profile is intentionally fixed at 48 kHz / 128 frames, +no audio input, no startup bleep and 16 reserved KS oscillators. Some exported +configuration properties inherited from the generic `Amy` GDScript class are +therefore not Android startup controls yet. If applications need configurable +engine creation, extend `AmyService` with an explicit service configuration +object rather than silently starting separate AMY instances per client. + +## Lifecycle and ownership + +The current service accepts one connected wire client. A typical Godot app +should: + +1. create one `AmyAndroid` node near application startup; +2. allow it to start `AmyService` and connect once; +3. keep that socket for all musical control; +4. close the client and stop the service when the owning node/application exits. + +If a future application needs multiple independent UI/control producers, add a +single client-side dispatcher or deliberately extend the server protocol rather +than opening competing socket clients accidentally. + +## Testing strategy + +There are three useful layers: + +1. `tests/run_amy_unix_socket_test.sh` validates the transport independently. +2. The ordinary Android hello-world uses the reusable `AmyClient` and validates + AAR -> socket -> AMY/Oboe, including captured digital audio level. +3. The Godot Android hello-world builds a real Godot Gradle APK and validates on + an Android emulator that GDScript -> `JavaClassWrapper` -> `AmyClient` -> + `:amy` -> Oboe reaches a complete C-major scale. + +This makes the Godot layer thin and testable while keeping the audio-critical +implementation identical for every Android framework. diff --git a/godot/amy_android.gd b/godot/amy_android.gd new file mode 100644 index 00000000..e6767cff --- /dev/null +++ b/godot/amy_android.gd @@ -0,0 +1,82 @@ +class_name AmyAndroid +extends Amy +## Android service backend for the high-level AMY GDScript API. +## +## The inherited Amy.send()/Amy.message() API still constructs ordinary AMY +## wire messages. Only send_raw() and lifecycle are replaced: messages are sent +## through the amy-service AAR to AMY running in the separate :amy process. + +const CONNECT_RETRIES: int = 100 +const CONNECT_RETRY_SECONDS: float = 0.05 + +var _android_runtime: Object = null +var _android_context: Object = null +var _android_service: Object = null +var _android_client: Object = null +var _android_last_error: int = 0 + +func _ready() -> void: + if OS.get_name() != "Android": + push_warning("AmyAndroid is only available in Android exports") + return + _init_android() + +func _init_android() -> void: + _android_runtime = Engine.get_singleton("AndroidRuntime") + if _android_runtime == null: + _android_last_error = -1 + push_error("AMY Android: AndroidRuntime singleton unavailable") + return + + _android_context = _android_runtime.getApplicationContext() + if _android_context == null: + _android_last_error = -1 + push_error("AMY Android: application Context unavailable") + return + + var service_class: Object = JavaClassWrapper.wrap("org.amy.audio.AmyService") + var client_class: Object = JavaClassWrapper.wrap("org.amy.audio.AmyClient") + _android_service = service_class + _android_client = client_class.AmyClient() + _android_service.start(_android_context) + + for _attempt in range(CONNECT_RETRIES): + var result: int = int(_android_client.connect(_android_context)) + if result == 0: + _android_last_error = 0 + _started = true + print("AMY Android service ready") + return + _android_last_error = result + await get_tree().create_timer(CONNECT_RETRY_SECONDS).timeout + + push_error("AMY Android: unable to connect to amy.sock, error %d" % _android_last_error) + +# The Android service/Oboe path owns audio. Never feed Godot's +# AudioStreamGenerator on this backend. +func _process(_delta: float) -> void: + pass + +func _exit_tree() -> void: + _started = false + if _android_client != null: + _android_client.close() + _android_client = null + if _android_service != null and _android_context != null: + _android_service.stop(_android_context) + +func is_running() -> bool: + return _started + +func last_error() -> int: + return _android_last_error + +## Send one ordinary AMY wire request as one SOCK_SEQPACKET packet. +func send_raw(msg: String) -> void: + if not _started or msg.is_empty() or _android_client == null: + return + var result: int = int(_android_client.sendWire(msg)) + if result < 0: + _android_last_error = result + _started = false + push_error("AMY Android send failed: %d" % result) diff --git a/godot/android-hello-world/.gitignore b/godot/android-hello-world/.gitignore new file mode 100644 index 00000000..f1ad8c89 --- /dev/null +++ b/godot/android-hello-world/.gitignore @@ -0,0 +1,6 @@ +.godot/ +android/ +build/ +addons/amy_android/*.aar +addons/amy_android/amy.gd +addons/amy_android/amy_android.gd diff --git a/godot/android-hello-world/README.md b/godot/android-hello-world/README.md new file mode 100644 index 00000000..e8584e3a --- /dev/null +++ b/godot/android-hello-world/README.md @@ -0,0 +1,86 @@ +# AMY Godot Android hello world + +This project proves the Android service architecture end to end from GDScript: + +```text +Godot GDScript + -> Amy.send(Dictionary) + -> Amy.message() wire serialization + -> AmyAndroid.send_raw() + -> JavaClassWrapper + -> org.amy.audio.AmyClient (AAR) + -> AF_UNIX / SOCK_SEQPACKET + -> separate :amy Android process + -> Oboe realtime callback + -> AMY + -> AAudio +``` + +AMY is deliberately **not** compiled into the Godot process. The example uses +the normal high-level Godot API (`send`, `message`, AMY parameter names) while +only replacing the Android transport/audio backend. + +See `../ANDROID.md` for the architecture rationale, realtime implications and +integration guidance. + +## Requirements + +The example is written for Godot 4.7.2 and requires a Gradle Android export. +Godot 4.4+ provides `JavaClassWrapper` and `AndroidRuntime`; 4.7.2 is the current +stable version used by CI for this example. + +The AMY AAR requires Android API 26 or later. CI builds both `arm64-v8a` and +`x86_64`; the latter is used by the Android emulator test. + +## Prepare the example + +From the repository root: + +```bash +bash godot/android-hello-world/prepare.sh +``` + +This builds `android/amy-service` and copies three generated/runtime inputs into +`godot/android-hello-world/addons/amy_android/`: + +- `amy-service-debug.aar` +- the existing high-level `godot/amy.gd` +- `godot/amy_android.gd`, the Android service backend subclass + +The generated copies and AAR are gitignored; `godot/amy.gd` remains the single +source for the dictionary-to-wire API. + +## Export from Godot + +1. Open `godot/android-hello-world/project.godot` in Godot 4.7.2. +2. Install the Android Gradle Build template (`Project -> Install Android Build Template`). +3. Ensure an Android SDK/JDK is configured. +4. Export the existing `Android` preset as a debug APK. + +The preset uses Gradle Build because Godot automatically includes `.aar` files +found under the project's `addons` directory only in Gradle exports. + +Equivalent CI-style command after export templates are installed: + +```bash +godot --headless \ + --path godot/android-hello-world \ + --install-android-build-template \ + --export-debug Android build/godot-amy-hello.apk +``` + +## What the example tests + +On launch the GDScript code: + +1. loads `AmyAndroid`, which inherits the existing `Amy` GDScript API; +2. starts `AmyService` through `JavaClassWrapper`; +3. creates `AmyClient` and retries the private socket until Oboe is actually running; +4. uses dictionaries to configure sine oscillator 0 at `volume=10`; +5. plays MIDI notes 60, 62, 64, 65, 67, 69, 71 and 72; +6. prints every serialized wire request and `Godot C scale complete`. + +The GitHub Actions emulator test verifies the service/Oboe startup, Godot client +readiness, the eight note-on messages and successful completion. This is not a +mock transport test: it exercises GDScript -> JavaClassWrapper -> AAR JNI client +-> private socket -> separate AMY/Oboe process on Android. diff --git a/godot/android-hello-world/export_presets.cfg b/godot/android-hello-world/export_presets.cfg new file mode 100644 index 00000000..8b3c1060 --- /dev/null +++ b/godot/android-hello-world/export_presets.cfg @@ -0,0 +1,46 @@ +[preset.0] + +name="Android" +platform="Android" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/godot-amy-hello.apk" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/gradle_build_directory="" +gradle_build/android_source_template="" +gradle_build/compress_native_libraries=false +gradle_build/export_format=0 +gradle_build/min_sdk="26" +gradle_build/target_sdk="36" +architectures/armeabi-v7a=false +architectures/arm64-v8a=true +architectures/x86=false +architectures/x86_64=true +version/code=1 +version/name="1.0" +package/unique_name="org.amy.godothello" +package/name="AMY Godot Android Hello World" +package/signed=true +package/app_category=2 +package/retain_data_on_uninstall=false +package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=true diff --git a/godot/android-hello-world/main.gd b/godot/android-hello-world/main.gd new file mode 100644 index 00000000..2884a3aa --- /dev/null +++ b/godot/android-hello-world/main.gd @@ -0,0 +1,96 @@ +extends Control + +var _amy: Node = null +var _status: Label = null +var _play_button: Button = null + +func _ready() -> void: + _build_ui() + _status.text = "Starting AMY Android service..." + + var android_backend: Script = load("res://addons/amy_android/amy_android.gd") + if android_backend == null: + _fail("AMY Android addon missing; run prepare.sh before export") + return + + _amy = android_backend.new() + add_child(_amy) + + for _attempt in range(120): + if _amy.call("is_running"): + _status.text = "AMY ready" + print("Godot AMY ready") + await _play_scale() + return + await get_tree().create_timer(0.05).timeout + + _fail("AMY service connection timeout: %s" % str(_amy.call("last_error"))) + +func _build_ui() -> void: + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(560, 0) + column.alignment = BoxContainer.ALIGNMENT_CENTER + center.add_child(column) + + var title := Label.new() + title.text = "AMY Godot Android Hello World" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 30) + column.add_child(title) + + _status = Label.new() + _status.text = "Initializing..." + _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status.add_theme_font_size_override("font_size", 20) + column.add_child(_status) + + _play_button = Button.new() + _play_button.text = "Play C scale" + _play_button.disabled = true + _play_button.pressed.connect(_on_play_pressed) + column.add_child(_play_button) + +func _on_play_pressed() -> void: + _play_button.disabled = true + await _play_scale() + +func _send_and_log(params: Dictionary) -> void: + var wire: String = str(_amy.call("message", params)) + print("Godot wire: %s" % wire) + _amy.call("send", params) + +func _play_scale() -> void: + if _amy == null or not _amy.call("is_running"): + _fail("AMY is not connected") + return + + _play_button.disabled = true + _status.text = "Playing C major scale..." + + # Use the same high-level dictionary API as desktop/web Godot. The inherited + # Amy.message() serializes these dictionaries; AmyAndroid only changes the + # transport backend to the private Android service. + _send_and_log({"osc": 0, "wave": 0, "volume": 10.0}) + await get_tree().create_timer(0.03).timeout + + for note in [60, 62, 64, 65, 67, 69, 71, 72]: + _send_and_log({"osc": 0, "note": note, "vel": 1.0}) + await get_tree().create_timer(0.35).timeout + _send_and_log({"osc": 0, "vel": 0.0}) + await get_tree().create_timer(0.08).timeout + + _status.text = "C scale complete" + _play_button.disabled = false + print("Godot C scale complete") + +func _fail(message: String) -> void: + push_error(message) + print("Godot AMY error: %s" % message) + if _status != null: + _status.text = message + if _play_button != null: + _play_button.disabled = true diff --git a/godot/android-hello-world/main.tscn b/godot/android-hello-world/main.tscn new file mode 100644 index 00000000..23751ec5 --- /dev/null +++ b/godot/android-hello-world/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource path="res://main.gd" type="Script" id="1_main"] + +[node name="AmyGodotAndroidHello" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_main") diff --git a/godot/android-hello-world/prepare.sh b/godot/android-hello-world/prepare.sh new file mode 100755 index 00000000..10d99e91 --- /dev/null +++ b/godot/android-hello-world/prepare.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${HERE}/../.." && pwd)" +GRADLE_BIN="${GRADLE_BIN:-gradle}" + +( + cd "${ROOT}/android" + "${GRADLE_BIN}" :amy-service:assembleDebug +) + +ADDON="${HERE}/addons/amy_android" +mkdir -p "${ADDON}" +cp "${ROOT}/android/amy-service/build/outputs/aar/amy-service-debug.aar" \ + "${ADDON}/amy-service-debug.aar" +cp "${ROOT}/godot/amy.gd" "${ADDON}/amy.gd" +cp "${ROOT}/godot/amy_android.gd" "${ADDON}/amy_android.gd" + +printf 'Prepared Godot Android addon in %s\n' "${ADDON}" diff --git a/godot/android-hello-world/project.godot b/godot/android-hello-world/project.godot new file mode 100644 index 00000000..d798fba9 --- /dev/null +++ b/godot/android-hello-world/project.godot @@ -0,0 +1,17 @@ +; Engine configuration file. +; AMY Godot Android service hello-world. +config_version=5 + +[application] +config/name="AMY Godot Android Hello World" +run/main_scene="res://main.tscn" + +[display] +window/size/viewport_width=720 +window/size/viewport_height=1280 +window/size/window_width_override=360 +window/size/window_height_override=640 + +[rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" From a9fdc559108e45013868602830213cf36b599c46 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:41:32 +0200 Subject: [PATCH 02/19] Enable Android texture compression for Godot export --- godot/android-hello-world/project.godot | 1 + 1 file changed, 1 insertion(+) diff --git a/godot/android-hello-world/project.godot b/godot/android-hello-world/project.godot index d798fba9..a2efd8aa 100644 --- a/godot/android-hello-world/project.godot +++ b/godot/android-hello-world/project.godot @@ -15,3 +15,4 @@ window/size/window_height_override=640 [rendering] renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" +textures/vram_compression/import_etc2_astc=true From 98f0fc885786e635bf596d125e8e8abf53334722 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:43:05 +0200 Subject: [PATCH 03/19] Print Android smoke-test logs before assertions --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8a758861..b99cdf0b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -86,6 +86,7 @@ jobs: adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + cat /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 @@ -102,6 +103,7 @@ jobs: adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + cat /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 From 65cc8b2dc5e4eb7f1f3e597252526742d64543ab Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:47:27 +0200 Subject: [PATCH 04/19] Keep Android hello-world client alive across Activity recreation --- .../main/java/org/amy/hello/MainActivity.java | 32 +++++++++++++------ 1 file changed, 22 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 aadef7c3..44988ec3 100644 --- a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -1,6 +1,7 @@ package org.amy.hello; import android.app.Activity; +import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.Gravity; @@ -21,8 +22,12 @@ public final class MainActivity extends Activity { private static final String TAG = "AmyHelloWorld"; private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - private final AmyClient amyClient = new AmyClient(); + // Keep the integration-test worker and socket client independent of one + // Activity instance. Android may recreate the Activity during a cold launch + // (for example after a configuration change); that must not interrupt a + // musical command sequence already in progress. + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private static final AmyClient AMY_CLIENT = new AmyClient(); private TextView status; private Button playButton; @@ -83,16 +88,18 @@ protected void onCreate(Bundle state) { } } - private int sendLogged(String wire) { - int result = amyClient.sendWire(wire); + private static int sendLogged(String wire) { + int result = AMY_CLIENT.sendWire(wire); if (result == 0) { Log.i(TAG, "wire: " + wire); } return result; } - private int playCScale() { - int result = amyClient.isConnected() ? 0 : amyClient.connectWithRetry(this, 5000); + private static int playCScale(Context appContext) { + int result = AMY_CLIENT.isConnected() + ? 0 + : AMY_CLIENT.connectWithRetry(appContext, 5000); if (result < 0) return result; // AMY's V control is a 0..10 bus/master volume scale; V10.0 gives full @@ -124,9 +131,10 @@ private int playCScale() { private void playScale() { playButton.setEnabled(false); status.setText("Playing C major scale..."); + Context appContext = getApplicationContext(); - executor.execute(() -> { - int rc = playCScale(); + EXECUTOR.execute(() -> { + int rc = playCScale(appContext); runOnUiThread(() -> { if (isDestroyed()) return; if (rc == 0) { @@ -142,8 +150,12 @@ private void playScale() { @Override protected void onDestroy() { - amyClient.close(); - executor.shutdownNow(); + // Do not close the process-level client during an Android Activity + // recreation. If this Activity is really finishing, release the socket; + // process death would close it automatically as well. + if (isFinishing() && !isChangingConfigurations()) { + AMY_CLIENT.close(); + } super.onDestroy(); } } From 115006be2b13f5f185339cd33e7131734a47ea51 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:50:48 +0200 Subject: [PATCH 05/19] Document reusable Android AmyClient in hello world --- android/hello-world/README.md | 75 ++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/android/hello-world/README.md b/android/hello-world/README.md index 491abfe2..82a5f792 100644 --- a/android/hello-world/README.md +++ b/android/hello-world/README.md @@ -1,19 +1,59 @@ # AMY Android Hello World -Minimal Android application proving the generic AMY Android service end to end. +Minimal Android application proving the generic AMY Android service and reusable +client API end to end. On launch it: 1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; -2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; -3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V10.0`; -4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; -5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; -6. shows `C scale complete` when all packets have been sent. +2. uses the AAR's reusable `org.amy.audio.AmyClient` to retry a connection to + the app-private `/amy.sock` `AF_UNIX` / `SOCK_SEQPACKET` socket + until the AMY/Oboe service publishes its ready socket; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to + `V10.0`; +4. waits 30 ms so that setup is committed on a fresh AMY instance before the + first note-on; +5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4 and C5; +6. shows and logs `C scale complete` when all packets have been sent. + +The application does not contain AMY and does not implement an app-specific JNI +socket bridge. `AmyClient` is part of the same generic AAR as `AmyService`. Its +small native library only owns the Unix `SOCK_SEQPACKET` descriptor; the AMY +engine and Oboe audio stream remain in the separate `:amy` service process. +Each `AmyClient.sendWire()` call is one ordinary AMY wire packet. + +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. + +## Reusable client API + +A normal Java/Kotlin/framework client can use the same API demonstrated here: + +```java +AmyService.start(context); + +AmyClient client = new AmyClient(); +int rc = client.connect(context); // one immediate attempt +// Or, from a worker thread: +rc = client.connectWithRetry(context, 5000); + +client.sendWire("v0w0V10.0Z"); +client.sendWire("v0n60l1Z"); +client.sendWire("v0l0Z"); + +client.close(); +AmyService.stop(context); +``` -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 connection is intended to remain open for musical control. After connect, +the native client descriptor is non-blocking; a saturated control path returns +a negative errno rather than stalling a framework/UI thread. -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. +`amy.sock` is not published until Oboe has started and its first callback has +run, so a successful `connect()` is the engine-readiness boundary. A client may +retry connection instead of sleeping a guessed service-start delay. ## Wire sequence @@ -23,7 +63,11 @@ Setup: v0w0V10.0Z ``` -`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. `V2.0`, used by an earlier version of this example, was only 20% linear master gain (about -14 dB relative to `V10.0`). +`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude +control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects +full master gain for this audible hello-world test. `V2.0`, used by an earlier +version of this example, was only 20% linear master gain (about -14 dB relative +to `V10.0`). Notes use MIDI note numbers and velocity, e.g. middle C: @@ -48,4 +92,15 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The Android audio-level regression also captures the raw AMY signed-16-bit render stream and the exact signed-16-bit callback buffer handed to Oboe, verifies that they are sample-for-sample identical, and checks their measured peak/RMS level. +The CI Android emulator smoke test builds the AAR/APK and performs two clean +install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an +output-route diagnostic, exactly one completed C scale, all eight note-on +packets and no socket failure. The Android audio-level regression also captures +the raw AMY signed-16-bit render stream and the exact signed-16-bit callback +buffer handed to Oboe, verifies that they are sample-for-sample identical, and +checks their measured peak/RMS level. + +The integration worker/client are deliberately not tied to a single Activity +instance: Android may recreate an Activity during startup or a configuration +change, and that must not interrupt a musical command sequence already in +progress. From 42d3841c3c85eb13a5901e74ad0fac7118784778 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 11:51:16 +0200 Subject: [PATCH 06/19] Document reusable client and framework-isolated Android design --- android/README.md | 181 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 53 deletions(-) diff --git a/android/README.md b/android/README.md index 49a30900..4adc840a 100644 --- a/android/README.md +++ b/android/README.md @@ -5,13 +5,20 @@ This directory builds a generic Android AAR that hosts AMY in an unexported AMY wire messages through the private pathname Unix transport implemented by `src/amy_unix_socket.[ch]`. +The AAR also contains a small reusable `org.amy.audio.AmyClient`. `AmyClient` +does **not** contain or instantiate AMY: its native library only owns a +persistent `AF_UNIX` / `SOCK_SEQPACKET` descriptor in the caller process. + ```text -Android client process +Android / framework client process + | + +-- AmyClient + | | + | | AF_UNIX / SOCK_SEQPACKET + | | /amy.sock + | | one AMY wire request per packet + | v | - | AF_UNIX / SOCK_SEQPACKET - | /amy.sock - | one AMY wire message per packet - v Android :amy service process | +-- amy_unix_socket receiver thread @@ -23,11 +30,10 @@ Android :amy service process AAudio ``` -The AAR is intended to be embedded by an Android application that wants to use -AMY as its local synth engine. The client can be written with the Android SDK, -Kotlin/Java, native code, Qt, another framework, or any other environment able -to start the service and use an Android Unix-domain `SOCK_SEQPACKET` socket. -AMY itself has no dependency on the client UI framework. +The client may be an Android SDK application, Kotlin/Java, native code, Godot, +Qt or another framework. AMY itself has no dependency on the client UI +framework. For the Android-specific Godot design and rationale, see +`../godot/ANDROID.md`. The service declaration uses `android:exported="false"` and `android:process=":amy"`. Consequently the service runs in a separate process @@ -39,6 +45,28 @@ 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. +## Why keep AMY in a separate process? + +The separate process is primarily an isolation boundary, not CPU reservation. +Linux/Android schedules threads, and `android:process=":amy"` does not reserve a +CPU core. The realtime-relevant scheduling comes from Oboe/AAudio's callback +thread. + +The separation is still useful for realtime audio applications: + +- UI/framework rendering, scripting and garbage collection do not run in the + AMY process; +- large framework allocations do not share the AMY process heap; +- the synth/audio lifecycle has one clear owner; +- the same AMY/Oboe implementation is reused by every Android UI framework; +- clients send compact control packets, not rendered PCM, across the process + boundary. + +This is particularly useful for engines such as Godot: Android musical control +can use Godot's normal high-level AMY message builder while AMY renders directly +on the Oboe callback rather than feeding a Godot `AudioStreamGenerator` queue. +See `../godot/ANDROID.md` for the complete comparison. + ## Audio profile The Android native build uses AMY's existing 48 kHz / 128-frame build profile @@ -46,45 +74,85 @@ and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. Oboe requests: -- stereo signed 16-bit output -- 48 kHz -- `PerformanceMode::LowLatency` -- `SharingMode::Exclusive` -- callback-driven output +- stereo signed 16-bit output; +- 48 kHz; +- `PerformanceMode::LowLatency`; +- `SharingMode::Exclusive` (a request; Android may negotiate shared mode); +- callback-driven output. The callback size is not assumed to equal 128 frames. The native adapter keeps only the unconsumed tail of the current AMY block and calls `amy_simple_fill_buffer()` exactly when another AMY block is required. It does not add an extra 128-frame output ring. +AMY's 128-frame block is its synthesis/command quantum (about 2.67 ms at +48 kHz), not Android's hardware DMA size. Oboe reports the actual device burst +and buffer capacity; Android/AAudio/HAL owns the hardware-facing buffering. + 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. +packets and passes them to `amy_add_message()`. The socket receiver thread itself +never calls AMY and never participates in audio rendering. AMY is started with its internal platform audio disabled and with AMY rendering owned by the Oboe callback thread. The current Android build configuration reserves 16 Karplus-Strong oscillators. -## JNI boundary +## JNI boundaries + +There are two intentionally small native boundaries: -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`. +1. `AmyService` uses JNI for AMY/Oboe lifecycle and diagnostics inside the + separate service process. +2. `AmyClient` uses JNI only for Android Unix `SOCK_SEQPACKET` operations in the + caller process, because Android's Java `LocalSocket` API is stream-oriented. + +Musical commands are **not** JNI calls into AMY. A `sendWire()` JNI call only +writes one opaque AMY wire packet to `amy.sock`; parsing, scheduling and +rendering all remain in the service process. The client-facing architecture is therefore deliberately transport-oriented: ```text -client application -> amy.sock -> AMY/Oboe service +client framework -> AmyClient -> amy.sock -> AMY/Oboe service +``` + +## Reusable AmyClient API + +Java/Kotlin/framework code can use: + +```java +AmyService.start(context); + +AmyClient client = new AmyClient(); +int rc = client.connect(context); // one immediate attempt + +// Convenience form for a worker thread: +rc = client.connectWithRetry(context, 5000); + +client.sendWire("v0w0V10.0Z"); +client.sendWire("v0n60l1Z"); +client.sendWire("v0l0Z"); + +client.close(); +AmyService.stop(context); ``` -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. +`connectWithRetry()` sleeps between attempts and therefore belongs on a worker +thread. Frameworks with their own asynchronous scheduler (for example Godot) +can instead call immediate `connect()` repeatedly without blocking their UI +thread. + +After a successful connect the native descriptor is switched to non-blocking +mode. A full socket therefore produces a negative errno such as `-EAGAIN` +instead of blocking a UI/framework control thread. + +The connection is intended to remain open for the lifetime of musical control; +do not open/close a Unix socket for every note. ## 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: +The underlying transport is `AF_UNIX` + `SOCK_SEQPACKET`, one logical AMY +request per packet. Example payloads of three consecutive packets: ```text K28i2Z @@ -92,37 +160,36 @@ n60l1i2Z n60l0i2Z ``` -Do not add stream framing or depend on newline boundaries. Packet boundaries -are preserved by `SOCK_SEQPACKET`. +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 +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. +wire commands; the existing `amy_unix_socket_send()` path is available for +compact introspection/status replies when that functionality is integrated. -## Client integration +## Framework 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. +3. create one persistent `AmyClient` (or implement the equivalent native socket + contract if there is a specific reason not to use the provided client); +4. retry connection until `amy.sock` exists; +5. send one ordinary AMY wire request per packet; +6. optionally receive future response packets over the same bidirectional + socket; +7. reconnect cleanly across service/audio lifecycle events. + +The ordinary Android hello-world demonstrates the reusable Java client. The +Godot Android hello-world demonstrates the same AAR from GDScript through +`JavaClassWrapper`, without embedding AMY in the Godot process. ## Building the AAR @@ -143,7 +210,7 @@ cd android gradle :amy-service:assembleDebug ``` -The production Android service build targets `arm64-v8a`. Output is below: +The AAR contains native libraries for the configured ABIs and is written below: ```text android/amy-service/build/outputs/aar/ @@ -161,17 +228,25 @@ It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, oversized-packet rejection, cleanup, and protection against deleting an existing non-socket path. -`.github/workflows/android.yml` runs that regression plus a complete Android -AAR/NDK/Oboe build. The earlier `.github/workflows/android-unix-socket.yml` -continues to isolate the transport regression itself. +`.github/workflows/android.yml` builds the AAR and ordinary Android hello-world, +runs two clean emulator launches, verifies all C-scale wire packets, captures +AMY and Oboe PCM, checks their sample-for-sample identity and verifies healthy +digital output level without clipping. + +`.github/workflows/godot-android.yml` additionally builds a real Godot Android +Gradle APK and checks the complete path: + +```text +GDScript -> JavaClassWrapper -> AmyClient -> amy.sock -> :amy -> Oboe/AMY +``` ## Hardware-test items -The first device tests should measure: +Useful device measurements remain: 1. command-to-audio latency; -2. negotiated Oboe callback/device buffer sizes; +2. negotiated Oboe callback/device burst and 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. +5. whether rare heavy AMY commands at a block boundary need further separation + from the realtime callback. From e62a9cd088dae3d715399d72e027a71035f60f87 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 12:25:37 +0200 Subject: [PATCH 07/19] Fix Godot Android AAR packaging and ABI-specific exports --- .github/workflows/godot-android.yml | 78 ++++++++++++++++--- .../addons/amy_android/export_plugin.gd | 25 ++++++ .../addons/amy_android/plugin.cfg | 7 ++ godot/android-hello-world/export_presets.cfg | 51 +++++++++++- godot/android-hello-world/prepare.sh | 4 +- godot/android-hello-world/project.godot | 3 + 6 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 godot/android-hello-world/addons/amy_android/export_plugin.gd create mode 100644 godot/android-hello-world/addons/amy_android/plugin.cfg diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml index 34f39a4a..359b1f4d 100644 --- a/.github/workflows/godot-android.yml +++ b/.github/workflows/godot-android.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" @@ -43,6 +43,20 @@ jobs: - name: Build AMY AAR and prepare Godot addon run: bash godot/android-hello-world/prepare.sh + - name: Inspect prepared AAR + run: | + AAR=godot/android-hello-world/addons/amy_android/amy-service-debug.aar + test -s "$AAR" + unzip -l "$AAR" | tee /tmp/amy-aar-contents.txt + grep -q 'jni/arm64-v8a/libamy_android.so' /tmp/amy-aar-contents.txt + grep -q 'jni/arm64-v8a/libamy_android_client.so' /tmp/amy-aar-contents.txt + grep -q 'jni/x86_64/libamy_android.so' /tmp/amy-aar-contents.txt + grep -q 'jni/x86_64/libamy_android_client.so' /tmp/amy-aar-contents.txt + unzip -p "$AAR" classes.jar > /tmp/amy-classes.jar + unzip -l /tmp/amy-classes.jar | tee /tmp/amy-classes.txt + grep -q 'org/amy/audio/AmyService.class' /tmp/amy-classes.txt + grep -q 'org/amy/audio/AmyClient.class' /tmp/amy-classes.txt + - name: Install Godot 4.7.2 and export templates run: | curl -L --fail --retry 3 \ @@ -64,20 +78,63 @@ jobs: "$HOME/.local/share/godot/export_templates/4.7.2.stable/" godot --version - - name: Export Godot Android hello-world + - name: Import Godot project and install Android build template + run: | + godot --headless --path godot/android-hello-world --import --quit + godot --headless --path godot/android-hello-world --install-android-build-template + + - name: Export ARM64 phone APK run: | mkdir -p godot/android-hello-world/build godot --headless \ --path godot/android-hello-world \ - --install-android-build-template \ - --export-debug Android build/godot-amy-hello.apk - test -s godot/android-hello-world/build/godot-amy-hello.apk + --export-debug "Android ARM64" build/godot-amy-hello-arm64.apk + test -s godot/android-hello-world/build/godot-amy-hello-arm64.apk + + - name: Export x86_64 CI APK + run: | + godot --headless \ + --path godot/android-hello-world \ + --export-debug "Android CI x86_64" build/godot-amy-hello-x86_64.apk + test -s godot/android-hello-world/build/godot-amy-hello-x86_64.apk + + - name: Verify APK architecture and AMY packaging + run: | + ARM=godot/android-hello-world/build/godot-amy-hello-arm64.apk + X86=godot/android-hello-world/build/godot-amy-hello-x86_64.apk + + unzip -l "$ARM" | tee /tmp/amy-arm-apk.txt + grep -q 'lib/arm64-v8a/libgodot_android.so' /tmp/amy-arm-apk.txt + grep -q 'lib/arm64-v8a/libamy_android.so' /tmp/amy-arm-apk.txt + grep -q 'lib/arm64-v8a/libamy_android_client.so' /tmp/amy-arm-apk.txt + ! grep -q 'lib/x86_64/' /tmp/amy-arm-apk.txt + + unzip -p "$ARM" classes.dex | strings > /tmp/amy-arm-dex.txt + grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-arm-dex.txt + grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-arm-dex.txt + + unzip -l "$X86" | tee /tmp/amy-x86-apk.txt + grep -q 'lib/x86_64/libgodot_android.so' /tmp/amy-x86-apk.txt + grep -q 'lib/x86_64/libamy_android.so' /tmp/amy-x86-apk.txt + grep -q 'lib/x86_64/libamy_android_client.so' /tmp/amy-x86-apk.txt + ! grep -q 'lib/arm64-v8a/' /tmp/amy-x86-apk.txt + + unzip -p "$X86" classes.dex | strings > /tmp/amy-x86-dex.txt + grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-x86-dex.txt + grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-x86-dex.txt + + ARM_SIZE=$(stat -c%s "$ARM") + X86_SIZE=$(stat -c%s "$X86") + echo "ARM64 APK bytes: $ARM_SIZE" + echo "x86_64 APK bytes: $X86_SIZE" + test "$ARM_SIZE" -lt 110000000 + test "$X86_SIZE" -lt 120000000 - - name: Upload Godot Android hello-world APK + - name: Upload ARM64 phone-test APK uses: actions/upload-artifact@v4 with: - name: amy-godot-android-hello-world-apk - path: godot/android-hello-world/build/godot-amy-hello.apk + name: amy-godot-android-hello-world-arm64-apk + path: godot/android-hello-world/build/godot-amy-hello-arm64.apk if-no-files-found: error - name: Emulator Godot-to-AMY smoke test @@ -90,11 +147,12 @@ jobs: emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | adb uninstall org.amy.godothello >/dev/null 2>&1 || true - adb install godot/android-hello-world/build/godot-amy-hello.apk + adb install godot/android-hello-world/build/godot-amy-hello-x86_64.apk adb logcat -c adb shell monkey -p org.amy.godothello 1 >/dev/null - sleep 10 + sleep 15 adb logcat -d > /tmp/amy-godot.log + cat /tmp/amy-godot.log grep -q 'AMY/Oboe started' /tmp/amy-godot.log grep -q 'AMY Android service ready' /tmp/amy-godot.log diff --git a/godot/android-hello-world/addons/amy_android/export_plugin.gd b/godot/android-hello-world/addons/amy_android/export_plugin.gd new file mode 100644 index 00000000..3e59091e --- /dev/null +++ b/godot/android-hello-world/addons/amy_android/export_plugin.gd @@ -0,0 +1,25 @@ +@tool +extends EditorPlugin + +var _export_plugin: EditorExportPlugin + +func _enter_tree() -> void: + _export_plugin = AmyAndroidExportPlugin.new() + add_export_plugin(_export_plugin) + +func _exit_tree() -> void: + if _export_plugin != null: + remove_export_plugin(_export_plugin) + _export_plugin = null + +class AmyAndroidExportPlugin extends EditorExportPlugin: + func _supports_platform(platform: EditorExportPlatform) -> bool: + return platform is EditorExportPlatformAndroid + + func _get_android_libraries(_platform: EditorExportPlatform, debug: bool) -> PackedStringArray: + if debug: + return PackedStringArray(["amy_android/amy-service-debug.aar"]) + return PackedStringArray(["amy_android/amy-service-release.aar"]) + + func _get_name() -> String: + return "AMY Android service" diff --git a/godot/android-hello-world/addons/amy_android/plugin.cfg b/godot/android-hello-world/addons/amy_android/plugin.cfg new file mode 100644 index 00000000..a5fba58d --- /dev/null +++ b/godot/android-hello-world/addons/amy_android/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="AMY Android Service Export" +description="Packages the AMY Android service AAR for Gradle exports." +author="AMY" +version="1.0" +script="export_plugin.gd" diff --git a/godot/android-hello-world/export_presets.cfg b/godot/android-hello-world/export_presets.cfg index 8b3c1060..4a2dad02 100644 --- a/godot/android-hello-world/export_presets.cfg +++ b/godot/android-hello-world/export_presets.cfg @@ -1,6 +1,6 @@ [preset.0] -name="Android" +name="Android ARM64" platform="Android" runnable=true advanced_options=false @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/godot-amy-hello.apk" +export_path="build/godot-amy-hello-arm64.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -32,6 +32,53 @@ gradle_build/target_sdk="36" architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false +architectures/x86_64=false +version/code=1 +version/name="1.0" +package/unique_name="org.amy.godothello" +package/name="AMY Godot Android Hello World" +package/signed=true +package/app_category=2 +package/retain_data_on_uninstall=false +package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=true + +[preset.1] + +name="Android CI x86_64" +platform="Android" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/godot-amy-hello-x86_64.apk" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/gradle_build_directory="" +gradle_build/android_source_template="" +gradle_build/compress_native_libraries=false +gradle_build/export_format=0 +gradle_build/min_sdk="26" +gradle_build/target_sdk="36" +architectures/armeabi-v7a=false +architectures/arm64-v8a=false +architectures/x86=false architectures/x86_64=true version/code=1 version/name="1.0" diff --git a/godot/android-hello-world/prepare.sh b/godot/android-hello-world/prepare.sh index 10d99e91..d9c104aa 100755 --- a/godot/android-hello-world/prepare.sh +++ b/godot/android-hello-world/prepare.sh @@ -7,13 +7,15 @@ GRADLE_BIN="${GRADLE_BIN:-gradle}" ( cd "${ROOT}/android" - "${GRADLE_BIN}" :amy-service:assembleDebug + "${GRADLE_BIN}" :amy-service:assembleDebug :amy-service:assembleRelease ) ADDON="${HERE}/addons/amy_android" mkdir -p "${ADDON}" cp "${ROOT}/android/amy-service/build/outputs/aar/amy-service-debug.aar" \ "${ADDON}/amy-service-debug.aar" +cp "${ROOT}/android/amy-service/build/outputs/aar/amy-service-release.aar" \ + "${ADDON}/amy-service-release.aar" cp "${ROOT}/godot/amy.gd" "${ADDON}/amy.gd" cp "${ROOT}/godot/amy_android.gd" "${ADDON}/amy_android.gd" diff --git a/godot/android-hello-world/project.godot b/godot/android-hello-world/project.godot index a2efd8aa..a504cc2b 100644 --- a/godot/android-hello-world/project.godot +++ b/godot/android-hello-world/project.godot @@ -12,6 +12,9 @@ window/size/viewport_height=1280 window/size/window_width_override=360 window/size/window_height_override=640 +[editor_plugins] +enabled=PackedStringArray("res://addons/amy_android/plugin.cfg") + [rendering] renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" From c29d2b07cc911054feb5c93c148deffa00b6d8a6 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 12:30:17 +0200 Subject: [PATCH 08/19] Run Android template install together with Godot export --- .github/workflows/godot-android.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml index 359b1f4d..79db1796 100644 --- a/.github/workflows/godot-android.yml +++ b/.github/workflows/godot-android.yml @@ -78,16 +78,15 @@ jobs: "$HOME/.local/share/godot/export_templates/4.7.2.stable/" godot --version - - name: Import Godot project and install Android build template - run: | - godot --headless --path godot/android-hello-world --import --quit - godot --headless --path godot/android-hello-world --install-android-build-template + - name: Import Godot project + run: godot --headless --path godot/android-hello-world --import --quit - name: Export ARM64 phone APK run: | mkdir -p godot/android-hello-world/build godot --headless \ --path godot/android-hello-world \ + --install-android-build-template \ --export-debug "Android ARM64" build/godot-amy-hello-arm64.apk test -s godot/android-hello-world/build/godot-amy-hello-arm64.apk From 5a1146c2303197085eb0049888b4713c5e88bf4e Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 13:52:00 +0200 Subject: [PATCH 09/19] Preserve Godot Android smoke diagnostics --- .github/workflows/godot-android.yml | 53 +++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml index 79db1796..c4035d5d 100644 --- a/.github/workflows/godot-android.yml +++ b/.github/workflows/godot-android.yml @@ -145,20 +145,51 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | + set -u adb uninstall org.amy.godothello >/dev/null 2>&1 || true adb install godot/android-hello-world/build/godot-amy-hello-x86_64.apk adb logcat -c adb shell monkey -p org.amy.godothello 1 >/dev/null sleep 15 + + { + echo '===== PROCESS LIST =====' + adb shell ps -A | grep -E 'org\.amy\.godothello|amy' || true + echo + echo '===== PACKAGE =====' + adb shell dumpsys package org.amy.godothello || true + echo + echo '===== SERVICES =====' + adb shell dumpsys activity services org.amy.godothello || true + echo + echo '===== APP FILES =====' + adb shell run-as org.amy.godothello sh -c 'id; pwd; ls -la files; ls -la files/amy.sock 2>&1' || true + echo + echo '===== LOGCAT =====' + adb logcat -d + } | tee /tmp/amy-godot-diagnostics.txt + adb logcat -d > /tmp/amy-godot.log - cat /tmp/amy-godot.log - - grep -q 'AMY/Oboe started' /tmp/amy-godot.log - grep -q 'AMY Android service ready' /tmp/amy-godot.log - grep -q 'Godot AMY ready' /tmp/amy-godot.log - grep -q 'Godot wire: v0w0V10' /tmp/amy-godot.log - test "$(grep -Ec 'Godot wire: v0n(60|62|64|65|67|69|71|72)l1' /tmp/amy-godot.log)" -eq 8 - grep -q 'Godot wire: v0n60l1' /tmp/amy-godot.log - grep -q 'Godot wire: v0n72l1' /tmp/amy-godot.log - grep -q 'Godot C scale complete' /tmp/amy-godot.log - ! grep -q 'Godot AMY error:' /tmp/amy-godot.log + + - name: Upload Godot Android smoke diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: amy-godot-android-smoke-diagnostics + path: | + /tmp/amy-godot.log + /tmp/amy-godot-diagnostics.txt + if-no-files-found: warn + + - name: Assert Godot-to-AMY smoke result + run: | + cat /tmp/amy-godot.log + grep -q 'AMY/Oboe started' /tmp/amy-godot.log + grep -q 'AMY Android service ready' /tmp/amy-godot.log + grep -q 'Godot AMY ready' /tmp/amy-godot.log + grep -q 'Godot wire: v0w0V10' /tmp/amy-godot.log + test "$(grep -Ec 'Godot wire: v0n(60|62|64|65|67|69|71|72)l1' /tmp/amy-godot.log)" -eq 8 + grep -q 'Godot wire: v0n60l1' /tmp/amy-godot.log + grep -q 'Godot wire: v0n72l1' /tmp/amy-godot.log + grep -q 'Godot C scale complete' /tmp/amy-godot.log + ! grep -q 'Godot AMY error:' /tmp/amy-godot.log From 2ba6ca81adbed67209453e2f72b8837fd135b3a0 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:19:01 +0200 Subject: [PATCH 10/19] Fix Godot Android service bridge and diagnostics --- .github/workflows/godot-android.yml | 45 +++-- .../java/org/amy/audio/AmyAndroidBridge.java | 163 ++++++++++++++++++ .../main/java/org/amy/audio/AmyService.java | 15 +- .../main/java/org/amy/hello/MainActivity.java | 31 ++-- godot/amy_android.gd | 112 +++++++++--- godot/android-hello-world/main.gd | 16 +- 6 files changed, 319 insertions(+), 63 deletions(-) create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml index c4035d5d..d323b439 100644 --- a/.github/workflows/godot-android.yml +++ b/.github/workflows/godot-android.yml @@ -36,6 +36,15 @@ jobs: "ndk;27.0.12077973" \ "cmake;3.22.1" + - name: Enable KVM access when available + run: | + if [ -e /dev/kvm ]; then + sudo chmod 666 /dev/kvm + ls -l /dev/kvm + else + echo "No /dev/kvm on this runner; emulator will use software acceleration" + fi + - uses: gradle/actions/setup-gradle@v4 with: gradle-version: "8.13" @@ -56,6 +65,7 @@ jobs: unzip -l /tmp/amy-classes.jar | tee /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyService.class' /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyClient.class' /tmp/amy-classes.txt + grep -q 'org/amy/audio/AmyAndroidBridge.class' /tmp/amy-classes.txt - name: Install Godot 4.7.2 and export templates run: | @@ -111,6 +121,7 @@ jobs: unzip -p "$ARM" classes.dex | strings > /tmp/amy-arm-dex.txt grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-arm-dex.txt grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-arm-dex.txt + grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-arm-dex.txt unzip -l "$X86" | tee /tmp/amy-x86-apk.txt grep -q 'lib/x86_64/libgodot_android.so' /tmp/amy-x86-apk.txt @@ -121,6 +132,7 @@ jobs: unzip -p "$X86" classes.dex | strings > /tmp/amy-x86-dex.txt grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-x86-dex.txt grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-x86-dex.txt + grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-x86-dex.txt ARM_SIZE=$(stat -c%s "$ARM") X86_SIZE=$(stat -c%s "$X86") @@ -145,31 +157,23 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim script: | - set -u adb uninstall org.amy.godothello >/dev/null 2>&1 || true adb install godot/android-hello-world/build/godot-amy-hello-x86_64.apk adb logcat -c adb shell monkey -p org.amy.godothello 1 >/dev/null sleep 15 - - { - echo '===== PROCESS LIST =====' - adb shell ps -A | grep -E 'org\.amy\.godothello|amy' || true - echo - echo '===== PACKAGE =====' - adb shell dumpsys package org.amy.godothello || true - echo - echo '===== SERVICES =====' - adb shell dumpsys activity services org.amy.godothello || true - echo - echo '===== APP FILES =====' - adb shell run-as org.amy.godothello sh -c 'id; pwd; ls -la files; ls -la files/amy.sock 2>&1' || true - echo - echo '===== LOGCAT =====' - adb logcat -d - } | tee /tmp/amy-godot-diagnostics.txt - + echo '===== PROCESS LIST =====' > /tmp/amy-godot-diagnostics.txt + adb shell ps -A >> /tmp/amy-godot-diagnostics.txt 2>&1 || true + echo '===== PACKAGE =====' >> /tmp/amy-godot-diagnostics.txt + adb shell dumpsys package org.amy.godothello >> /tmp/amy-godot-diagnostics.txt 2>&1 || true + echo '===== SERVICES =====' >> /tmp/amy-godot-diagnostics.txt + adb shell dumpsys activity services org.amy.godothello >> /tmp/amy-godot-diagnostics.txt 2>&1 || true + echo '===== APP FILES =====' >> /tmp/amy-godot-diagnostics.txt + adb shell run-as org.amy.godothello sh -c 'id; pwd; ls -la files; ls -la files/amy.sock 2>&1' >> /tmp/amy-godot-diagnostics.txt 2>&1 || true adb logcat -d > /tmp/amy-godot.log + echo '===== LOGCAT =====' >> /tmp/amy-godot-diagnostics.txt + cat /tmp/amy-godot.log >> /tmp/amy-godot-diagnostics.txt + cat /tmp/amy-godot-diagnostics.txt - name: Upload Godot Android smoke diagnostics if: always() @@ -184,7 +188,10 @@ jobs: - name: Assert Godot-to-AMY smoke result run: | cat /tmp/amy-godot.log + grep -q 'AMY service start requested' /tmp/amy-godot.log + grep -q 'AMY native library loaded in service process' /tmp/amy-godot.log grep -q 'AMY/Oboe started' /tmp/amy-godot.log + grep -q 'AMY control socket connected' /tmp/amy-godot.log grep -q 'AMY Android service ready' /tmp/amy-godot.log grep -q 'Godot AMY ready' /tmp/amy-godot.log grep -q 'Godot wire: v0w0V10' /tmp/amy-godot.log diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java b/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java new file mode 100644 index 00000000..3071bb52 --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java @@ -0,0 +1,163 @@ +package org.amy.audio; + +import android.content.Context; +import android.util.Log; + +/** + * Framework-neutral, reflection-friendly facade for the AMY Android service. + * + *

The full {@link AmyClient} instance API remains available to ordinary + * Android applications. This facade is intentionally limited to primitive + * return values, strings and {@link Context}, which makes it convenient for + * frameworks such as Godot that call Java through a reflection/JNI bridge.

+ * + *

The facade contains no synthesizer and no PCM path. AMY runs exclusively + * in {@link AmyService}'s separate {@code :amy} process; this class owns only + * one persistent control-socket client in the caller process.

+ */ +public final class AmyAndroidBridge { + private static final String TAG = "AmyAndroidBridge"; + private static final int EINVAL = 22; + private static final int EJAVA = 1000; + private static final int ELINKAGE = 1001; + + private static AmyClient client; + private static String lastErrorText = ""; + + private AmyAndroidBridge() {} + + private static Context applicationContext(Context context) { + if (context == null) return null; + Context app = context.getApplicationContext(); + return app != null ? app : context; + } + + private static int fail(String operation, Throwable error, int code) { + lastErrorText = operation + ": " + error.getClass().getName() + + (error.getMessage() == null ? "" : ": " + error.getMessage()); + Log.e(TAG, lastErrorText, error); + return -code; + } + + /** Request startup of the private {@code :amy} service process. */ + public static synchronized int start(Context context) { + Context app = applicationContext(context); + if (app == null) { + lastErrorText = "start: null Context"; + return -EINVAL; + } + try { + AmyService.start(app); + lastErrorText = ""; + Log.i(TAG, "AMY service start requested"); + return 0; + } catch (LinkageError error) { + return fail("start", error, ELINKAGE); + } catch (RuntimeException error) { + return fail("start", error, EJAVA); + } + } + + /** Attempt one immediate connection to filesDir/amy.sock. */ + public static synchronized int connect(Context context) { + Context app = applicationContext(context); + if (app == null) { + lastErrorText = "connect: null Context"; + return -EINVAL; + } + try { + if (client == null) client = new AmyClient(); + int result = client.connect(app); + if (result == 0) { + lastErrorText = ""; + Log.i(TAG, "AMY control socket connected"); + } else { + lastErrorText = "connect returned " + result; + } + return result; + } catch (LinkageError error) { + return fail("connect", error, ELINKAGE); + } catch (RuntimeException error) { + return fail("connect", error, EJAVA); + } + } + + /** Convenience retrying connect for non-UI worker threads. */ + public static synchronized int connectWithRetry(Context context, int timeoutMs) { + Context app = applicationContext(context); + if (app == null) { + lastErrorText = "connectWithRetry: null Context"; + return -EINVAL; + } + try { + if (client == null) client = new AmyClient(); + int result = client.connectWithRetry(app, timeoutMs); + if (result == 0) { + lastErrorText = ""; + Log.i(TAG, "AMY control socket connected"); + } else { + lastErrorText = "connectWithRetry returned " + result; + } + return result; + } catch (LinkageError error) { + return fail("connectWithRetry", error, ELINKAGE); + } catch (RuntimeException error) { + return fail("connectWithRetry", error, EJAVA); + } + } + + /** Send one AMY wire request as one SOCK_SEQPACKET packet. */ + public static synchronized int sendWire(String wire) { + if (client == null) { + lastErrorText = "sendWire: client not connected"; + return -107; + } + try { + int result = client.sendWire(wire); + if (result == 0) { + lastErrorText = ""; + } else { + lastErrorText = "sendWire returned " + result; + } + return result; + } catch (LinkageError error) { + return fail("sendWire", error, ELINKAGE); + } catch (RuntimeException error) { + return fail("sendWire", error, EJAVA); + } + } + + public static synchronized boolean isConnected() { + return client != null && client.isConnected(); + } + + /** Close only the caller-process control socket. */ + public static synchronized void close() { + if (client != null) { + client.close(); + client = null; + } + } + + /** Close the client and request shutdown of the private AMY service. */ + public static synchronized int stop(Context context) { + close(); + Context app = applicationContext(context); + if (app == null) { + lastErrorText = "stop: null Context"; + return -EINVAL; + } + try { + AmyService.stop(app); + lastErrorText = ""; + return 0; + } catch (RuntimeException error) { + return fail("stop", error, EJAVA); + } + } + + /** Human-readable detail for the last bridge failure, if any. */ + public static synchronized String getLastErrorText() { + return lastErrorText; + } +} 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 98ff90e7..df9f51a6 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 @@ -24,10 +24,6 @@ public final class AmyService extends Service { public static final String EXTRA_SOCKET_PATH = "org.amy.audio.extra.SOCKET_PATH"; public static final String DEFAULT_SOCKET_NAME = "amy.sock"; - static { - System.loadLibrary("amy_android"); - } - private boolean running; private String runningSocketPath; @@ -35,6 +31,17 @@ public final class AmyService extends Service { private static native int nativeGetOutputDeviceId(); private static native void nativeStop(); + @Override + public void onCreate() { + super.onCreate(); + // Load the full AMY/Oboe library only after Android has instantiated + // this Service in the manifest-declared :amy process. Merely calling + // AmyService.start() from a framework/UI process must not load AMY + // into that process as a side effect of Java class initialization. + System.loadLibrary("amy_android"); + Log.i(TAG, "AMY native library loaded in service process"); + } + /** Start the private AMY process using filesDir/amy.sock. */ public static void start(Context context) { File socket = new File(context.getFilesDir(), DEFAULT_SOCKET_NAME); 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 44988ec3..a48ec872 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 @@ -10,8 +10,7 @@ import android.widget.LinearLayout; import android.widget.TextView; -import org.amy.audio.AmyClient; -import org.amy.audio.AmyService; +import org.amy.audio.AmyAndroidBridge; import java.io.File; import java.io.IOException; @@ -22,12 +21,11 @@ public final class MainActivity extends Activity { private static final String TAG = "AmyHelloWorld"; private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; - // Keep the integration-test worker and socket client independent of one - // Activity instance. Android may recreate the Activity during a cold launch - // (for example after a configuration change); that must not interrupt a + // Keep the integration-test worker independent of one Activity instance. + // AmyAndroidBridge owns the process-level persistent socket client. Android + // may recreate the Activity during a cold launch; that must not interrupt a // musical command sequence already in progress. private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); - private static final AmyClient AMY_CLIENT = new AmyClient(); private TextView status; private Button playButton; @@ -80,8 +78,12 @@ protected void onCreate(Bundle state) { Log.e(TAG, "Unable to arm AMY audio capture", ex); } - AmyService.start(this); - if (state == null) { + int startResult = AmyAndroidBridge.start(this); + if (startResult < 0) { + Log.e(TAG, "AMY bridge start failed: " + startResult + " " + + AmyAndroidBridge.getLastErrorText()); + status.setText("AMY start error: " + startResult); + } else if (state == null) { playScale(); } else { status.setText("AMY ready"); @@ -89,7 +91,7 @@ protected void onCreate(Bundle state) { } private static int sendLogged(String wire) { - int result = AMY_CLIENT.sendWire(wire); + int result = AmyAndroidBridge.sendWire(wire); if (result == 0) { Log.i(TAG, "wire: " + wire); } @@ -97,13 +99,13 @@ private static int sendLogged(String wire) { } private static int playCScale(Context appContext) { - int result = AMY_CLIENT.isConnected() + int result = AmyAndroidBridge.isConnected() ? 0 - : AMY_CLIENT.connectWithRetry(appContext, 5000); + : AmyAndroidBridge.connectWithRetry(appContext, 5000); if (result < 0) return result; // AMY's V control is a 0..10 bus/master volume scale; V10.0 gives full - // master gain. AmyClient preserves one wire request per socket packet. + // master gain. The bridge preserves one wire request per socket packet. result = sendLogged("v0w0V10.0Z"); if (result < 0) return result; @@ -140,7 +142,8 @@ private void playScale() { if (rc == 0) { status.setText("C scale complete"); } else { - Log.e(TAG, "C scale failed: " + rc); + Log.e(TAG, "C scale failed: " + rc + " " + + AmyAndroidBridge.getLastErrorText()); status.setText("AMY/socket error: " + rc); } playButton.setEnabled(true); @@ -154,7 +157,7 @@ protected void onDestroy() { // recreation. If this Activity is really finishing, release the socket; // process death would close it automatically as well. if (isFinishing() && !isChangingConfigurations()) { - AMY_CLIENT.close(); + AmyAndroidBridge.close(); } super.onDestroy(); } diff --git a/godot/amy_android.gd b/godot/amy_android.gd index e6767cff..8cf8376c 100644 --- a/godot/amy_android.gd +++ b/godot/amy_android.gd @@ -5,52 +5,113 @@ extends Amy ## The inherited Amy.send()/Amy.message() API still constructs ordinary AMY ## wire messages. Only send_raw() and lifecycle are replaced: messages are sent ## through the amy-service AAR to AMY running in the separate :amy process. +## +## Godot talks to the framework-neutral AmyAndroidBridge using only a Context, +## Strings and primitive return values. No AMY engine or rendered PCM exists in +## the Godot process. -const CONNECT_RETRIES: int = 100 +const CONNECT_RETRIES: int = 120 const CONNECT_RETRY_SECONDS: float = 0.05 +const ERR_JAVA_EXCEPTION: int = -1002 var _android_runtime: Object = null var _android_context: Object = null -var _android_service: Object = null -var _android_client: Object = null +var _android_bridge: Object = null var _android_last_error: int = 0 +var _android_status: String = "Not started" func _ready() -> void: if OS.get_name() != "Android": - push_warning("AmyAndroid is only available in Android exports") + _set_status("AmyAndroid is only available in Android exports") + push_warning(_android_status) return - _init_android() + # Do not perform JNI/reflection work synchronously inside add_child(). This + # lets the host scene render and exposes the exact startup stage if Android + # or a third-party framework bridge ever stalls. + call_deferred("_init_android") + +func _set_status(text: String) -> void: + _android_status = text + print("Godot AMY stage: %s" % text) + +func _java_exception(stage: String) -> bool: + var exception: Object = JavaClassWrapper.get_exception() + if exception == null: + return false + _android_last_error = ERR_JAVA_EXCEPTION + _set_status("%s: Java exception" % stage) + push_error("AMY Android %s raised Java exception: %s" % [stage, str(exception)]) + return true + +func _bridge_error_text() -> String: + if _android_bridge == null: + return "" + var value: Variant = _android_bridge.getLastErrorText() + if _java_exception("getLastErrorText"): + return "Java exception while reading bridge error" + return str(value) func _init_android() -> void: + _set_status("Finding AndroidRuntime...") + await get_tree().process_frame _android_runtime = Engine.get_singleton("AndroidRuntime") if _android_runtime == null: _android_last_error = -1 - push_error("AMY Android: AndroidRuntime singleton unavailable") + _set_status("AndroidRuntime singleton unavailable") + push_error("AMY Android: %s" % _android_status) return + _set_status("Getting Android application Context...") + await get_tree().process_frame _android_context = _android_runtime.getApplicationContext() if _android_context == null: _android_last_error = -1 - push_error("AMY Android: application Context unavailable") + _set_status("Android application Context unavailable") + push_error("AMY Android: %s" % _android_status) + return + + _set_status("Loading AmyAndroidBridge class...") + await get_tree().process_frame + _android_bridge = JavaClassWrapper.wrap("org.amy.audio.AmyAndroidBridge") + if _java_exception("wrap AmyAndroidBridge"): + return + if _android_bridge == null or not _android_bridge.has_java_method("start"): + _android_last_error = -1 + _set_status("AmyAndroidBridge class/method unavailable") + push_error("AMY Android: %s" % _android_status) return - var service_class: Object = JavaClassWrapper.wrap("org.amy.audio.AmyService") - var client_class: Object = JavaClassWrapper.wrap("org.amy.audio.AmyClient") - _android_service = service_class - _android_client = client_class.AmyClient() - _android_service.start(_android_context) + _set_status("Starting AMY Android service...") + await get_tree().process_frame + var start_value: Variant = _android_bridge.start(_android_context) + if _java_exception("AmyAndroidBridge.start"): + return + var start_result: int = int(start_value) + if start_result != 0: + _android_last_error = start_result + _set_status("AMY service start failed %d: %s" % [start_result, _bridge_error_text()]) + push_error("AMY Android: %s" % _android_status) + return - for _attempt in range(CONNECT_RETRIES): - var result: int = int(_android_client.connect(_android_context)) + _set_status("AMY service requested; waiting for audio/socket...") + for attempt in range(CONNECT_RETRIES): + var result_value: Variant = _android_bridge.connect(_android_context) + if _java_exception("AmyAndroidBridge.connect"): + return + var result: int = int(result_value) if result == 0: _android_last_error = 0 _started = true + _set_status("AMY ready") print("AMY Android service ready") return _android_last_error = result + if attempt == 0 or (attempt + 1) % 20 == 0: + _set_status("Waiting for amy.sock (attempt %d/%d, rc=%d)" % [attempt + 1, CONNECT_RETRIES, result]) await get_tree().create_timer(CONNECT_RETRY_SECONDS).timeout - push_error("AMY Android: unable to connect to amy.sock, error %d" % _android_last_error) + _set_status("AMY socket timeout rc=%d: %s" % [_android_last_error, _bridge_error_text()]) + push_error("AMY Android: %s" % _android_status) # The Android service/Oboe path owns audio. Never feed Godot's # AudioStreamGenerator on this backend. @@ -59,11 +120,10 @@ func _process(_delta: float) -> void: func _exit_tree() -> void: _started = false - if _android_client != null: - _android_client.close() - _android_client = null - if _android_service != null and _android_context != null: - _android_service.stop(_android_context) + if _android_bridge != null: + _android_bridge.stop(_android_context) + _java_exception("AmyAndroidBridge.stop") + _android_bridge = null func is_running() -> bool: return _started @@ -71,12 +131,20 @@ func is_running() -> bool: func last_error() -> int: return _android_last_error +func status_text() -> String: + return _android_status + ## Send one ordinary AMY wire request as one SOCK_SEQPACKET packet. func send_raw(msg: String) -> void: - if not _started or msg.is_empty() or _android_client == null: + if not _started or msg.is_empty() or _android_bridge == null: + return + var result_value: Variant = _android_bridge.sendWire(msg) + if _java_exception("AmyAndroidBridge.sendWire"): + _started = false return - var result: int = int(_android_client.sendWire(msg)) + var result: int = int(result_value) if result < 0: _android_last_error = result _started = false + _set_status("AMY send failed %d: %s" % [result, _bridge_error_text()]) push_error("AMY Android send failed: %d" % result) diff --git a/godot/android-hello-world/main.gd b/godot/android-hello-world/main.gd index 2884a3aa..a009a444 100644 --- a/godot/android-hello-world/main.gd +++ b/godot/android-hello-world/main.gd @@ -6,7 +6,7 @@ var _play_button: Button = null func _ready() -> void: _build_ui() - _status.text = "Starting AMY Android service..." + _status.text = "Loading AMY Android backend..." var android_backend: Script = load("res://addons/amy_android/amy_android.gd") if android_backend == null: @@ -16,15 +16,22 @@ func _ready() -> void: _amy = android_backend.new() add_child(_amy) - for _attempt in range(120): + var previous_status: String = "" + for _attempt in range(240): if _amy.call("is_running"): _status.text = "AMY ready" print("Godot AMY ready") await _play_scale() return + + var backend_status: String = str(_amy.call("status_text")) + if not backend_status.is_empty() and backend_status != previous_status: + previous_status = backend_status + _status.text = backend_status await get_tree().create_timer(0.05).timeout - _fail("AMY service connection timeout: %s" % str(_amy.call("last_error"))) + _fail("AMY startup timeout: %s (rc=%s)" % [ + str(_amy.call("status_text")), str(_amy.call("last_error"))]) func _build_ui() -> void: var center := CenterContainer.new() @@ -32,7 +39,7 @@ func _build_ui() -> void: add_child(center) var column := VBoxContainer.new() - column.custom_minimum_size = Vector2(560, 0) + column.custom_minimum_size = Vector2(760, 0) column.alignment = BoxContainer.ALIGNMENT_CENTER center.add_child(column) @@ -45,6 +52,7 @@ func _build_ui() -> void: _status = Label.new() _status.text = "Initializing..." _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status.add_theme_font_size_override("font_size", 20) column.add_child(_status) From 97fee7a487bfe1be9253b1a1457c81ced8545e93 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:29:09 +0200 Subject: [PATCH 11/19] Auto-start AMY service with application --- .../org/amy/audio/AmyAutoStartProvider.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java b/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java new file mode 100644 index 00000000..da68dc9b --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java @@ -0,0 +1,42 @@ +package org.amy.audio; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.util.Log; + +import java.io.File; + +/** Starts the private :amy service when the application process starts. */ +public final class AmyAutoStartProvider extends ContentProvider { + private static final String TAG = "AmyAutoStart"; + + @Override + public boolean onCreate() { + Context context = getContext(); + if (context == null) return false; + + File socket = new File(context.getFilesDir(), AmyService.DEFAULT_SOCKET_NAME); + Intent intent = new Intent(context, AmyService.class); + intent.putExtra(AmyService.EXTRA_SOCKET_PATH, socket.getAbsolutePath()); + try { + context.startService(intent); + Log.i(TAG, "AMY service auto-start requested"); + return true; + } catch (RuntimeException error) { + Log.e(TAG, "Unable to auto-start AMY service", error); + return false; + } + } + + @Override public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { return null; } + @Override public String getType(Uri uri) { return null; } + @Override public Uri insert(Uri uri, ContentValues values) { return null; } + @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; } + @Override public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { return 0; } +} From 9ef1139ea923050399ba3059ca5e0701a0162c38 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:29:22 +0200 Subject: [PATCH 12/19] Auto-start AMY service with application --- android/amy-service/src/main/AndroidManifest.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/amy-service/src/main/AndroidManifest.xml b/android/amy-service/src/main/AndroidManifest.xml index cde4251b..682656e0 100644 --- a/android/amy-service/src/main/AndroidManifest.xml +++ b/android/amy-service/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + Date: Sun, 23 Aug 2026 14:29:44 +0200 Subject: [PATCH 13/19] Keep AMY lifecycle inside service process --- .../main/java/org/amy/audio/AmyService.java | 62 +++++-------------- 1 file changed, 14 insertions(+), 48 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 df9f51a6..366c7bb7 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 @@ -11,13 +11,7 @@ import java.io.File; import java.io.IOException; -/** - * Unexported same-UID service hosting native AMY + Oboe in a separate process. - * - * Musical control never crosses JNI. The host opens the private pathname Unix - * SOCK_SEQPACKET socket and sends one AMY wire message per packet. JNI is only - * used to start/stop the native engine and report its actual Oboe output device. - */ +/** Unexported same-UID service hosting native AMY + Oboe in a separate process. */ public final class AmyService extends Service { private static final String TAG = "AmyService"; @@ -34,27 +28,13 @@ public final class AmyService extends Service { @Override public void onCreate() { super.onCreate(); - // Load the full AMY/Oboe library only after Android has instantiated - // this Service in the manifest-declared :amy process. Merely calling - // AmyService.start() from a framework/UI process must not load AMY - // into that process as a side effect of Java class initialization. + // This class is instantiated by Android in the manifest-declared :amy + // process. The full synth/audio library therefore never enters the UI + // or Godot process. System.loadLibrary("amy_android"); Log.i(TAG, "AMY native library loaded in service process"); } - /** 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) { @@ -76,11 +56,7 @@ 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; } @@ -126,26 +102,16 @@ private void logOutputRoute(int deviceId) { 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; + 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; } } From adb912ba24ed9d4b1d70779b24ba05139e6d942c Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:30:03 +0200 Subject: [PATCH 14/19] Reduce Android client to socket transport only --- .../main/java/org/amy/audio/AmyClient.java | 69 ++++--------------- 1 file changed, 12 insertions(+), 57 deletions(-) diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyClient.java b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java index 01c163c4..8f9d30aa 100644 --- a/android/amy-service/src/main/java/org/amy/audio/AmyClient.java +++ b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java @@ -4,45 +4,26 @@ import java.io.File; -/** - * Small persistent client for the private AMY Android wire socket. - * - *

This class contains no synthesizer and no audio path. The AMY engine stays - * in {@link AmyService}'s separate {@code :amy} process; AmyClient only owns a - * native AF_UNIX/SOCK_SEQPACKET descriptor in the calling process. Each - * {@link #sendWire(String)} call is one packet and therefore one AMY wire - * request.

- * - *

{@link #connect(Context)} performs one immediate connection attempt. - * {@link #connectWithRetry(Context, int)} is a convenience for a worker thread; - * do not use the retrying form on an Android or game-engine UI thread.

- */ -public final class AmyClient implements AutoCloseable { - private static final int CONNECT_RETRY_MS = 50; +/** Tiny process-local client for the private AMY SOCK_SEQPACKET control socket. */ +public final class AmyClient { private static final int EINVAL = 22; - private static final int EINTR = 4; private static final int ENOTCONN = 107; - - private int nativeFd = -1; + private static int nativeFd = -1; static { System.loadLibrary("amy_android_client"); } - public AmyClient() {} + private AmyClient() {} - /** Return the service socket pathname for this application. */ - public static String socketPath(Context context) { + private static String socketPath(Context context) { if (context == null) return ""; return new File(context.getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) .getAbsolutePath(); } - /** - * Attempt one connection. Returns 0 on success or a negative errno value. - * The service publishes amy.sock only after its Oboe callback is running. - */ - public synchronized int connect(Context context) { + /** Attempt one connection to filesDir/amy.sock. Returns 0 or negative errno. */ + public static synchronized int connect(Context context) { if (context == null) return -EINVAL; closeLocked(); int fd = nativeConnect(socketPath(context)); @@ -51,33 +32,8 @@ public synchronized int connect(Context context) { return 0; } - /** - * Retry connect() every 50 ms until success or timeout. Intended for a - * worker thread. timeoutMs <= 0 means one attempt only. - */ - public int connectWithRetry(Context context, int timeoutMs) { - final long deadline = System.nanoTime() - + Math.max(timeoutMs, 0) * 1_000_000L; - int result; - do { - result = connect(context); - if (result == 0 || timeoutMs <= 0) return result; - if (System.nanoTime() >= deadline) return result; - try { - Thread.sleep(CONNECT_RETRY_MS); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return -EINTR; - } - } while (true); - } - - /** - * Send exactly one AMY wire request packet. Returns 0 or negative errno. - * The native socket is non-blocking; a caller may see -EAGAIN under - * sustained backpressure instead of stalling its UI/control thread. - */ - public synchronized int sendWire(String wire) { + /** Send one AMY wire request as one packet. Returns 0 or negative errno. */ + public static synchronized int sendWire(String wire) { if (nativeFd < 0) return -ENOTCONN; if (wire == null || wire.isEmpty()) return -EINVAL; int result = nativeSend(nativeFd, wire); @@ -85,16 +41,15 @@ public synchronized int sendWire(String wire) { return result; } - public synchronized boolean isConnected() { + public static synchronized boolean isConnected() { return nativeFd >= 0; } - @Override - public synchronized void close() { + public static synchronized void close() { closeLocked(); } - private void closeLocked() { + private static void closeLocked() { if (nativeFd >= 0) { nativeClose(nativeFd); nativeFd = -1; From bd61d8dd518fdf2c4c74a35a722932f37a32aeb2 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:30:14 +0200 Subject: [PATCH 15/19] Remove redundant Godot Android bridge --- .../java/org/amy/audio/AmyAndroidBridge.java | 163 ------------------ 1 file changed, 163 deletions(-) delete mode 100644 android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java b/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java deleted file mode 100644 index 3071bb52..00000000 --- a/android/amy-service/src/main/java/org/amy/audio/AmyAndroidBridge.java +++ /dev/null @@ -1,163 +0,0 @@ -package org.amy.audio; - -import android.content.Context; -import android.util.Log; - -/** - * Framework-neutral, reflection-friendly facade for the AMY Android service. - * - *

The full {@link AmyClient} instance API remains available to ordinary - * Android applications. This facade is intentionally limited to primitive - * return values, strings and {@link Context}, which makes it convenient for - * frameworks such as Godot that call Java through a reflection/JNI bridge.

- * - *

The facade contains no synthesizer and no PCM path. AMY runs exclusively - * in {@link AmyService}'s separate {@code :amy} process; this class owns only - * one persistent control-socket client in the caller process.

- */ -public final class AmyAndroidBridge { - private static final String TAG = "AmyAndroidBridge"; - private static final int EINVAL = 22; - private static final int EJAVA = 1000; - private static final int ELINKAGE = 1001; - - private static AmyClient client; - private static String lastErrorText = ""; - - private AmyAndroidBridge() {} - - private static Context applicationContext(Context context) { - if (context == null) return null; - Context app = context.getApplicationContext(); - return app != null ? app : context; - } - - private static int fail(String operation, Throwable error, int code) { - lastErrorText = operation + ": " + error.getClass().getName() - + (error.getMessage() == null ? "" : ": " + error.getMessage()); - Log.e(TAG, lastErrorText, error); - return -code; - } - - /** Request startup of the private {@code :amy} service process. */ - public static synchronized int start(Context context) { - Context app = applicationContext(context); - if (app == null) { - lastErrorText = "start: null Context"; - return -EINVAL; - } - try { - AmyService.start(app); - lastErrorText = ""; - Log.i(TAG, "AMY service start requested"); - return 0; - } catch (LinkageError error) { - return fail("start", error, ELINKAGE); - } catch (RuntimeException error) { - return fail("start", error, EJAVA); - } - } - - /** Attempt one immediate connection to filesDir/amy.sock. */ - public static synchronized int connect(Context context) { - Context app = applicationContext(context); - if (app == null) { - lastErrorText = "connect: null Context"; - return -EINVAL; - } - try { - if (client == null) client = new AmyClient(); - int result = client.connect(app); - if (result == 0) { - lastErrorText = ""; - Log.i(TAG, "AMY control socket connected"); - } else { - lastErrorText = "connect returned " + result; - } - return result; - } catch (LinkageError error) { - return fail("connect", error, ELINKAGE); - } catch (RuntimeException error) { - return fail("connect", error, EJAVA); - } - } - - /** Convenience retrying connect for non-UI worker threads. */ - public static synchronized int connectWithRetry(Context context, int timeoutMs) { - Context app = applicationContext(context); - if (app == null) { - lastErrorText = "connectWithRetry: null Context"; - return -EINVAL; - } - try { - if (client == null) client = new AmyClient(); - int result = client.connectWithRetry(app, timeoutMs); - if (result == 0) { - lastErrorText = ""; - Log.i(TAG, "AMY control socket connected"); - } else { - lastErrorText = "connectWithRetry returned " + result; - } - return result; - } catch (LinkageError error) { - return fail("connectWithRetry", error, ELINKAGE); - } catch (RuntimeException error) { - return fail("connectWithRetry", error, EJAVA); - } - } - - /** Send one AMY wire request as one SOCK_SEQPACKET packet. */ - public static synchronized int sendWire(String wire) { - if (client == null) { - lastErrorText = "sendWire: client not connected"; - return -107; - } - try { - int result = client.sendWire(wire); - if (result == 0) { - lastErrorText = ""; - } else { - lastErrorText = "sendWire returned " + result; - } - return result; - } catch (LinkageError error) { - return fail("sendWire", error, ELINKAGE); - } catch (RuntimeException error) { - return fail("sendWire", error, EJAVA); - } - } - - public static synchronized boolean isConnected() { - return client != null && client.isConnected(); - } - - /** Close only the caller-process control socket. */ - public static synchronized void close() { - if (client != null) { - client.close(); - client = null; - } - } - - /** Close the client and request shutdown of the private AMY service. */ - public static synchronized int stop(Context context) { - close(); - Context app = applicationContext(context); - if (app == null) { - lastErrorText = "stop: null Context"; - return -EINVAL; - } - try { - AmyService.stop(app); - lastErrorText = ""; - return 0; - } catch (RuntimeException error) { - return fail("stop", error, EJAVA); - } - } - - /** Human-readable detail for the last bridge failure, if any. */ - public static synchronized String getLastErrorText() { - return lastErrorText; - } -} From 54720725f5b676569d0c432ba23fa36e1afcf47b Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:30:35 +0200 Subject: [PATCH 16/19] Make Godot Android backend socket-only --- godot/amy_android.gd | 138 +++++++++++++------------------------------ 1 file changed, 40 insertions(+), 98 deletions(-) diff --git a/godot/amy_android.gd b/godot/amy_android.gd index 8cf8376c..9e625f1f 100644 --- a/godot/amy_android.gd +++ b/godot/amy_android.gd @@ -1,129 +1,72 @@ class_name AmyAndroid extends Amy -## Android service backend for the high-level AMY GDScript API. +## Android transport for the high-level AMY GDScript API. ## -## The inherited Amy.send()/Amy.message() API still constructs ordinary AMY -## wire messages. Only send_raw() and lifecycle are replaced: messages are sent -## through the amy-service AAR to AMY running in the separate :amy process. -## -## Godot talks to the framework-neutral AmyAndroidBridge using only a Context, -## Strings and primitive return values. No AMY engine or rendered PCM exists in -## the Godot process. +## Amy.send()/Amy.message() stay in GDScript and produce ordinary AMY wire +## messages. Android starts the separate :amy service with the application; +## this class only connects to amy.sock and sends those wire messages. const CONNECT_RETRIES: int = 120 const CONNECT_RETRY_SECONDS: float = 0.05 -const ERR_JAVA_EXCEPTION: int = -1002 -var _android_runtime: Object = null var _android_context: Object = null -var _android_bridge: Object = null +var _android_client: Object = null var _android_last_error: int = 0 -var _android_status: String = "Not started" +var _android_status: String = "Waiting for amy.sock" func _ready() -> void: if OS.get_name() != "Android": - _set_status("AmyAndroid is only available in Android exports") - push_warning(_android_status) + push_warning("AmyAndroid is only available in Android exports") return - # Do not perform JNI/reflection work synchronously inside add_child(). This - # lets the host scene render and exposes the exact startup stage if Android - # or a third-party framework bridge ever stalls. - call_deferred("_init_android") - -func _set_status(text: String) -> void: - _android_status = text - print("Godot AMY stage: %s" % text) - -func _java_exception(stage: String) -> bool: - var exception: Object = JavaClassWrapper.get_exception() - if exception == null: - return false - _android_last_error = ERR_JAVA_EXCEPTION - _set_status("%s: Java exception" % stage) - push_error("AMY Android %s raised Java exception: %s" % [stage, str(exception)]) - return true - -func _bridge_error_text() -> String: - if _android_bridge == null: - return "" - var value: Variant = _android_bridge.getLastErrorText() - if _java_exception("getLastErrorText"): - return "Java exception while reading bridge error" - return str(value) + call_deferred("_connect_android") -func _init_android() -> void: - _set_status("Finding AndroidRuntime...") - await get_tree().process_frame - _android_runtime = Engine.get_singleton("AndroidRuntime") - if _android_runtime == null: - _android_last_error = -1 - _set_status("AndroidRuntime singleton unavailable") - push_error("AMY Android: %s" % _android_status) +func _connect_android() -> void: + var runtime: Object = Engine.get_singleton("AndroidRuntime") + if runtime == null: + _fail("AndroidRuntime unavailable", -1) return - _set_status("Getting Android application Context...") - await get_tree().process_frame - _android_context = _android_runtime.getApplicationContext() + _android_context = runtime.getApplicationContext() if _android_context == null: - _android_last_error = -1 - _set_status("Android application Context unavailable") - push_error("AMY Android: %s" % _android_status) - return - - _set_status("Loading AmyAndroidBridge class...") - await get_tree().process_frame - _android_bridge = JavaClassWrapper.wrap("org.amy.audio.AmyAndroidBridge") - if _java_exception("wrap AmyAndroidBridge"): - return - if _android_bridge == null or not _android_bridge.has_java_method("start"): - _android_last_error = -1 - _set_status("AmyAndroidBridge class/method unavailable") - push_error("AMY Android: %s" % _android_status) + _fail("Android application Context unavailable", -1) return - _set_status("Starting AMY Android service...") - await get_tree().process_frame - var start_value: Variant = _android_bridge.start(_android_context) - if _java_exception("AmyAndroidBridge.start"): - return - var start_result: int = int(start_value) - if start_result != 0: - _android_last_error = start_result - _set_status("AMY service start failed %d: %s" % [start_result, _bridge_error_text()]) - push_error("AMY Android: %s" % _android_status) + _android_client = JavaClassWrapper.wrap("org.amy.audio.AmyClient") + if _android_client == null or not _android_client.has_java_method("connect"): + _fail("AmyClient unavailable", -1) return - _set_status("AMY service requested; waiting for audio/socket...") for attempt in range(CONNECT_RETRIES): - var result_value: Variant = _android_bridge.connect(_android_context) - if _java_exception("AmyAndroidBridge.connect"): + var result: int = int(_android_client.connect(_android_context)) + var exception: Object = JavaClassWrapper.get_exception() + if exception != null: + _fail("AmyClient.connect Java exception: %s" % str(exception), -1000) return - var result: int = int(result_value) + _android_last_error = result if result == 0: - _android_last_error = 0 _started = true - _set_status("AMY ready") + _android_status = "AMY ready" print("AMY Android service ready") return - _android_last_error = result - if attempt == 0 or (attempt + 1) % 20 == 0: - _set_status("Waiting for amy.sock (attempt %d/%d, rc=%d)" % [attempt + 1, CONNECT_RETRIES, result]) + _android_status = "Waiting for amy.sock (rc=%d)" % result await get_tree().create_timer(CONNECT_RETRY_SECONDS).timeout - _set_status("AMY socket timeout rc=%d: %s" % [_android_last_error, _bridge_error_text()]) - push_error("AMY Android: %s" % _android_status) + _fail("AMY socket timeout (rc=%d)" % _android_last_error, _android_last_error) -# The Android service/Oboe path owns audio. Never feed Godot's -# AudioStreamGenerator on this backend. +func _fail(text: String, error: int) -> void: + _android_status = text + _android_last_error = error + push_error("AMY Android: %s" % text) + +# AMY/Oboe owns audio in the separate Android process. func _process(_delta: float) -> void: pass func _exit_tree() -> void: _started = false - if _android_bridge != null: - _android_bridge.stop(_android_context) - _java_exception("AmyAndroidBridge.stop") - _android_bridge = null + if _android_client != null: + _android_client.close() + _android_client = null func is_running() -> bool: return _started @@ -136,15 +79,14 @@ func status_text() -> String: ## Send one ordinary AMY wire request as one SOCK_SEQPACKET packet. func send_raw(msg: String) -> void: - if not _started or msg.is_empty() or _android_bridge == null: + if not _started or msg.is_empty() or _android_client == null: return - var result_value: Variant = _android_bridge.sendWire(msg) - if _java_exception("AmyAndroidBridge.sendWire"): + var result: int = int(_android_client.sendWire(msg)) + var exception: Object = JavaClassWrapper.get_exception() + if exception != null: _started = false + _fail("AmyClient.sendWire Java exception: %s" % str(exception), -1000) return - var result: int = int(result_value) if result < 0: - _android_last_error = result _started = false - _set_status("AMY send failed %d: %s" % [result, _bridge_error_text()]) - push_error("AMY Android send failed: %d" % result) + _fail("AMY send failed: %d" % result, result) From 571f9c1d1a91470cae31728f5cc6b79fecd3b9b7 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:30:53 +0200 Subject: [PATCH 17/19] Use auto-started AMY service in Android example --- .../main/java/org/amy/hello/MainActivity.java | 66 +++++++------------ 1 file changed, 23 insertions(+), 43 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 a48ec872..0894c2e4 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 @@ -10,21 +10,13 @@ import android.widget.LinearLayout; import android.widget.TextView; -import org.amy.audio.AmyAndroidBridge; +import org.amy.audio.AmyClient; -import java.io.File; -import java.io.IOException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public final class MainActivity extends Activity { private static final String TAG = "AmyHelloWorld"; - private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; - - // Keep the integration-test worker independent of one Activity instance. - // AmyAndroidBridge owns the process-level persistent socket client. Android - // may recreate the Activity during a cold launch; that must not interrupt a - // musical command sequence already in progress. private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); private TextView status; @@ -48,7 +40,7 @@ protected void onCreate(Bundle state) { ViewGroup.LayoutParams.WRAP_CONTENT)); status = new TextView(this); - status.setText("Starting AMY..."); + status.setText("Waiting for AMY..."); status.setTextSize(18); status.setGravity(Gravity.CENTER); LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( @@ -66,46 +58,38 @@ protected void onCreate(Bundle state) { setContentView(root); - // The hello-world app is also the Android integration test client. Arm - // one diagnostic capture before starting the service. The generic AAR - // does not capture anything unless this private marker exists. - try { - File marker = new File(getFilesDir(), AUDIO_CAPTURE_MARKER); - if (!marker.createNewFile() && !marker.isFile()) { - Log.e(TAG, "Unable to arm AMY audio capture: " + marker); - } - } catch (IOException ex) { - Log.e(TAG, "Unable to arm AMY audio capture", ex); - } - - int startResult = AmyAndroidBridge.start(this); - if (startResult < 0) { - Log.e(TAG, "AMY bridge start failed: " + startResult + " " - + AmyAndroidBridge.getLastErrorText()); - status.setText("AMY start error: " + startResult); - } else if (state == null) { + if (state == null) { playScale(); } else { status.setText("AMY ready"); } } + private static int connectWithRetry(Context context, int timeoutMs) { + long deadline = System.nanoTime() + timeoutMs * 1_000_000L; + int result; + do { + result = AmyClient.connect(context); + if (result == 0 || System.nanoTime() >= deadline) return result; + try { + Thread.sleep(50); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return -4; + } + } while (true); + } + private static int sendLogged(String wire) { - int result = AmyAndroidBridge.sendWire(wire); - if (result == 0) { - Log.i(TAG, "wire: " + wire); - } + int result = AmyClient.sendWire(wire); + if (result == 0) Log.i(TAG, "wire: " + wire); return result; } private static int playCScale(Context appContext) { - int result = AmyAndroidBridge.isConnected() - ? 0 - : AmyAndroidBridge.connectWithRetry(appContext, 5000); + int result = AmyClient.isConnected() ? 0 : connectWithRetry(appContext, 5000); if (result < 0) return result; - // AMY's V control is a 0..10 bus/master volume scale; V10.0 gives full - // master gain. The bridge preserves one wire request per socket packet. result = sendLogged("v0w0V10.0Z"); if (result < 0) return result; @@ -142,8 +126,7 @@ private void playScale() { if (rc == 0) { status.setText("C scale complete"); } else { - Log.e(TAG, "C scale failed: " + rc + " " - + AmyAndroidBridge.getLastErrorText()); + Log.e(TAG, "C scale failed: " + rc); status.setText("AMY/socket error: " + rc); } playButton.setEnabled(true); @@ -153,11 +136,8 @@ private void playScale() { @Override protected void onDestroy() { - // Do not close the process-level client during an Android Activity - // recreation. If this Activity is really finishing, release the socket; - // process death would close it automatically as well. if (isFinishing() && !isChangingConfigurations()) { - AmyAndroidBridge.close(); + AmyClient.close(); } super.onDestroy(); } From eadbadf9050cec35a06e39f80b6b639cbd15ea46 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:31:19 +0200 Subject: [PATCH 18/19] Test automatic AMY service startup --- .github/workflows/android.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b99cdf0b..9548eb84 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -82,11 +82,14 @@ jobs: script: | adb uninstall org.amy.hello >/dev/null 2>&1 || true adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyAutoStart:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log cat /tmp/amy-first.log + grep -q 'AMY service auto-start requested' /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 @@ -99,11 +102,14 @@ jobs: adb uninstall org.amy.hello adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyAutoStart:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log cat /tmp/amy-second.log + grep -q 'AMY service auto-start requested' /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 From ecc9895c8fdf8fb57a2efd860425d1c8a1131635 Mon Sep 17 00:00:00 2001 From: linuxificator Date: Sun, 23 Aug 2026 14:31:56 +0200 Subject: [PATCH 19/19] Test Godot as socket-only AMY client --- .github/workflows/godot-android.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml index d323b439..485c528b 100644 --- a/.github/workflows/godot-android.yml +++ b/.github/workflows/godot-android.yml @@ -65,7 +65,8 @@ jobs: unzip -l /tmp/amy-classes.jar | tee /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyService.class' /tmp/amy-classes.txt grep -q 'org/amy/audio/AmyClient.class' /tmp/amy-classes.txt - grep -q 'org/amy/audio/AmyAndroidBridge.class' /tmp/amy-classes.txt + grep -q 'org/amy/audio/AmyAutoStartProvider.class' /tmp/amy-classes.txt + ! grep -q 'AmyAndroidBridge.class' /tmp/amy-classes.txt - name: Install Godot 4.7.2 and export templates run: | @@ -121,7 +122,8 @@ jobs: unzip -p "$ARM" classes.dex | strings > /tmp/amy-arm-dex.txt grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-arm-dex.txt grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-arm-dex.txt - grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-arm-dex.txt + grep -q 'Lorg/amy/audio/AmyAutoStartProvider;' /tmp/amy-arm-dex.txt + ! grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-arm-dex.txt unzip -l "$X86" | tee /tmp/amy-x86-apk.txt grep -q 'lib/x86_64/libgodot_android.so' /tmp/amy-x86-apk.txt @@ -132,7 +134,8 @@ jobs: unzip -p "$X86" classes.dex | strings > /tmp/amy-x86-dex.txt grep -q 'Lorg/amy/audio/AmyService;' /tmp/amy-x86-dex.txt grep -q 'Lorg/amy/audio/AmyClient;' /tmp/amy-x86-dex.txt - grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-x86-dex.txt + grep -q 'Lorg/amy/audio/AmyAutoStartProvider;' /tmp/amy-x86-dex.txt + ! grep -q 'Lorg/amy/audio/AmyAndroidBridge;' /tmp/amy-x86-dex.txt ARM_SIZE=$(stat -c%s "$ARM") X86_SIZE=$(stat -c%s "$X86") @@ -164,12 +167,11 @@ jobs: sleep 15 echo '===== PROCESS LIST =====' > /tmp/amy-godot-diagnostics.txt adb shell ps -A >> /tmp/amy-godot-diagnostics.txt 2>&1 || true - echo '===== PACKAGE =====' >> /tmp/amy-godot-diagnostics.txt - adb shell dumpsys package org.amy.godothello >> /tmp/amy-godot-diagnostics.txt 2>&1 || true echo '===== SERVICES =====' >> /tmp/amy-godot-diagnostics.txt adb shell dumpsys activity services org.amy.godothello >> /tmp/amy-godot-diagnostics.txt 2>&1 || true echo '===== APP FILES =====' >> /tmp/amy-godot-diagnostics.txt - adb shell run-as org.amy.godothello sh -c 'id; pwd; ls -la files; ls -la files/amy.sock 2>&1' >> /tmp/amy-godot-diagnostics.txt 2>&1 || true + adb shell run-as org.amy.godothello ls -la files >> /tmp/amy-godot-diagnostics.txt 2>&1 || true + adb shell run-as org.amy.godothello ls -la files/amy.sock >> /tmp/amy-godot-diagnostics.txt 2>&1 || true adb logcat -d > /tmp/amy-godot.log echo '===== LOGCAT =====' >> /tmp/amy-godot-diagnostics.txt cat /tmp/amy-godot.log >> /tmp/amy-godot-diagnostics.txt @@ -188,10 +190,9 @@ jobs: - name: Assert Godot-to-AMY smoke result run: | cat /tmp/amy-godot.log - grep -q 'AMY service start requested' /tmp/amy-godot.log + grep -q 'AMY service auto-start requested' /tmp/amy-godot.log grep -q 'AMY native library loaded in service process' /tmp/amy-godot.log grep -q 'AMY/Oboe started' /tmp/amy-godot.log - grep -q 'AMY control socket connected' /tmp/amy-godot.log grep -q 'AMY Android service ready' /tmp/amy-godot.log grep -q 'Godot AMY ready' /tmp/amy-godot.log grep -q 'Godot wire: v0w0V10' /tmp/amy-godot.log @@ -199,4 +200,6 @@ jobs: grep -q 'Godot wire: v0n60l1' /tmp/amy-godot.log grep -q 'Godot wire: v0n72l1' /tmp/amy-godot.log grep -q 'Godot C scale complete' /tmp/amy-godot.log + grep -q 'org.amy.godothello:amy' /tmp/amy-godot-diagnostics.txt + grep -q 'amy.sock' /tmp/amy-godot-diagnostics.txt ! grep -q 'Godot AMY error:' /tmp/amy-godot.log