Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/android-unix-socket.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Android Unix socket transport

on:
pull_request:
paths:
- 'src/amy_unix_socket.c'
- 'src/amy_unix_socket.h'
- 'tests/test_amy_unix_socket.c'
- 'tests/run_amy_unix_socket_test.sh'
- '.github/workflows/android-unix-socket.yml'

permissions:
contents: read

jobs:
linux-socket-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Compile and run private Unix socket transport test
run: bash tests/run_amy_unix_socket_test.sh
148 changes: 148 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
name: Android AMY

on:
pull_request:
paths:
- "android/**"
- "src/**"
- "tests/test_amy_unix_socket.c"
- "tests/run_amy_unix_socket_test.sh"
- "tests/check_android_audio_capture.py"
- ".github/workflows/android.yml"

permissions:
contents: read

jobs:
socket-transport:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Build and run private Unix socket test
run: bash tests/run_amy_unix_socket_test.sh

android-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- uses: android-actions/setup-android@v3

- name: Install Android SDK components
run: |
yes | sdkmanager --licenses >/dev/null
sdkmanager \
"platforms;android-36" \
"build-tools;35.0.0" \
"ndk;27.0.12077973" \
"cmake;3.22.1"

- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.13"

- name: Build AMY Android AAR and hello-world APK
working-directory: android
run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace

- name: Verify transport-only hello-world packaging
run: |
APK=android/hello-world/build/outputs/apk/debug/hello-world-debug.apk
test -s "$APK"
! unzip -l "$APK" | grep -q 'libamy_hello_client.so'
test ! -e android/hello-world/src/main/cpp

- name: Upload AMY Android AAR
uses: actions/upload-artifact@v4
with:
name: amy-service-debug-aar
path: android/amy-service/build/outputs/aar/amy-service-debug.aar
if-no-files-found: error

- name: Upload AMY hello-world APK
uses: actions/upload-artifact@v4
with:
name: amy-hello-world-debug-apk
path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk
if-no-files-found: error

- name: Enable KVM for Android emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Emulator end-to-end smoke test
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
profile: pixel_2
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim
script: |
adb uninstall org.amy.hello >/dev/null 2>&1 || true
adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk
adb shell run-as org.amy.hello mkdir -p files
adb shell run-as org.amy.hello touch files/amy-audio-capture.enable
adb logcat -c
adb shell am start -W -n org.amy.hello/.MainActivity
sleep 10
adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log
test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1
grep -q 'AMY output route: deviceId=' /tmp/amy-first.log
grep -q 'connected to amy.sock' /tmp/amy-first.log
test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1
! grep -q 'C scale failed' /tmp/amy-first.log
grep -q 'wire: v0w0V10.0Z' /tmp/amy-first.log
test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8
grep -q 'wire: v0n60l1Z' /tmp/amy-first.log
grep -q 'wire: v0n72l1Z' /tmp/amy-first.log
grep -q 'Audio capture complete:' /tmp/amy-first.log

adb uninstall org.amy.hello
adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk
adb shell run-as org.amy.hello mkdir -p files
adb shell run-as org.amy.hello touch files/amy-audio-capture.enable
adb logcat -c
adb shell am start -W -n org.amy.hello/.MainActivity
sleep 10
adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log
test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1
grep -q 'AMY output route: deviceId=' /tmp/amy-second.log
grep -q 'connected to amy.sock' /tmp/amy-second.log
test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1
! grep -q 'C scale failed' /tmp/amy-second.log
grep -q 'wire: v0w0V10.0Z' /tmp/amy-second.log
test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8
grep -q 'wire: v0n60l1Z' /tmp/amy-second.log
grep -q 'wire: v0n72l1Z' /tmp/amy-second.log
grep -q 'Audio capture complete:' /tmp/amy-second.log

mkdir -p android/audio-capture
adb exec-out run-as org.amy.hello cat files/amy-render.wav > android/audio-capture/amy-render.wav
adb exec-out run-as org.amy.hello cat files/amy-oboe.wav > android/audio-capture/amy-oboe.wav
adb exec-out run-as org.amy.hello cat files/amy-audio-levels.txt > android/audio-capture/amy-audio-levels.txt
test -s android/audio-capture/amy-render.wav
test -s android/audio-capture/amy-oboe.wav
test -s android/audio-capture/amy-audio-levels.txt
cat android/audio-capture/amy-audio-levels.txt

- name: Analyze captured AMY and Oboe audio levels
run: |
python3 tests/check_android_audio_capture.py \
android/audio-capture/amy-render.wav \
android/audio-capture/amy-oboe.wav

- name: Upload Android audio captures
uses: actions/upload-artifact@v4
with:
name: amy-android-audio-capture
path: android/audio-capture/
if-no-files-found: error
180 changes: 180 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# AMY Android Oboe service

This directory builds a generic Android AAR that hosts AMY in an unexported
`:amy` service process. The service owns Oboe/AAudio output and receives native
AMY wire messages through the private pathname Unix transport implemented by
`src/amy_unix_socket.[ch]`.

```text
Android client process
|
| AF_UNIX / SOCK_SEQPACKET
| <app filesDir>/amy.sock
| one AMY wire message per packet
v
Android :amy service process
|
+-- amy_unix_socket receiver thread
+-- fixed 64-packet SPSC queue
+-- AMY C engine
+-- Oboe low-latency callback
|
v
AAudio
```

The AAR is 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 `<Context.getFilesDir()>/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.

## Audio profile

The Android native build uses AMY's existing 48 kHz / 128-frame build profile
and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend.

Oboe requests:

- stereo signed 16-bit output
- 48 kHz
- `PerformanceMode::LowLatency`
- `SharingMode::Exclusive`
- callback-driven output

The callback size is not assumed to equal 128 frames. The native adapter keeps
only the unconsumed tail of the current AMY block and calls
`amy_simple_fill_buffer()` exactly when another AMY block is required. It does
not add an extra 128-frame output ring.

Before each new AMY block the callback drains up to 64 already-queued socket
packets and passes them to `amy_add_message()`. The socket thread itself never
calls AMY and never participates in audio rendering.

AMY is started with its internal platform audio disabled and with AMY rendering
owned by the Oboe callback thread. The current Android build configuration
reserves 16 Karplus-Strong oscillators.

## JNI boundary

JNI 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:

```text
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.

## Socket client contract

Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet.
For example the payload of three consecutive packets may be:

```text
K28i2Z
n60l1i2Z
n60l0i2Z
```

Do not add stream framing or depend on newline boundaries. Packet boundaries
are preserved by `SOCK_SEQPACKET`.

The pathname also serves as the engine readiness boundary. `amy.sock` is not
created until Oboe has started and the realtime audio callback has executed at
least once. A client may therefore retry `connect()` while the service starts;
once `connect()` succeeds it may begin sending AMY wire packets immediately.
No fixed Android-startup sleep is required.

The socket is bidirectional. The Android engine currently consumes ordinary AMY
wire commands; the existing `amy_unix_socket_send()` path is ready for compact
introspection/status replies when that functionality is integrated.

## Client integration

A client application needs to:

1. package the `amy-service` AAR/module in the Android application;
2. obtain the application's actual private files directory rather than
hard-code `/data/user/...`;
3. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `<filesDir>/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.

## Building the AAR

Requirements used by CI:

- JDK 17
- Android SDK platform 36
- Android NDK 27.0.12077973
- CMake 3.22.1
- Gradle 8.13
- Android Gradle Plugin 8.13.2
- Oboe 1.10.0 (Prefab dependency)

From the repository root:

```bash
cd android
gradle :amy-service:assembleDebug
```

The production Android service build targets `arm64-v8a`. Output is below:

```text
android/amy-service/build/outputs/aar/
```

## Tests

The private socket regression test is:

```bash
bash tests/run_amy_unix_socket_test.sh
```

It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior,
oversized-packet rejection, cleanup, and protection against deleting an
existing non-socket path.

`.github/workflows/android.yml` runs that regression plus a complete Android
AAR/NDK/Oboe build and emulator end-to-end test. The emulator arms its own
one-shot audio-capture marker before starting the client; the hello-world
application itself remains transport-only.

## Hardware-test items

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.
Loading
Loading