diff --git a/.github/workflows/godot-android.yml b/.github/workflows/godot-android.yml new file mode 100644 index 00000000..a10feb70 --- /dev/null +++ b/.github/workflows/godot-android.yml @@ -0,0 +1,189 @@ +name: Godot Android + +on: + push: + branches: [main] + paths: + - "godot/**" + - "android/amy-service/**" + - "src/amy_unix_socket.*" + - "tests/check_android_audio_capture.py" + - ".github/workflows/godot-android.yml" + pull_request: + paths: + - "godot/**" + - "android/amy-service/**" + - "src/amy_unix_socket.*" + - "tests/check_android_audio_capture.py" + - ".github/workflows/godot-android.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + 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: Prepare Android service and shared Godot API + run: bash godot/android-hello-world/prepare.sh + + - name: Verify transport-only Godot packaging inputs + run: | + AAR=godot/android-hello-world/addons/amy_android/amy-service-debug.aar + test -s "$AAR" + cmp godot/amy.gd godot/android-hello-world/amy.gd + unzip -l "$AAR" | tee /tmp/amy-aar.txt + grep -q 'jni/arm64-v8a/libamy_android.so' /tmp/amy-aar.txt + grep -q 'jni/x86_64/libamy_android.so' /tmp/amy-aar.txt + ! grep -q 'libamy_android_client.so' /tmp/amy-aar.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/AmyClient.class' /tmp/amy-classes.txt + ! grep -q 'JavaClassWrapper' godot/android-hello-world/main.gd + ! grep -q 'AmyClient' godot/android-hello-world/main.gd + ! grep -q 'sendWire' godot/android-hello-world/main.gd + ! grep -Eq '^android\.' godot/amy.gdextension + test ! -e godot/android-hello-world/amy.gdextension + test ! -d godot/android-hello-world/addons/amy/bin + + - name: Install Godot 4.7.2 and Android 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/" + + - name: Import project and reject GDScript errors + run: | + godot --headless --path godot/android-hello-world --import --quit 2>&1 | tee /tmp/godot-import.log + ! grep -q 'SCRIPT ERROR' /tmp/godot-import.log + ! grep -q 'ERROR: Failed to load script' /tmp/godot-import.log + + - name: Export Android APKs + run: | + mkdir -p godot/android-hello-world/build + godot --headless --path godot/android-hello-world --install-android-build-template \ + --export-debug "Android CI x86_64" build/amy-godot-android-x86_64.apk + godot --headless --path godot/android-hello-world \ + --export-debug "Android ARM64" build/amy-godot-android-arm64.apk + test -s godot/android-hello-world/build/amy-godot-android-x86_64.apk + test -s godot/android-hello-world/build/amy-godot-android-arm64.apk + + - name: Verify APKs contain only the service AMY implementation + run: | + ARM=godot/android-hello-world/build/amy-godot-android-arm64.apk + X86=godot/android-hello-world/build/amy-godot-android-x86_64.apk + unzip -l "$ARM" | tee /tmp/arm-apk.txt + grep -q 'lib/arm64-v8a/libamy_android.so' /tmp/arm-apk.txt + ! grep -q 'libamy_android_client.so' /tmp/arm-apk.txt + test "$(grep -Ec 'lib/arm64-v8a/libamy[^ ]*\.so' /tmp/arm-apk.txt)" -eq 1 + ! zipinfo -1 "$ARM" | grep -q 'amy.gdextension' + + unzip -l "$X86" | tee /tmp/x86-apk.txt + grep -q 'lib/x86_64/libamy_android.so' /tmp/x86-apk.txt + ! grep -q 'libamy_android_client.so' /tmp/x86-apk.txt + test "$(grep -Ec 'lib/x86_64/libamy[^ ]*\.so' /tmp/x86-apk.txt)" -eq 1 + ! zipinfo -1 "$X86" | grep -q 'amy.gdextension' + + - name: Upload ARM64 test APK + uses: actions/upload-artifact@v4 + with: + name: amy-godot-android-arm64 + path: godot/android-hello-world/build/amy-godot-android-arm64.apk + if-no-files-found: error + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Amy.gd socket/audio 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: | + set -e + adb uninstall org.amy.godothello >/dev/null 2>&1 || true + adb install godot/android-hello-world/build/amy-godot-android-x86_64.apk + adb shell run-as org.amy.godothello mkdir -p files + adb shell run-as org.amy.godothello touch files/amy-audio-capture.enable + adb logcat -c + adb shell am start -W -n org.amy.godothello/com.godot.game.GodotAppLauncher || true + sleep 12 + adb logcat -d > /tmp/amy-godot.log + adb shell ps -A > /tmp/amy-processes.txt + + # This is deliberately an AMY transport/audio regression, not a Godot + # renderer regression. Godot/SwiftShader UI errors after these events + # are irrelevant as long as Amy.gd reached the service and audio exists. + grep -q 'AMY listening on private socket' /tmp/amy-godot.log + grep -q 'AMY/Oboe started:' /tmp/amy-godot.log + grep -q 'AMY Android connected to amy.sock' /tmp/amy-godot.log + grep -q 'Godot Amy.gd Android backend ready' /tmp/amy-godot.log + grep -q 'AMY Android wire: v0w0V10' /tmp/amy-godot.log + grep -q 'AMY Android wire: v0n60l1' /tmp/amy-godot.log + ! grep -q 'AmyClient.sendWire exception:' /tmp/amy-godot.log + ! grep -q 'amy.sock send failed:' /tmp/amy-godot.log + grep -q 'org.amy.godothello:amy' /tmp/amy-processes.txt + + mkdir -p android/godot-audio-capture + adb exec-out run-as org.amy.godothello cat files/amy-render.wav > android/godot-audio-capture/amy-render.wav + adb exec-out run-as org.amy.godothello cat files/amy-oboe.wav > android/godot-audio-capture/amy-oboe.wav + test -s android/godot-audio-capture/amy-render.wav + test -s android/godot-audio-capture/amy-oboe.wav + + - name: Analyze captured audio + run: | + python3 tests/check_android_audio_capture.py \ + android/godot-audio-capture/amy-render.wav \ + android/godot-audio-capture/amy-oboe.wav + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: amy-godot-android-diagnostics + path: | + /tmp/amy-godot.log + /tmp/amy-processes.txt + android/godot-audio-capture/ + if-no-files-found: warn diff --git a/android/README.md b/android/README.md index 17c14f54..b3b02977 100644 --- a/android/README.md +++ b/android/README.md @@ -1,9 +1,6 @@ # AMY Android Oboe service -This directory builds a generic Android AAR that hosts AMY in an unexported -`:amy` service process. The service owns Oboe/AAudio output and receives native -AMY wire messages through the private pathname Unix transport implemented by -`src/amy_unix_socket.[ch]`. +This directory builds a generic Android AAR that hosts AMY in an unexported `:amy` service process. The service owns Oboe/AAudio output and receives native AMY wire messages through the private pathname Unix transport implemented by `src/amy_unix_socket.[ch]`. ```text Android client process @@ -23,58 +20,25 @@ Android :amy service process AAudio ``` -The AAR is embedded in an Android application package. Its private -`AmyAutoStartProvider` starts the separate `:amy` service process as part of -Android package initialization; client application code does not start or stop -AMY. A client can therefore be Java/Kotlin, native code, Godot, Qt, another -framework, or any other environment that can package an Android AAR and open an -Android Unix-domain `SOCK_SEQPACKET` socket. No AMY headers, AMY source, JNI -bindings, or language-specific AMY API are required in the client code. - -The service declaration uses `android:exported="false"` and -`android:process=":amy"`. Consequently the service runs in a separate process -from the client while remaining in the same Android application package and -under the same application UID. - -The service only accepts the exact pathname `/amy.sock`. -The native transport creates that node mode `0600` and additionally verifies -accepted peers with `SO_PEERCRED` against the service effective UID. The AAR -must therefore be packaged into the same application/UID as the client; this is -intentional and preserves the private-socket security model. See -`docs/android_unix_socket.md` for the transport/security contract. +The AAR is embedded in an Android application package. Its private `AmyAutoStartProvider` starts the separate `:amy` service process as part of Android package initialization; client application code does not start or stop AMY. A client can therefore be Java/Kotlin, native code, Godot, Qt, another framework, or any other environment that can package an Android AAR and open an Android Unix-domain `SOCK_SEQPACKET` socket. No AMY headers, AMY source, JNI bindings, or language-specific AMY API are required in the client code. -## Audio profile +The service declaration uses `android:exported="false"` and `android:process=":amy"`. Consequently the service runs in a separate process from the client while remaining in the same Android application package and under the same application UID. -The Android native build uses AMY's existing 48 kHz / 128-frame build profile -and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. +The service only accepts the exact pathname `/amy.sock`. The native transport creates that node mode `0600` and additionally verifies accepted peers with `SO_PEERCRED` against the service effective UID. The AAR must therefore be packaged into the same application/UID as the client; this is intentional and preserves the private-socket security model. See `docs/android_unix_socket.md` for the transport/security contract. -Oboe requests: +## Audio profile -- stereo signed 16-bit output -- 48 kHz -- `PerformanceMode::LowLatency` -- `SharingMode::Exclusive` -- callback-driven output +The Android native build uses AMY's existing 48 kHz / 128-frame build profile and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. -The 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. +Oboe requests stereo signed 16-bit output at 48 kHz with `PerformanceMode::LowLatency`, `SharingMode::Exclusive`, and callback-driven output. The callback size is not assumed to equal 128 frames. The native adapter keeps only the unconsumed tail of the current AMY block and calls `amy_simple_fill_buffer()` exactly when another AMY block is required; it does not add an extra 128-frame output ring. -Before each new AMY block the callback drains up to 64 already-queued socket -packets and passes them to `amy_add_message()`. The socket thread itself never -calls AMY and never participates in audio rendering. +Before each new AMY block the callback drains up to 64 already-queued socket packets and passes them to `amy_add_message()`. The socket thread itself never calls AMY and never participates in audio rendering. -AMY is started with its internal platform audio disabled and with AMY rendering -owned by the Oboe callback thread. The current Android build configuration -reserves 16 Karplus-Strong oscillators. +AMY is started with its internal platform audio disabled and with rendering owned by the Oboe callback thread. The current Android build configuration reserves 16 Karplus-Strong oscillators. ## JNI boundary -JNI exists only inside the service implementation. `AmyService` calls the -native library to start and stop AMY/Oboe and to report its actual Oboe output -device. Musical control never crosses JNI: notes, patches, sequencer commands, -and other control are unchanged AMY wire packets sent through `amy.sock`. +JNI exists only inside the service implementation. `AmyService` calls the native library to start and stop AMY/Oboe and to report its actual Oboe output device. Musical control never crosses JNI: notes, patches, sequencer commands, and other control are unchanged AMY wire packets sent through `amy.sock`. The client-facing architecture is deliberately transport-only: @@ -82,14 +46,11 @@ The client-facing architecture is deliberately transport-only: client application -> amy.sock -> AMY/Oboe service ``` -The minimal Java hello-world demonstrates this literally with Android's public -`LocalSocket(SOCKET_SEQPACKET)` API. It neither imports `AmyService` nor loads a -native client library. +The minimal Java hello-world demonstrates this literally with Android's public `LocalSocket(SOCKET_SEQPACKET)` API. It neither imports `AmyService` nor loads a native client library. ## Socket client contract -Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet. -For example the payload of three consecutive packets may be: +Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet. For example: ```text K28i2Z @@ -97,34 +58,32 @@ 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 -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 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. +The socket is bidirectional. The Android engine currently consumes ordinary AMY wire commands; the existing `amy_unix_socket_send()` path is ready for compact introspection/status replies when that functionality is integrated. ## Client integration A client application needs to: 1. package the `amy-service` AAR/module in the Android application; -2. obtain the application's actual private files directory rather than - hard-code `/data/user/...`; -3. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` - until the service publishes its ready socket; +2. obtain the application's actual private files directory rather than hard-code `/data/user/...`; +3. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` until the service publishes its ready socket; 4. send one ordinary AMY wire message per packet; 5. optionally receive response packets over the same bidirectional socket; 6. reconnect cleanly when its own Android/application lifecycle requires it. -Starting AMY is deliberately absent from the client contract. The packaged AAR -owns that Android lifecycle responsibility. +Starting AMY is deliberately absent from the client contract. The packaged AAR owns that Android lifecycle responsibility. + +### Godot client + +The Godot Android integration in `godot/amy.gd` preserves the existing high-level GDScript API. `Amy.send(Dictionary)` uses the same Dictionary-to-wire encoder as the desktop and web backends, then calls `org.amy.audio.AmyClient` from the AAR. `AmyClient` is a small **pure-Java transport helper** around Android `LocalSocket(SOCKET_SEQPACKET)`; it contains no synth engine, loads no JNI client library, and does not control `AmyService` lifecycle. + +This helper exists because GDScript needs a Java bridge to Android's pathname Unix socket API. It does not change the generic socket contract: other frameworks can use `amy.sock` directly without `AmyClient` if they have their own socket access. + +The Godot Android APK deliberately excludes the `AmySynth` GDExtension and AMY C/C++ sources. See `docs/godot.md` and `godot/android-hello-world/README.md`. ## Building the AAR @@ -151,6 +110,8 @@ The production Android service build targets `arm64-v8a`. Output is below: android/amy-service/build/outputs/aar/ ``` +Building this AAR is the native AMY build step. Applications may instead package a previously built/released AAR; the client framework itself does not need to compile AMY. + ## Tests The private socket regression test is: @@ -159,22 +120,10 @@ The private socket regression test is: bash tests/run_amy_unix_socket_test.sh ``` -It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, -oversized-packet rejection, cleanup, and protection against deleting an -existing non-socket path. +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 and emulator end-to-end test. The emulator arms its own -one-shot audio-capture marker before starting the client; the hello-world -application itself remains transport-only. +`.github/workflows/android.yml` runs the generic Android service/Java-client regression. `.github/workflows/godot-android.yml` additionally exports Godot Android APKs, rejects any packaged `AmySynth` GDExtension or second AMY native client library, and runs the high-level `Amy.gd` Dictionary API through `amy.sock` on an Android emulator. ## Hardware-test items -The first device tests should measure: - -1. command-to-audio latency; -2. negotiated Oboe callback/device buffer sizes; -3. xruns during patch changes and heavy reverb/delay loads; -4. suspend/resume and audio-device changes; -5. whether executing rare heavy AMY commands at a block boundary needs further - separation from the realtime callback. +Device testing should cover command-to-audio latency, negotiated Oboe callback/device buffer sizes, xruns during patch changes and heavy effects, suspend/resume and audio-device changes, and whether rare heavy AMY commands need further separation from the realtime callback. 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..b9b9de2e --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyClient.java @@ -0,0 +1,85 @@ +package org.amy.audio; + +import android.content.Context; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * Minimal pure-Java client for the private AMY SOCK_SEQPACKET socket. + * + * This class is transport only: it contains no AMY engine code, does not start + * or stop AmyService, and does not load any JNI/native client library. + */ +public final class AmyClient { + private static final int EINVAL = 22; + private static final int EIO = 5; + private static final int ENOTCONN = 107; + + private static LocalSocket socket; + private static OutputStream output; + + private AmyClient() {} + + private static String socketPath(Context context) { + if (context == null) return ""; + return new File(context.getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + } + + /** Connect once to filesDir/amy.sock. Returns 0 or a negative errno-style value. */ + public static synchronized int connect(Context context) { + if (context == null) return -EINVAL; + closeLocked(); + + LocalSocket candidate = new LocalSocket(LocalSocket.SOCKET_SEQPACKET); + LocalSocketAddress address = new LocalSocketAddress( + socketPath(context), LocalSocketAddress.Namespace.FILESYSTEM); + try { + candidate.connect(address); + output = candidate.getOutputStream(); + socket = candidate; + return 0; + } catch (IOException ex) { + try { + candidate.close(); + } catch (IOException ignored) { + } + return -EIO; + } + } + + /** Send exactly one AMY wire command as one SOCK_SEQPACKET packet. */ + public static synchronized int sendWire(String wire) { + if (socket == null || output == null) return -ENOTCONN; + if (wire == null || wire.isEmpty()) return -EINVAL; + + try { + output.write(wire.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + return 0; + } catch (IOException ex) { + closeLocked(); + return -EIO; + } + } + + public static synchronized void close() { + closeLocked(); + } + + private static void closeLocked() { + output = null; + if (socket != null) { + try { + socket.close(); + } catch (IOException ignored) { + } + socket = null; + } + } +} diff --git a/docs/godot.md b/docs/godot.md index 3918c4f7..ddce49ea 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -1,189 +1,220 @@ # AMY in Godot -The AMY synthesizer engine works as a [GDExtension](https://docs.godotengine.org/en/stable/tutorials/scripting/gdextension/index.html) addon for Godot 4.3+, with support for both **native** (macOS, Linux, Windows) and **web** exports. +AMY exposes one high-level GDScript API across three different runtime backends: -On native platforms, AMY runs as a C library via GDExtension and routes audio through Godot's `AudioStreamGenerator`. On web, AMY runs its own WASM module with AudioWorklet and the GDScript wrapper sends wire messages via `JavaScriptBridge`. +- **Desktop native (macOS/Linux/Windows):** AMY runs in-process through the `AmySynth` GDExtension and feeds Godot's `AudioStreamGenerator`. +- **Android:** `Amy.gd` keeps the same Dictionary-to-wire API, but AMY runs in the independent Android `:amy` service from the `amy-service` AAR. Godot sends ordinary AMY wire messages over the app-private `amy.sock` socket. The Android APK does not contain the `AmySynth` GDExtension. +- **Web:** AMY runs in its WASM/AudioWorklet backend and `Amy.gd` sends wire messages through `JavaScriptBridge`. -## Quick Start +The existing desktop/web addon targets Godot 4.3+. The Android integration is continuously tested with Godot 4.7.2 and Android API 35/36 tooling. -### Option A: Download pre-built addon (easiest) +## Common GDScript API -Download [`amy-godot-addon.zip`](https://github.com/shorepine/amy/releases/latest/download/amy-godot-addon.zip) and unzip it into your Godot project root so you have `your_project/addons/amy/`. +Create an `Amy` node and connect its readiness/error signals before adding it to the scene tree. Connecting first avoids missing a backend that becomes ready immediately. -On **macOS**, you need to remove the quarantine flag from the downloaded binary: +```gdscript +var amy: Amy + +func _ready() -> void: + amy = Amy.new() + amy.backend_ready.connect(_on_amy_ready) + amy.backend_error.connect(_on_amy_error) + add_child(amy) + +func _on_amy_ready() -> void: + amy.send({"osc": 0, "wave": Amy.SINE, "freq": 440, "vel": 1.0}) + amy.send({"osc": 1, "wave": Amy.TRIANGLE, "note": 60, "vel": 0.5}) + amy.send({"osc": 0, "vel": 0}) + +func _on_amy_error(message: String) -> void: + push_error(message) +``` + +You can also build or send wire messages explicitly: + +```gdscript +var wire := amy.message({"osc": 0, "note": 60, "vel": 1.0}) +amy.send_raw(wire) +``` + +`message(Dictionary)` is shared by all backends, so Android does not maintain a second AMY API or protocol encoder. + +## Desktop native setup + +### Option A: download the pre-built addon + +Download [`amy-godot-addon.zip`](https://github.com/shorepine/amy/releases/latest/download/amy-godot-addon.zip) and unzip it into the Godot project root so it creates `addons/amy/`. + +On macOS, remove the quarantine flag if necessary: ```bash xattr -dr com.apple.quarantine addons/amy/bin/* ``` -If you don't do this, you'll see a Godot error like "Apple could not verify 'libamy.macos.template_debug.universal.dylib' is free of malware that may harm your Mac or compromise your privacy." This command tells macOS you've chosen to trust the software yourself. - -### Option B: Build from source +### Option B: build from source -Clone AMY and [godot-cpp](https://github.com/godotengine/godot-cpp), then run the setup script: +Clone AMY and `godot-cpp`, then run: ```bash git clone https://github.com/shorepine/amy.git cd amy - -# Clone godot-cpp next to your project (or wherever you like) git clone --branch godot-4.4-stable https://github.com/godotengine/godot-cpp.git ../godot-cpp - -# Build the addon and install it into your Godot project ./setup_godot.sh /path/to/your/godot/project ``` -The script builds the native GDExtension library and copies everything into `your_project/addons/amy/`. - -If you want to point to a `godot-cpp` checkout in a different location: +For a `godot-cpp` checkout elsewhere: ```bash GODOT_CPP_PATH=/path/to/godot-cpp ./setup_godot.sh /path/to/your/godot/project ``` -### 2. Open the project in the Godot editor +`setup_godot.sh` is the **desktop-native GDExtension** installation path. Do not use the resulting `AmySynth` GDExtension as the Android backend. -Open or reimport the project so Godot registers the `Amy` class. +### Desktop engine configuration -### 3. Use AMY in your scripts +Before adding the `Amy` node to the tree, desktop native code can set engine configuration properties such as: ```gdscript -var amy: Amy +amy = Amy.new() +amy.startup_bleep = false +amy.default_synths = true +amy.max_oscs = 180 +add_child(amy) +``` -func _ready(): - amy = Amy.new() - add_child(amy) - await get_tree().process_frame # let AMY initialize +Those properties configure the in-process native engine before `AmySynth.start()`. + +## Android + +### Architecture + +Android deliberately separates the application/framework process from AMY: + +```text +Godot application process + | + | amy.send(Dictionary) + v + Amy.gd + | + | shared Dictionary -> AMY wire encoder + v +AmyClient (pure Java transport helper) + | + | LocalSocket / SOCK_SEQPACKET + v +/amy.sock + | + v +Android :amy service process + | + +-- AMY C engine + +-- Oboe / AAudio +``` - # Play a 440 Hz sine wave - amy.send({"osc": 0, "wave": Amy.SINE, "freq": 440, "vel": 1.0}) +The `amy-service` AAR owns service startup through its private Android `ContentProvider`. Godot does not call `AmyService.start()` or `stop()`. The socket is published only after Oboe has delivered its first realtime callback; successful socket connection is therefore the Android readiness boundary. - # Play a MIDI note on a triangle wave - amy.send({"osc": 1, "wave": Amy.TRIANGLE, "note": 60, "vel": 0.5}) +The service and Godot application remain in the same APK/UID because `amy.sock` is intentionally private (`0600` plus `SO_PEERCRED` same-UID validation). - # Stop oscillator 0 - amy.send({"osc": 0, "vel": 0}) +### No AMY/GDExtension compile in the Godot application - # Use a patch (preset instrument) - amy.send({"synth": 1, "patch": 1, "num_voices": 6, "note": 48, "vel": 0.8}) +An Android Godot project needs: - # Or use wire protocol directly - amy.send_raw("v3w0f880l0.5") -``` +1. the shared `amy.gd` GDScript API; +2. a **prebuilt** `amy-service` AAR packaged into the Android APK; +3. an Android export plugin that adds that AAR. -### 4. Configure AMY (optional) +It does **not** need AMY C/C++ source, `godot/amy.gdextension`, `godot/bin/`, or a Godot AMY native client library. The final APK contains one AMY native implementation: `libamy_android.so` from the service AAR. `AmyClient` is pure Java transport code and loads no JNI/native client library. -Set [config properties](api.md) on the `Amy` node **before** adding it to the tree: +Building the AAR from an AMY source checkout is a development/distribution step, not a requirement that every Godot application must repeat. A released/prebuilt AAR can be reused by multiple Godot projects. -```gdscript -var amy: Amy +### Android hello-world -func _ready(): - amy = Amy.new() - amy.startup_bleep = false - amy.default_synths = true - add_child(amy) # config is applied when AMY starts in _ready() +The repository contains `godot/android-hello-world`, which demonstrates the intended packaging and API boundary. + +From an AMY source checkout: + +```bash +bash godot/android-hello-world/prepare.sh +godot --editor --path godot/android-hello-world ``` -## Web Export +`prepare.sh` builds debug/release service AARs and copies the shared `godot/amy.gd` into the example. The example's `addons/amy_android` export plugin packages the appropriate AAR. This source-build helper exists for development and CI; a downstream project can instead package a prebuilt AAR directly. + +Do not add the desktop `amy.gdextension` or its compiled libraries to an Android export. + +### Android API surface -AMY works on web exports. The native GDExtension isn't used on web — instead, AMY's pre-built WASM module runs via JavaScript and the `Amy` class automatically switches to using `JavaScriptBridge`. +The ordinary wire-oriented GDScript API works on Android: -### Setup steps +- `send(params: Dictionary)` +- `message(params: Dictionary)` +- `send_raw(msg: String)` +- `panic()` +- `reset_sysclock()` (implemented as an ordinary wire event) +- the wave/filter/envelope constants and Dictionary formatting helpers -1. **Run the install script** in the Godot editor: - - Open `addons/amy/install.gd` in the Script Editor - - Run it via **File > Run** (or `Ctrl/Cmd+Shift+X`) - - This copies web audio files to the right locations +The table-driven methods that call AMY's C API directly in the desktop GDExtension (`render_load()`, `set_render_load_threshold()`, `bleep()`, `sequencer_ticks()`, `dump_state()`) are **not transported over `amy.sock` by this Android backend**. Do not rely on those methods on Android until a wire/status equivalent is defined. -2. **Configure the web export preset**: - - Open **Project > Export > Web** - - Set **Custom HTML Shell** to `res://export/custom_shell.html` - - Set **Exclude Filter** to `addons/amy/bin/*,addons/amy/src/*,addons/amy/amy_src/*,addons/amy/web/*,addons/amy/SConstruct,addons/amy/install.gd,addons/amy/amy.gdextension` (exclude native libraries and build files, but keep `amy.gd`) +Likewise, the `Amy` node's pre-start engine configuration properties (`max_oscs`, `max_buses`, feature toggles, and similar settings) configure the in-process desktop backend but do not reconfigure an already-started Android service. On Android, engine configuration is owned by the AAR/service. Parameters that are part of the ordinary AMY wire protocol should be sent with `amy.send(...)` instead. -3. **Export to a separate folder** (e.g. `dist/` inside your project): - - Click **Export Project** and save the `.html` file into a new folder (e.g. `dist/YourGame.html`) - - Godot will place all its export files there automatically +## Web export + +The native GDExtension is not used on web. AMY runs as its pre-built WASM module and the `Amy` class switches to `JavaScriptBridge`. + +1. Run `addons/amy/install.gd` from the Godot Script Editor. +2. In the Web export preset set **Custom HTML Shell** to `res://export/custom_shell.html`. +3. Exclude native/build files while keeping `amy.gd`: + + ```text + addons/amy/bin/*,addons/amy/src/*,addons/amy/amy_src/*,addons/amy/web/*,addons/amy/SConstruct,addons/amy/install.gd,addons/amy/amy.gdextension + ``` + +4. Export to a directory such as `dist/`. +5. Copy the AMY web audio support files: -4. **Copy AMY's web audio files** into the export folder: ```bash cp -r addons/amy/web/ dist/web_audio/ cp addons/amy/web/enable-threads.js dist/ ``` -5. **Deploy** — upload the contents of `dist/` to your web server. The folder should contain: - ``` - YourGame.html # main page - YourGame.js # Godot engine - YourGame.wasm # Godot WASM binary - YourGame.pck # packed game assets - YourGame.audio.worklet.js # Godot audio worklet - YourGame.audio.position.worklet.js # Godot audio position worklet - enable-threads.js # COOP/COEP service worker for AudioWorklet support - web_audio/ # AMY audio engine - amy.js - amy.wasm - godot_amy_bridge.js - enable-threads.js - ``` +For local testing, serve the export directory through HTTP rather than opening the HTML file directly. -Or run locally: `python3 -m http.server` from your `dist` folder and go to `localhost:8000`. +## Backend summary +| Platform | AMY engine location | Godot-side native AMY code | Audio owner | Control path | +|---|---|---|---|---| +| macOS/Linux/Windows | Godot process | `AmySynth` GDExtension | Godot `AudioStreamGenerator` | `Amy.gd` -> GDExtension | +| Android | separate `:amy` process | none | Oboe/AAudio service | `Amy.gd` -> `AmyClient` -> `amy.sock` | +| Web | WASM/AudioWorklet | none | Web Audio | `Amy.gd` -> `JavaScriptBridge` | -## How It Works +## API reference -- **Native (macOS/Linux/Windows):** AMY runs as a GDExtension (`AmySynth` C++ class) compiled with `-DAMY_NO_MINIAUDIO` and `AMY_AUDIO_IS_NONE`. Audio is rendered via `amy_simple_fill_buffer()` and routed through Godot's `AudioStreamGenerator` at 44100 Hz. - -- **Web:** AMY runs as its own WASM module with Web Audio API AudioWorklets. The `Amy` GDScript class detects `OS.get_name() == "Web"` and sends wire messages via `JavaScriptBridge` instead of the native extension. +### `amy.send(params: Dictionary)` +Sends an AMY message using named parameters matching the Python API. Common parameters include `osc`, `wave`, `freq`, `note`, `vel`, `amp`, `duty`, `pan`, `patch`, `filter_freq`, `filter_type`, `resonance`, `feedback`, `ratio`, `algorithm`, `bp0`, `bp1`, `volume`, `tempo`, `chorus`, `reverb`, and `echo`. -## API Reference +See the full [AMY API reference](api.md) for all fields. -### `amy.send(params: Dictionary)` +### `amy.message(params: Dictionary)` -Send a message to AMY using named parameters. This mirrors AMY's Python API — the parameter names are the same as `amy.send()` in Python. - -**Common parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `osc` | int | Oscillator number (0-63) | -| `wave` | int | Wave type (use constants like `Amy.SINE`) | -| `freq` | float | Frequency in Hz | -| `note` | int/float | MIDI note number | -| `vel` | float | Velocity / volume (0.0 = off, 1.0 = max) | -| `amp` | float | Amplitude | -| `duty` | float | Pulse width duty cycle | -| `pan` | float | Stereo panning | -| `patch` | int | Preset patch number | -| `filter_freq` | float | Filter cutoff frequency | -| `filter_type` | int | Filter type (use `Amy.FILTER_LPF` etc.) | -| `resonance` | float | Filter resonance / Q | -| `feedback` | float | FM feedback amount | -| `ratio` | float | FM frequency ratio | -| `algorithm` | int | FM algorithm number | -| `bp0` | string | Breakpoint envelope 0 (time,val pairs) | -| `bp1` | string | Breakpoint envelope 1 | -| `volume` | float | Global volume | -| `tempo` | float | Sequencer tempo in BPM | -| `chorus` | string | Chorus settings | -| `reverb` | string | Reverb settings | -| `echo` | string | Echo/delay settings | - -See the full [AMY API reference](api.md) for all available parameters. +Builds the AMY wire string without sending it. This is the shared encoder used by `send()` on desktop, Android, and web. ### `amy.send_raw(msg: String)` -Send a raw AMY wire-protocol message (e.g. `"v0w0f440l1"`). +Sends an already-built AMY wire message, for example `"v0w0f440l1"`. ### `amy.panic()` -Stop all sound immediately. +Sends AMY's immediate stop command. -### Constants +### Signals + +- `backend_ready`: emitted when the selected backend is ready for messages. +- `backend_error(message)`: emitted when backend initialization or Android transport fails. -**Wave types:** `Amy.SINE`, `Amy.PULSE`, `Amy.SAW_DOWN`, `Amy.SAW_UP`, `Amy.TRIANGLE`, `Amy.NOISE`, `Amy.KS`, `Amy.PCM`, `Amy.ALGO`, `Amy.PARTIAL`, `Amy.WAVETABLE`, `Amy.CUSTOM`, `Amy.WAVE_OFF` +### Constants -**Filter types:** `Amy.FILTER_NONE`, `Amy.FILTER_LPF`, `Amy.FILTER_BPF`, `Amy.FILTER_HPF`, `Amy.FILTER_LPF24`, `Amy.FILTER_NOTCH`, `Amy.FILTER_PHASER` +Wave types include `Amy.SINE`, `Amy.PULSE`, `Amy.SAW_DOWN`, `Amy.SAW_UP`, `Amy.TRIANGLE`, `Amy.NOISE`, `Amy.KS`, `Amy.PCM`, `Amy.ALGO`, `Amy.PARTIAL`, `Amy.WAVETABLE`, `Amy.CUSTOM`, and `Amy.WAVE_OFF`. -**Envelope types:** `Amy.ENVELOPE_NORMAL`, `Amy.ENVELOPE_LINEAR`, `Amy.ENVELOPE_DX7`, `Amy.ENVELOPE_TRUE_EXPONENTIAL` +Filter types include `Amy.FILTER_NONE`, `Amy.FILTER_LPF`, `Amy.FILTER_BPF`, `Amy.FILTER_HPF`, `Amy.FILTER_LPF24`, `Amy.FILTER_NOTCH`, and `Amy.FILTER_PHASER`. diff --git a/godot/amy.gd b/godot/amy.gd index a59651a4..9ee1e249 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -3,7 +3,8 @@ extends Node ## AMY Synthesizer for Godot. ## ## High-level GDScript API that mirrors AMY's Python interface. -## Works on native (GDExtension) and web (WASM via JavaScriptBridge). +## Works on native (GDExtension), Android (private AMY service socket), and web +## (WASM via JavaScriptBridge). ## ## Usage: ## var amy := Amy.new() @@ -14,6 +15,9 @@ extends Node ## Or use wire protocol directly: ## amy.send_raw("v0w0f440l1") +signal backend_ready +signal backend_error(message: String) + # ============================================================ # Wave types # ============================================================ @@ -81,20 +85,31 @@ const ENVELOPE_TRUE_EXPONENTIAL: int = 3 @export var max_voices: int = 64 ## Maximum number of synths. @export var max_synths: int = 64 +## Log each successfully sent Android wire packet. Intended for diagnostics/tests. +@export var debug_wire: bool = false # ============================================================ -# Internals — audio bridge +# Internals — audio / transport bridge # ============================================================ var _synth: Node = null var _stream_player: AudioStreamPlayer = null var _playback: AudioStreamGeneratorPlayback = null var _started: bool = false var _is_web: bool = false +var _is_android: bool = false +# Deliberately leave Java wrappers as Variant. Typing the wrapped class as +# Object makes GDScript bind connect() to Object.connect(signal, callable) +# instead of dynamically dispatching to AmyClient.connect(Context). +var _amy_client +var _android_context func _ready() -> void: _is_web = OS.get_name() == "Web" + _is_android = OS.get_name() == "Android" if _is_web: _init_web() + elif _is_android: + _init_android() else: _init_native() @@ -103,7 +118,9 @@ func _init_native() -> void: _synth = ClassDB.instantiate(&"AmySynth") add_child(_synth) else: - push_warning("AmySynth GDExtension not loaded — audio disabled") + var message := "AmySynth GDExtension not loaded — audio disabled" + push_warning(message) + backend_error.emit(message) return # Apply config before starting @@ -132,6 +149,45 @@ func _init_native() -> void: _stream_player.play() _playback = _stream_player.get_stream_playback() as AudioStreamGeneratorPlayback _started = true + backend_ready.emit() + +func _init_android() -> void: + # The AAR owns AmyService lifecycle. Amy.gd is only an ordinary same-UID + # client of filesDir/amy.sock; it never starts or stops the service. + var runtime = Engine.get_singleton("AndroidRuntime") + if runtime == null: + _android_fail("AndroidRuntime unavailable") + return + + _android_context = runtime.getApplicationContext() + if _android_context == null: + _android_fail("Android application context unavailable") + return + + _amy_client = JavaClassWrapper.wrap("org.amy.audio.AmyClient") + if _amy_client == null: + _android_fail("AmyClient class unavailable") + return + if not _amy_client.has_java_method("connect") or not _amy_client.has_java_method("sendWire") or not _amy_client.has_java_method("close"): + _android_fail("AmyClient methods unavailable") + return + + # The service publishes amy.sock only after Oboe has delivered its first + # realtime callback, so a successful connect is also the readiness boundary. + for _attempt in range(200): + var rc: int = int(_amy_client.connect(_android_context)) + var exception = JavaClassWrapper.get_exception() + if exception != null: + _android_fail("AmyClient.connect exception: %s" % str(exception)) + return + if rc == 0: + _started = true + print("AMY Android connected to amy.sock") + backend_ready.emit() + return + await get_tree().create_timer(0.05).timeout + + _android_fail("Could not connect to amy.sock") func _init_web() -> void: # Pass config to JS bridge before AMY starts @@ -144,12 +200,15 @@ func _init_web() -> void: if ready: _started = true print("AMY web synth ready") + backend_ready.emit() return await get_tree().create_timer(0.1).timeout - push_warning("AMY web module failed to load after 10 s") + var message := "AMY web module failed to load after 10 s" + push_warning(message) + backend_error.emit(message) func _process(_delta: float) -> void: - if _started and not _is_web: + if _started and not _is_web and not _is_android: _fill_audio() func _fill_audio() -> void: @@ -162,9 +221,19 @@ func _fill_audio() -> void: _playback.push_buffer(buffer as PackedVector2Array) func _exit_tree() -> void: - if not _is_web and _synth: + if _is_android: + if _amy_client != null: + _amy_client.close() + _amy_client = null + _started = false + elif not _is_web and _synth: _synth.call("stop") +func _android_fail(message: String) -> void: + _started = false + push_error(message) + backend_error.emit(message) + # ============================================================ # Public API # ============================================================ @@ -202,7 +271,18 @@ func message(params: Dictionary) -> String: func send_raw(msg: String) -> void: if not _started or msg.is_empty(): return - if _is_web: + if _is_android: + var rc: int = int(_amy_client.sendWire(msg)) + var exception = JavaClassWrapper.get_exception() + if exception != null: + _android_fail("AmyClient.sendWire exception: %s" % str(exception)) + return + if rc != 0: + _android_fail("amy.sock send failed: %d" % rc) + return + if debug_wire: + print("AMY Android wire: %s" % msg) + elif _is_web: var safe: String = msg.replace("\\", "\\\\").replace("'", "\\'") JavaScriptBridge.eval("godot_amy_send('%s')" % safe) else: diff --git a/godot/android-hello-world/.gitignore b/godot/android-hello-world/.gitignore new file mode 100644 index 00000000..9beaa69b --- /dev/null +++ b/godot/android-hello-world/.gitignore @@ -0,0 +1,4 @@ +/amy.gd +/addons/amy_android/*.aar +/android/ +/build/ diff --git a/godot/android-hello-world/README.md b/godot/android-hello-world/README.md new file mode 100644 index 00000000..3da58260 --- /dev/null +++ b/godot/android-hello-world/README.md @@ -0,0 +1,72 @@ +# AMY Godot Android Hello World + +This example exercises the normal Godot `Amy` GDScript API on Android while AMY itself runs in the independent Android `:amy` service process. + +```text +Godot game code + | + | amy.send(Dictionary) + v +godot/amy.gd + | + | Dictionary -> ordinary AMY wire message + v +AmyClient (pure Java transport helper) + | + | LocalSocket / SOCK_SEQPACKET + v +/amy.sock + | + v +Android :amy service -> AMY -> Oboe/AAudio +``` + +The Godot application does not start or stop `AmyService`, does not compile AMY C source, and does not package the `AmySynth` GDExtension. The only AMY native implementation in the Android APK is `libamy_android.so` inside the service AAR. The AAR remains in the same APK/UID because `amy.sock` is intentionally app-private. + +## Prepare from this source checkout + +Requirements are the same as `android/README.md` plus a Godot 4 Android editor/export-template installation. + +From the repository root: + +```bash +bash godot/android-hello-world/prepare.sh +``` + +`prepare.sh` builds the Android service AARs, copies them into the example's export plugin, and copies the shared `godot/amy.gd` into the project. This source-development step is not a requirement for downstream Godot applications when a prebuilt service AAR is supplied: they only package the AAR and GDScript API. + +Open the project: + +```bash +godot --editor --path godot/android-hello-world +``` + +The included export plugin adds the matching debug or release AAR to Android exports. `Android ARM64` is the normal device preset. `Android CI x86_64` is deliberately transport/audio-only: it skips the optional Canvas UI so unrelated emulator/SwiftShader renderer limitations do not obscure the AMY socket/audio regression. + +## API boundary + +The example uses only the public GDScript API. Connect the readiness/error signals before adding the `Amy` node to the scene tree so an immediately ready backend cannot be missed: + +```gdscript +var amy: Amy + +func _ready() -> void: + amy = Amy.new() + amy.backend_ready.connect(_on_amy_ready) + amy.backend_error.connect(_on_amy_error) + add_child(amy) + +func _on_amy_ready() -> void: + amy.send({"osc": 0, "wave": Amy.SINE, "volume": 10.0}) + amy.send({"osc": 0, "note": 60, "vel": 1.0}) + amy.send({"osc": 0, "vel": 0.0}) + +func _on_amy_error(message: String) -> void: + push_error(message) +``` + +`Amy.message(Dictionary)` is the same wire generator used on the other Godot backends. On Android, `Amy.send()` hands that wire string to the pure-Java `AmyClient`, which sends one request per `SOCK_SEQPACKET` packet. + +For a normal project, copy/package the shared `amy.gd`, the `amy-service` AAR, and an Android export plugin equivalent to `addons/amy_android/`. Do **not** add `godot/amy.gdextension`, `godot/bin/`, or AMY C/C++ sources to an Android export. + +See `docs/godot.md` for platform differences and supported Android API surface. 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..e00be010 --- /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 into the Godot APK." +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 new file mode 100644 index 00000000..110aea5d --- /dev/null +++ b/godot/android-hello-world/export_presets.cfg @@ -0,0 +1,92 @@ +[preset.0] + +name="Android ARM64" +platform="Android" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/amy-godot-android-arm64.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=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="amy_android_ci_no_ui" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/amy-godot-android-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" +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_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..83c637e9 --- /dev/null +++ b/godot/android-hello-world/main.gd @@ -0,0 +1,110 @@ +extends Node + +const AmyApi = preload("res://amy.gd") + +var _status: Label +var _play_button: Button +var _amy +var _amy_error: String = "" +var _ci_no_ui: bool = false + +func _ready() -> void: + # The x86_64 emulator regression is intentionally transport/audio-only. The + # current SwiftShader GLES compatibility renderer cannot link Godot 4.7.2's + # canvas shader (it exposes 256 fragment uniform vectors; Godot requests 261). + # Device/ARM64 exports keep the normal interactive UI. + _ci_no_ui = OS.has_feature("amy_android_ci_no_ui") + if not _ci_no_ui: + _build_ui() + + if OS.get_name() != "Android": + _fail("Android export required") + return + + if _status != null: + _status.text = "Connecting Amy.gd to amy.sock..." + _amy = AmyApi.new() + _amy.debug_wire = true + _amy.backend_ready.connect(_on_amy_ready) + _amy.backend_error.connect(_on_amy_error) + add_child(_amy) + +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(680, 0) + column.alignment = BoxContainer.ALIGNMENT_CENTER + center.add_child(column) + + var title := Label.new() + title.text = "AMY + Godot Android" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 28) + 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 major scale" + _play_button.disabled = true + _play_button.pressed.connect(_on_play_pressed) + column.add_child(_play_button) + +func _on_amy_ready() -> void: + print("Godot Amy.gd Android backend ready") + if _status != null: + _status.text = "Connected to AMY through Amy.gd" + if _play_button != null: + _play_button.disabled = false + await _play_scale() + +func _on_amy_error(message: String) -> void: + _amy_error = message + _fail(message) + +func _on_play_pressed() -> void: + await _play_scale() + +func _send(params: Dictionary) -> bool: + _amy_error = "" + _amy.send(params) + return _amy_error.is_empty() + +func _play_scale() -> void: + if _play_button != null: + _play_button.disabled = true + if _status != null: + _status.text = "Playing C major scale through Amy.gd..." + + if not _send({"osc": 0, "wave": AmyApi.SINE, "volume": 10.0}): + return + await get_tree().create_timer(0.03).timeout + + for note in [60, 62, 64, 65, 67, 69, 71, 72]: + if not _send({"osc": 0, "note": note, "vel": 1.0}): + return + await get_tree().create_timer(0.35).timeout + if not _send({"osc": 0, "vel": 0.0}): + return + await get_tree().create_timer(0.08).timeout + + if _status != null: + _status.text = "C scale complete" + if _play_button != null: + _play_button.disabled = false + print("Godot Amy.gd C scale complete") + +func _fail(message: String) -> void: + push_error(message) + print("Godot Amy.gd 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..7819dd54 --- /dev/null +++ b/godot/android-hello-world/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource path="res://main.gd" type="Script" id="1_main"] + +[node name="AmyGodotAndroidHello" type="Node"] +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..5c91d97d --- /dev/null +++ b/godot/android-hello-world/prepare.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${HERE}/../.." && pwd)" +GRADLE_BIN="${GRADLE_BIN:-gradle}" + +# Android intentionally packages the independent AMY service AAR. The Godot +# application itself does not build or load the AmySynth GDExtension. +( + cd "${ROOT}/android" + "${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" + +# Exercise the exact shared high-level API used by normal Godot projects. +cp "${ROOT}/godot/amy.gd" "${HERE}/amy.gd" + +printf 'Prepared AMY Android service AARs and shared amy.gd in %s\n' "${HERE}" diff --git a/godot/android-hello-world/project.godot b/godot/android-hello-world/project.godot new file mode 100644 index 00000000..9dfdeffa --- /dev/null +++ b/godot/android-hello-world/project.godot @@ -0,0 +1,20 @@ +; Engine configuration file. +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 + +[editor_plugins] +enabled=PackedStringArray("res://addons/amy_android/plugin.cfg") + +[rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/vram_compression/import_etc2_astc=true