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