diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 53d7fcc3..6c5bdeac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,7 +20,7 @@ jobs: - name: Build and run private Unix socket test run: bash tests/run_amy_unix_socket_test.sh - android-aar: + android-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -45,9 +45,9 @@ jobs: with: gradle-version: "8.13" - - name: Build AMY Android AAR + - name: Build AMY Android AAR and hello-world APK working-directory: android - run: gradle :amy-service:assembleDebug --stacktrace + run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace - name: Upload AMY Android AAR uses: actions/upload-artifact@v4 @@ -55,3 +55,55 @@ jobs: name: amy-service-debug-aar path: android/amy-service/build/outputs/aar/amy-service-debug.aar if-no-files-found: error + + - name: Upload AMY hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-hello-world-debug-apk + path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + if-no-files-found: error + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Emulator end-to-end smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + 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 diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts index da4c528e..c0c0e37e 100644 --- a/android/amy-service/build.gradle.kts +++ b/android/amy-service/build.gradle.kts @@ -10,8 +10,11 @@ android { defaultConfig { minSdk = 26 + // arm64-v8a is the production target. x86_64 is included on this + // hello-world branch so CI can run the same AMY/Oboe service in the + // hardware-accelerated Android emulator. ndk { - abiFilters += listOf("arm64-v8a") + abiFilters += listOf("arm64-v8a", "x86_64") } externalNativeBuild { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 83ccca12..a2cc8b72 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,4 @@ plugins { + id("com.android.application") version "8.13.2" apply false id("com.android.library") version "8.13.2" apply false } diff --git a/android/hello-world/README.md b/android/hello-world/README.md new file mode 100644 index 00000000..ee44a05a --- /dev/null +++ b/android/hello-world/README.md @@ -0,0 +1,51 @@ +# AMY Android Hello World + +Minimal Android application proving the generic AMY Android service end to end. + +On launch it: + +1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; +2. 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 `V2.0`; +4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; +5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +6. shows `C scale complete` when all packets have been sent. + +The note path does not call AMY through JNI. JNI is used only for the Android client-side Unix socket syscalls because the Java `LocalSocket` API is stream-oriented. The synth process receives ordinary AMY wire packets exactly as another AMY wire transport would. + +The generic AMY Android service also logs Oboe's actual output device ID and resolves it through `AudioDeviceInfo`, so device logs identify routes such as `BUILTIN_SPEAKER`, `BUILTIN_EARPIECE`, Bluetooth, wired headphones, or USB where Android exposes a matching device. + +## Wire sequence + +Setup: + +```text +v0w0V2.0Z +``` + +`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. + +Notes use MIDI note numbers and velocity, e.g. middle C: + +```text +v0n60l1Z +v0l0Z +``` + +The complete scale is MIDI notes `60, 62, 64, 65, 67, 69, 71, 72`. + +## Build + +From `android/`: + +```bash +gradle :hello-world:assembleDebug +``` + +APK: + +```text +hello-world/build/outputs/apk/debug/hello-world-debug.apk +``` + +The CI Android emulator smoke test 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. diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts new file mode 100644 index 00000000..2e77a882 --- /dev/null +++ b/android/hello-world/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "org.amy.hello" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + applicationId = "org.amy.hello" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + cppFlags += "-std=c++17" + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } +} + +dependencies { + implementation(project(":amy-service")) +} diff --git a/android/hello-world/src/main/AndroidManifest.xml b/android/hello-world/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c3f384c --- /dev/null +++ b/android/hello-world/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/hello-world/src/main/cpp/CMakeLists.txt b/android/hello-world/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1915bf81 --- /dev/null +++ b/android/hello-world/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_hello_client LANGUAGES CXX) + +add_library(amy_hello_client SHARED amy_hello_client.cpp) + +target_compile_features(amy_hello_client PRIVATE cxx_std_17) +target_compile_options(amy_hello_client PRIVATE -Wall -Wextra -Werror) +target_link_libraries(amy_hello_client PRIVATE log) diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp new file mode 100644 index 00000000..df83a9e6 --- /dev/null +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -0,0 +1,101 @@ +#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. V is AMY's global output gain, not an + // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. + // Every packet is an ordinary AMY wire command sent through amy.sock. + int rc = send_wire(fd, "v0w0V2.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 new file mode 100644 index 00000000..e1b5eb96 --- /dev/null +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -0,0 +1,92 @@ +package org.amy.hello; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.amy.audio.AmyService; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + private TextView status; + private Button playButton; + + static { + System.loadLibrary("amy_hello_client"); + } + + private static native int nativePlayCScale(String socketPath); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(48, 48, 48, 48); + + TextView title = new TextView(this); + title.setText("AMY Hello World"); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + status = new TextView(this); + status.setText("Starting AMY..."); + status.setTextSize(18); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + statusParams.setMargins(0, 40, 0, 40); + root.addView(status, statusParams); + + playButton = new Button(this); + playButton.setText("Play C scale"); + playButton.setOnClickListener(v -> playScale()); + root.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + setContentView(root); + + AmyService.start(this); + if (state == null) { + playScale(); + } else { + status.setText("AMY ready"); + } + } + + private void playScale() { + playButton.setEnabled(false); + status.setText("Playing C major scale..."); + String socketPath = new File(getFilesDir(), AmyService.DEFAULT_SOCKET_NAME) + .getAbsolutePath(); + + EXECUTOR.execute(() -> { + int rc = nativePlayCScale(socketPath); + runOnUiThread(() -> { + if (isDestroyed()) return; + if (rc == 0) { + status.setText("C scale complete"); + } else { + status.setText("AMY/socket error: " + rc); + } + playButton.setEnabled(true); + }); + }); + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 84732c83..d17aab50 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "amy-android" include(":amy-service") +include(":hello-world")