Files
chanora/poc/audio-capture-playback-android-spike/README.md
T
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

139 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../../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`](../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
```text
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+.
```bash
# 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.