# 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 ... 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 24–25 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.