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.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# 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 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.
|
||||
Reference in New Issue
Block a user