Files
chanora/poc/audio-capture-playback-android-spike
EdisonJwa ec21a880d2 feat(poc/audio): add Android mobile audio spike
Closes the mobile half of the PoC plan §2 audio exit criterion
left open by poc/audio-capture-playback-spike. The desktop and
mobile halves together fully retire the audio PoC.

Stack:
  Kotlin (MainActivity) → JNI → Rust cdylib
    → cpal 0.16 → Oboe (AAudio / OpenSL ES) → Android audio HAL

Layout:
  rust crate (src/lib.rs)   — JNI_OnLoad, initContext,
                              playSine440, record1sToFile;
                              panic-catching at JNI boundary;
                              android_logger → logcat
  android/ (Gradle 8.7,     — minSdk 24, compileSdk 34, AGP 8.5.2.
   AGP 8.5.2, Kotlin 1.9.24)  cargoBuildRust task wraps cargo-ndk
                              -P 26 -t <abi> for all four ABIs;
                              wired into preBuild so AGP picks up
                              the produced .so files.

Verified on 2026-05-13 on a physical Motorola Moto G Stylus 5G
(2023), Android 14 SDK 34 arm64-v8a:
  - Playback: 500 ms 440 Hz mono sine, 22,050 frames emitted at
    44.1 kHz through cpal/Oboe/AAudio/device speaker.
  - Capture: 1 s from default input, 42,624 frames written to
    /data/data/app.chanora.poc.audio/files/chanora_poc_capture.wav.
    File pulled via 'adb exec-out run-as ... cat' and confirmed
    by file(1) as 'RIFF (little-endian) data, WAVE audio,
    Microsoft PCM, 16 bit, mono 44100 Hz'. Header bytes
    cross-checked against the reported frame count.

Notes:
  - cpal links libaaudio (introduced API 26), so cargo-ndk targets
    API 26 via -P 26 while the Android module's minSdk stays at 24
    (DEC-004). API 24/25 devices would fall back to OpenSL ES at
    runtime; not exercised here.
  - JNI panic safety: every JNI entry point wraps its body in
    std::panic::catch_unwind and a tracing panic hook routes
    panic messages to logcat under tag 'ChanoraAudioPoC'. Without
    this, cpal panicking inside an extern "system" function would
    abort the process.
  - The emulator AVD chanora-poc-api34 and its system image were
    installed during Phase 0 but emulator verification was skipped
    once the physical-device run succeeded. Real-device evidence
    is stronger.

Surfaced finding: DEC-011.1 mobile half promoted from Deferred to
Accepted for Android in the same docs commit; iOS remains
explicitly Deferred (requires macOS + Xcode hardware).

Authority: PoC plan §2, DEC-011, DEC-011.1.
Not product code; not promoted into chanora_audio.
2026-05-14 18:24:53 +08:00
..

Android Audio Capture / Playback Spike

Chanora proof-of-concept. Not product code.

Field Value
PoC name audio-capture-playback-android-spike
PoC plan docs/architecture/proof-of-concept-plan.md §2
Purpose Close the mobile half of the audio capture/playback PoC exit criterion
Exit criterion (mobile half) "Capture/playback works on at least one mobile target"
Authority DEC-011 (platform-native first), DEC-011.1 (Android crate = cpal-on-Oboe)

What it proves

The desktop half of the audio PoC was closed by poc/audio-capture-playback-spike on Linux + PipeWire. This spike closes the mobile-Android half.

End-to-end path verified on a physical device:

Kotlin (MainActivity)
  └─ JNI →  Rust cdylib (audio_capture_playback_android_spike)
              └─ cpal 0.16
                  └─ Oboe / AAudio
                      └─ Android audio HAL
                          └─ device speaker / microphone

Specifically:

  • Playback: a 440 Hz mono sine, 500 ms, driven by Rust through cpal's Oboe backend, played out through the device's default output. 22,050 frames at 44.1 kHz emitted (exactly as expected).
  • Capture: 1 s of stereo F32 audio from the default input device, down-mixed to mono i16 PCM and written to app-private storage as a valid 85,292-byte WAV file (44.1 kHz, mono, 16-bit). The file was pulled via adb exec-out run-as ... cat and confirmed with file(1) to be RIFF/WAVE.

Layout

audio-capture-playback-android-spike/
  src/
    lib.rs                 # JNI entry points + JNI_OnLoad + initContext +
                           # play_sine_inner + record_inner + panic-catching
                           # boilerplate
  android/
    settings.gradle.kts
    build.gradle.kts
    gradle.properties
    gradlew
    gradle/wrapper/        # gradle-wrapper.{jar,properties}
    app/
      build.gradle.kts     # AGP + cargoBuildRust task
      src/main/
        AndroidManifest.xml
        java/app/chanora/poc/audio/
          MainActivity.kt
          NativeAudio.kt
  Cargo.toml
  README.md
  VERIFICATION.md

Why a separate spike

The PoC plan lists a single audio-capture-playback-spike; the mobile and desktop halves share the cpal abstraction but have totally different build toolchains (Cargo only vs. Gradle + cargo-ndk + JNI + Kotlin app shell). Splitting keeps each spike's VERIFICATION.md independently provable, while both close one half of the same PoC plan entry.

Reproduce

Requires:

  • Rust stable (developed against 1.95) with the Android targets installed: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
  • Android SDK with platform 34, build-tools 34.0.0.
  • Android NDK r26.x (developed against 26.3.11579264) at $ANDROID_NDK_HOME.
  • cargo-ndk 4.x (cargo install cargo-ndk).
  • An Android device on USB (API 24+) or an emulator AVD on API 26+.
# 1. Build the APK. AGP's preBuild depends on the custom cargoBuildRust
#    task that invokes `cargo ndk -P 26 -t <abi> ... build --release`
#    for all four enabled ABIs and copies the .so into jniLibs/.
cd android
./gradlew :app:assembleDebug

# 2. Install and run on the connected device.
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell pm grant app.chanora.poc.audio android.permission.RECORD_AUDIO
adb shell am start -n app.chanora.poc.audio/.MainActivity

# 3. Drive the buttons (or just tap them on-device).
adb shell input tap 540 520   # "PLAY 500 MS SINE"
adb shell input tap 540 640   # "RECORD 1 S TO FILE"
adb logcat -d -s ChanoraAudioPoC ChanoraPoCActivity

The captured WAV lives at /data/data/app.chanora.poc.audio/files/chanora_poc_capture.wav and can be pulled with adb exec-out run-as app.chanora.poc.audio cat files/chanora_poc_capture.wav > capture.wav.

NDK platform note

cpal links libaaudio which was introduced at API 26. The Gradle task therefore passes -P 26 to cargo-ndk so the NDK toolchain targets API 26 at compile time. The Android module itself stays at minSdk = 24 (matches DEC-004). On API 2425 devices the AAudio path is dynamically unavailable; cpal/Oboe falls back to OpenSL ES at runtime. Not exercised in this spike.

Scope boundaries

  • No DSP. HPF / NS / AEC / AGC, Opus, jitter buffer, mixer all belong to chanora_audio and are out of scope.
  • No bit-exact loopback assertion. The spike proves the streams open and frames flow; it does not verify signal integrity from capture to playback.
  • No latency measurement.
  • No device-rotation / lifecycle correctness. The cpal streams are owned by short-lived Thread { … } blocks and dropped when the call returns. Production code in chanora_audio needs proper lifecycle handling.
  • No iOS verification. Deferred per docs/governance/product-decision-register.md DEC-011.1.

Verification log

See VERIFICATION.md in this directory.