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
109 changes: 109 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Android AMY

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

permissions:
contents: read

jobs:
socket-transport:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@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: 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 logcat -c
adb shell am start -W -n org.amy.hello/.MainActivity
sleep 10
adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log
test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1
grep -q 'AMY output route: deviceId=' /tmp/amy-first.log
test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1
! grep -q 'C scale failed' /tmp/amy-first.log
grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log
test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8
grep -q 'wire: v0n60l1Z' /tmp/amy-first.log
grep -q 'wire: v0n72l1Z' /tmp/amy-first.log
adb uninstall org.amy.hello
adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk
adb logcat -c
adb shell am start -W -n org.amy.hello/.MainActivity
sleep 10
adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log
test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1
grep -q 'AMY output route: deviceId=' /tmp/amy-second.log
test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1
! grep -q 'C scale failed' /tmp/amy-second.log
grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log
test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8
grep -q 'wire: v0n60l1Z' /tmp/amy-second.log
grep -q 'wire: v0n72l1Z' /tmp/amy-second.log
177 changes: 177 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# 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 intended to be embedded by an Android application that wants to use
AMY as its local synth engine. The client can be written with the Android SDK,
Kotlin/Java, native code, Qt, another framework, or any other environment able
to start the service and use an Android Unix-domain `SOCK_SEQPACKET` socket.
AMY itself has no dependency on the client UI framework.

The service declaration uses `android:exported="false"` and
`android:process=":amy"`. 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. 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 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`.

The client-facing architecture is therefore deliberately transport-oriented:

```text
client application -> amy.sock -> AMY/Oboe service
```

A client does not need AMY-specific JNI bindings. It only needs to start the
service and exchange AMY wire packets over the private socket.

## Socket client contract

Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet.
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. 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 `<filesDir>/amy.sock`
until the service publishes its ready socket;
5. send one ordinary AMY wire message per packet;
6. optionally receive response packets over the same bidirectional socket;
7. stop and reconnect cleanly across Android application/audio lifecycle
events.

The transport deliberately does not prescribe a programming language or UI
framework. A minimal example client is provided separately by the Android
hello-world application.

## Building the AAR

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. The earlier `.github/workflows/android-unix-socket.yml`
continues to isolate the transport regression itself.

## 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.
48 changes: 48 additions & 0 deletions android/amy-service/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
}

android {
namespace = "org.amy.audio"
compileSdk = 36
ndkVersion = "27.0.12077973"

defaultConfig {
minSdk = 26

// arm64-v8a is the production target. x86_64 is included on this
// hello-world branch so CI can run the same AMY/Oboe service in the
// hardware-accelerated Android emulator.
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}

externalNativeBuild {
cmake {
arguments += "-DANDROID_STL=c++_shared"
cppFlags += "-std=c++17"
}
}
}

buildFeatures {
prefab = true
}

externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}

packaging {
jniLibs {
useLegacyPackaging = false
}
}
}

dependencies {
implementation("com.google.oboe:oboe:1.10.0")
}
10 changes: 10 additions & 0 deletions android/amy-service/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="org.amy.audio.AmyService"
android:exported="false"
android:process=":amy"
android:stopWithTask="true" />
</application>
</manifest>
Loading
Loading