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

5.2 KiB
Raw Blame History

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

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:

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 2425 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.