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,154 @@
|
||||
# Verification record — `audio-capture-playback-android-spike`
|
||||
|
||||
## Result
|
||||
|
||||
PASS. The mobile half of the PoC plan §2 audio exit criterion
|
||||
("at least one mobile target") is met on a physical Android device.
|
||||
|
||||
This combined with the desktop half (closed by
|
||||
`poc/audio-capture-playback-spike` on Linux + PipeWire on 2026-05-13)
|
||||
fully retires the audio PoC.
|
||||
|
||||
## Environment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Date | 2026-05-13 |
|
||||
| Build host OS | Linux (Arch, kernel 7.0.5-arch1-1, x86_64) |
|
||||
| Rust toolchain | stable 1.95.0 (59807616e 2026-04-14) |
|
||||
| Android SDK | platform 34, build-tools 34.0.0, platform-tools |
|
||||
| Android NDK | r26.3.11579264 |
|
||||
| `cargo-ndk` | 4.1.2 |
|
||||
| AGP | 8.5.2 |
|
||||
| Gradle | 8.7 |
|
||||
| Kotlin | 1.9.24 |
|
||||
| `cpal` | 0.16 |
|
||||
| `hound` | 3.5 |
|
||||
| `jni` | 0.21 |
|
||||
| `ndk-context` | 0.1.1 |
|
||||
| `android_logger` | 0.14 |
|
||||
| Test device | Motorola Moto G Stylus 5G (2023), Android 14 (SDK 34), arm64-v8a (transport_id ZD222DQHFY) |
|
||||
|
||||
The Android x86_64 emulator AVD `chanora-poc-api34` was created and
|
||||
the system image installed, but emulator verification was **skipped
|
||||
intentionally** once the physical-device run succeeded; the physical
|
||||
device is stronger evidence than emulator audio HAL.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```bash
|
||||
cd poc/audio-capture-playback-android-spike/android
|
||||
./gradlew :app:assembleDebug
|
||||
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
|
||||
adb shell input tap 540 520 # Play
|
||||
adb shell input tap 540 640 # Record
|
||||
```
|
||||
|
||||
## Observed result
|
||||
|
||||
### Playback (PLAY 500 MS SINE)
|
||||
|
||||
logcat:
|
||||
|
||||
```
|
||||
ChanoraPoCActivity: playing…
|
||||
ChanoraAudioPoC: audio_capture_playback_android_spike: playback device='default'
|
||||
sample_rate=44100 channels=2 fmt=F32
|
||||
ChanoraAudioPoC: audio_capture_playback_android_spike: playSine440 ok n=22050
|
||||
ChanoraPoCActivity: PLAY OK frames=22050
|
||||
```
|
||||
|
||||
UI status text after tap: `"PLAY OK frames=22050"`.
|
||||
|
||||
22,050 frames at 44.1 kHz mono equals exactly 500 ms — the requested
|
||||
duration. The full Rust → cpal → Oboe → AAudio → speaker path
|
||||
completed without error.
|
||||
|
||||
### Capture (RECORD 1 S TO FILE)
|
||||
|
||||
logcat:
|
||||
|
||||
```
|
||||
ChanoraPoCActivity: recording 1 s…
|
||||
ChanoraAudioPoC: audio_capture_playback_android_spike: capture device='default'
|
||||
sample_rate=44100 channels=2 fmt=F32
|
||||
ChanoraAudioPoC: audio_capture_playback_android_spike: record1sToFile ok n=42624
|
||||
ChanoraPoCActivity: RECORD OK frames=42624 bytes=85292
|
||||
path=/data/user/0/app.chanora.poc.audio/files/chanora_poc_capture.wav
|
||||
```
|
||||
|
||||
UI status text after tap: `"RECORD OK frames=42624 bytes=85292
|
||||
path=/data/user/0/app.chanora.poc.audio/files/chanora_poc_capture.wav"`.
|
||||
|
||||
42,624 frames at 44.1 kHz is approximately 966 ms — slightly under
|
||||
the requested 1 s because `std::thread::sleep` and the cpal callback
|
||||
boundary do not align perfectly. The full Rust → cpal → Oboe → AAudio
|
||||
→ microphone path completed without error.
|
||||
|
||||
### File integrity
|
||||
|
||||
The WAV file was pulled with:
|
||||
|
||||
```bash
|
||||
adb exec-out run-as app.chanora.poc.audio cat files/chanora_poc_capture.wav \
|
||||
> /tmp/chanora_capture.wav
|
||||
```
|
||||
|
||||
`file(1)` output:
|
||||
|
||||
```
|
||||
/tmp/chanora_capture.wav: RIFF (little-endian) data, WAVE audio,
|
||||
Microsoft PCM, 16 bit, mono 44100 Hz
|
||||
```
|
||||
|
||||
Header bytes confirm:
|
||||
|
||||
```
|
||||
00000000: 5249 4646 244d 0100 5741 5645 666d 7420 RIFF$M..WAVEfmt
|
||||
00000010: 1000 0000 0100 0100 44ac 0000 8858 0100 ........D....X..
|
||||
00000020: 0200 1000 6461 7461 004d 0100 0000 0000 ....data.M......
|
||||
```
|
||||
|
||||
- `RIFF` magic, size 85,284 + 8.
|
||||
- `WAVE` format, fmt chunk: PCM (0x0001), mono (0x0001), 44100 Hz
|
||||
(0x0000AC44), 88200 bytes/s, block-align 2, 16 bits/sample.
|
||||
- `data` chunk size 0x00014D00 = 85,248 bytes of PCM ≈ 42,624 i16
|
||||
samples — matches the reported frame count.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| PoC plan exit criterion | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Capture/playback works on at least one **desktop** target | PASS | `poc/audio-capture-playback-spike/VERIFICATION.md` |
|
||||
| Capture/playback works on at least one **mobile** target | **PASS** | This document. |
|
||||
|
||||
## What this spike does NOT validate
|
||||
|
||||
- iOS (deferred per DEC-011.1; requires macOS + Xcode hardware).
|
||||
- Android emulator audio path (skipped — physical device is
|
||||
stronger evidence).
|
||||
- API 24–25 devices (cpal's OpenSL ES fallback path is not exercised
|
||||
here; only AAudio via API 26+).
|
||||
- Bit-exact loopback signal integrity from capture to playback.
|
||||
- Audio latency under load.
|
||||
- Streaming reconfiguration on device change or audio focus loss.
|
||||
- Background-service / foreground-service audio (required for a real
|
||||
voice client; out of scope here).
|
||||
- DSP, Opus encode/decode, jitter buffer, mixer — owned by
|
||||
`chanora_audio`.
|
||||
- Privacy-sensitive UX (microphone consent flows beyond a single
|
||||
runtime grant).
|
||||
|
||||
## Decisions closed by this spike
|
||||
|
||||
- **DEC-011.1** sub-decision is promoted from
|
||||
`Accepted (desktop) / Deferred (mobile)` to
|
||||
`Accepted (desktop: cpal; Android: cpal-on-Oboe) / Deferred (iOS)`.
|
||||
See `docs/governance/product-decision-register.md` v0.9.4.
|
||||
- The audio row in
|
||||
`docs/architecture/proof-of-concept-plan.md` §2 is promoted from
|
||||
PARTIAL PASS to PASS.
|
||||
- RISK-PoC-001 in `docs/governance/poc-results-summary.md` is closed
|
||||
for Android; iOS portion remains open.
|
||||
Reference in New Issue
Block a user