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:
EdisonJwa
2026-05-14 18:24:53 +08:00
parent eca93a141e
commit ec21a880d2
15 changed files with 2278 additions and 0 deletions
+929
View File
@@ -0,0 +1,929 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "audio_capture_playback_android_spike"
version = "0.1.0"
dependencies = [
"android_logger",
"cpal",
"hound",
"jni",
"log",
"ndk-context",
"thiserror 2.0.18",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "coreaudio-rs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
dependencies = [
"bitflags 1.3.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
dependencies = [
"alsa",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
"objc2",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "js-sys"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.11.1",
"libc",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.11.1",
"objc2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"objc2",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
@@ -0,0 +1,32 @@
[package]
name = "audio_capture_playback_android_spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: Android mobile half of the audio capture/playback spike. cdylib loaded by the Kotlin app via JNI; cpal targets the Oboe backend on Android."
# Not product code. See docs/architecture/proof-of-concept-plan.md §4.
# Closes the mobile half of the PoC audio exit criterion left open by
# poc/audio-capture-playback-spike on Linux desktop.
[lib]
name = "audio_capture_playback_android_spike"
crate-type = ["cdylib"]
path = "src/lib.rs"
[dependencies]
# cpal automatically selects the Oboe backend on Android — platform-native
# per DEC-011. cpal links the Oboe native library through ndk-glue.
cpal = "0.16"
hound = "3"
jni = { version = "0.21", default-features = false }
thiserror = "2"
# Android-specific logging: route `log` macros to logcat.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
log = "0.4"
ndk-context = "0.1"
# Required by cpal at build time on Android.
[target.'cfg(target_os = "android")'.build-dependencies]
@@ -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 2425 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.
@@ -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 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.
@@ -0,0 +1,124 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "app.chanora.poc.audio"
compileSdk = 34
defaultConfig {
applicationId = "app.chanora.poc.audio"
minSdk = 24 // matches DEC-004
targetSdk = 34
versionCode = 1
versionName = "0.1.0-poc"
ndk {
// Keep all four common ABIs so the same APK works on both
// the x86_64 emulator and a physical arm64 device.
abiFilters += listOf("x86_64", "arm64-v8a", "armeabi-v7a", "x86")
}
}
sourceSets {
getByName("main") {
// cargo-ndk drops .so files here.
jniLibs.srcDirs("src/main/jniLibs")
}
}
buildTypes {
getByName("debug") {
isMinifyEnabled = false
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
// Avoid duplicate-resource conflicts from native libs if any
// future deps bring them in.
resources.excludes += setOf("META-INF/AL2.0", "META-INF/LGPL2.1")
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
}
// ---------------------------------------------------------------------
// Rust cdylib build via cargo-ndk.
//
// Each ABI maps to a Rust target triple; cargo-ndk handles linker
// selection. The task copies the produced .so into the per-ABI
// jniLibs directory expected by Android Gradle Plugin.
// ---------------------------------------------------------------------
val rustCrateDir = rootProject.projectDir.parentFile // ../ -> the spike root
val rustTargetDir = file("$rustCrateDir/target")
val jniLibsDir = file("$projectDir/src/main/jniLibs")
val cargoBuildRust = tasks.register("cargoBuildRust") {
group = "build"
description = "Builds the Rust cdylib for all enabled Android ABIs via cargo ndk."
val abis = listOf(
Triple("x86_64", "x86_64-linux-android", "x86_64"),
Triple("arm64-v8a", "aarch64-linux-android", "arm64-v8a"),
Triple("armeabi-v7a", "armv7-linux-androideabi", "armeabi-v7a"),
Triple("x86", "i686-linux-android", "x86")
)
doLast {
// Resolve cargo: try CARGO_HOME/bin first, then PATH, then the
// rustup shim location.
val cargoBinDir = System.getenv("CARGO_HOME")?.let { "$it/bin" }
?: "${System.getProperty("user.home")}/.cargo/bin"
val cargo = listOf("$cargoBinDir/cargo", "/usr/local/bin/cargo", "/usr/bin/cargo")
.firstOrNull { file(it).exists() }
?: error("cargo not found; install via rustup")
val cargoNdkBin = listOf("$cargoBinDir/cargo-ndk", "/usr/local/bin/cargo-ndk")
.firstOrNull { file(it).exists() }
check(cargoNdkBin != null) { "cargo-ndk not installed (cargo install cargo-ndk)" }
val ndkHome = System.getenv("ANDROID_NDK_HOME")
?: System.getenv("NDK_HOME")
?: "/opt/android-sdk/ndk/26.3.11579264"
check(file(ndkHome).exists()) { "NDK not found at $ndkHome" }
// cargo-ndk is a cargo subcommand; invoke via `cargo ndk ...`.
// We must ensure cargo's PATH lookup can find cargo-ndk too.
// `-p 26` selects API 26 for the NDK toolchain so cpal can
// link against libaaudio (introduced in API 26). minSdk in
// the Android module stays at 24 — the device just won't
// execute the AAudio code path below API 26; cpal/Oboe falls
// back to OpenSL ES there.
val abiArgs = abis.flatMap { listOf("-t", it.first) }
val cargoCmd = listOf(cargo, "ndk", "-P", "26") + abiArgs +
listOf("-o", jniLibsDir.absolutePath, "build", "--release",
"--manifest-path", "$rustCrateDir/Cargo.toml")
println("[cargoBuildRust] running: ${cargoCmd.joinToString(" ")}")
exec {
commandLine(cargoCmd)
environment("ANDROID_NDK_HOME", ndkHome)
environment("PATH", "$cargoBinDir:${System.getenv("PATH")}")
}
}
}
// Make Android's preBuild depend on the Rust build so AGP picks up the .so files.
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn(cargoBuildRust)
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for capture. Cpal's Oboe input stream needs this. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="Chanora Audio PoC"
android:allowBackup="false"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,134 @@
package app.chanora.poc.audio
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.io.File
import kotlin.concurrent.thread
/**
* Chanora PoC — Android mobile audio spike.
*
* Two buttons:
* 1) Play 500 ms 440 Hz sine wave through the default output device.
* 2) Record 1 s from the default input device to app-private storage.
*
* Both call into the Rust cdylib via JNI. Status is logged to logcat
* under the tag "ChanoraAudioPoC" (Rust) and "ChanoraPoCActivity"
* (Kotlin). The UI also displays the result string.
*/
class MainActivity : Activity() {
private companion object {
const val TAG = "ChanoraPoCActivity"
const val REQ_RECORD = 1001
}
private lateinit var status: TextView
private lateinit var recordBtn: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Hand the Application Context to the Rust side BEFORE any
// audio call, so cpal's Oboe backend can find Android audio
// services via ndk_context.
NativeAudio.initContext(applicationContext)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "Chanora Audio PoC (Android)"
textSize = 20f
}
status = TextView(this).apply {
text = "ready"
textSize = 14f
setPadding(0, 24, 0, 24)
}
val playBtn = Button(this).apply {
text = "Play 500 ms sine"
setOnClickListener { onPlay() }
}
recordBtn = Button(this).apply {
text = "Record 1 s to file"
setOnClickListener { onRecord() }
}
root.addView(title)
root.addView(status)
root.addView(playBtn)
root.addView(recordBtn)
setContentView(root)
}
private fun setStatus(s: String) {
Log.i(TAG, s)
runOnUiThread { status.text = s }
}
private fun onPlay() {
setStatus("playing…")
thread(name = "chanora-poc-play") {
val frames = NativeAudio.playSine440(500L)
if (frames < 0) {
setStatus("PLAY ERROR (see logcat)")
} else {
setStatus("PLAY OK frames=$frames")
}
}
}
private fun onRecord() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), REQ_RECORD)
setStatus("requesting RECORD_AUDIO…")
return
}
doRecord()
}
private fun doRecord() {
setStatus("recording 1 s…")
thread(name = "chanora-poc-record") {
val path = File(filesDir, "chanora_poc_capture.wav").absolutePath
val frames = NativeAudio.record1sToFile(path, 1000L)
if (frames < 0) {
setStatus("RECORD ERROR (see logcat)")
} else {
val sz = File(path).length()
setStatus("RECORD OK frames=$frames bytes=$sz path=$path")
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_RECORD) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
doRecord()
} else {
setStatus("RECORD_AUDIO permission denied")
}
}
}
}
@@ -0,0 +1,22 @@
package app.chanora.poc.audio
import android.content.Context
object NativeAudio {
init {
System.loadLibrary("audio_capture_playback_android_spike")
}
/**
* Must be called once after `System.loadLibrary` with the
* application Context. Initialises the Rust-side `ndk_context`
* that cpal/Oboe relies on to locate Android audio services.
*/
external fun initContext(context: Context)
/** Returns the number of frames emitted, or -1 on error. */
external fun playSine440(durationMs: Long): Long
/** Returns the number of frames captured, or -1 on error. */
external fun record1sToFile(path: String, durationMs: Long): Long
}
@@ -0,0 +1,5 @@
// Root build file — versions only; module config lives in app/build.gradle.kts.
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
@@ -0,0 +1,18 @@
// Settings: declare the single :app module.
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "chanora-audio-poc"
include(":app")
@@ -0,0 +1,443 @@
//! Chanora PoC — Android mobile audio spike.
//!
//! Closes the mobile half of the PoC plan §2 audio exit criterion:
//! "Capture/playback works on at least one desktop and one mobile target."
//!
//! Desktop half is closed by `poc/audio-capture-playback-spike` on Linux/PipeWire.
//! This crate is the mobile-Android half.
//!
//! Approach:
//! * `cpal` 0.16 with no extra features — on Android it automatically
//! uses the Oboe backend (AAudio + OpenSL ES fallback). Matches
//! DEC-011 "platform-native first".
//! * The Kotlin app calls two JNI entry points:
//! - Java_app_chanora_poc_audio_NativeAudio_playSine440()
//! - Java_app_chanora_poc_audio_NativeAudio_record1sToFile(path)
//! * `tracing` is not used; on Android we route `log` macros to logcat
//! via `android_logger` so all output is visible via `adb logcat`.
#![cfg(target_os = "android")]
use std::panic::{self, AssertUnwindSafe};
use std::sync::Once;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SizedSample};
use hound::{SampleFormat as HoundSampleFormat, WavSpec, WavWriter};
use jni::objects::{JClass, JString};
use jni::sys::{jint, jlong, JNI_VERSION_1_6};
use jni::JNIEnv;
use log::{error, info, warn, LevelFilter};
const TAG: &str = "ChanoraAudioPoC";
static LOG_INIT: Once = Once::new();
// Stash the JavaVM* captured at library-load time. We need it to
// resolve a JNIEnv on the audio thread later if we ever want to make
// JNI calls there. cpal's Oboe backend uses `ndk_context` instead,
// which we initialise from the Kotlin side once we have a Context.
static mut JAVA_VM: *mut std::ffi::c_void = std::ptr::null_mut();
/// JNI entry point invoked by the Android runtime when our cdylib is
/// loaded via `System.loadLibrary`. Captures the JavaVM* but does NOT
/// yet initialise the ndk_context — that needs a `Context` reference,
/// which only the Activity has.
#[no_mangle]
pub extern "system" fn JNI_OnLoad(vm: *mut std::ffi::c_void, _reserved: *mut std::ffi::c_void) -> jint {
ensure_logging();
unsafe {
JAVA_VM = vm;
}
info!("JNI_OnLoad: captured JavaVM");
JNI_VERSION_1_6
}
/// Called by `MainActivity.onCreate` with the activity reference.
/// Initialises the `ndk_context` so cpal's Oboe backend can locate
/// the Android `Context` it needs.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_initContext<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
context: jni::objects::JObject<'local>,
) {
ensure_logging();
// Promote the local-ref Context to a global ref so it survives
// beyond this JNI call. ndk_context borrows the pointer.
let global = match env.new_global_ref(&context) {
Ok(g) => g,
Err(e) => {
error!("new_global_ref(context) failed: {e}");
return;
}
};
let raw_ctx = global.as_obj().as_raw() as *mut std::ffi::c_void;
// Leak the global ref intentionally — it must live for the
// lifetime of the library (which is the lifetime of the process).
std::mem::forget(global);
unsafe {
ndk_context::initialize_android_context(JAVA_VM, raw_ctx);
}
info!("initContext: ndk_context initialised (vm={:p}, ctx={:p})", unsafe { JAVA_VM }, raw_ctx);
}
fn ensure_logging() {
LOG_INIT.call_once(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Trace)
.with_tag(TAG),
);
// Route panics into logcat so they don't disappear into the void.
panic::set_hook(Box::new(|info| {
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<unknown panic payload>".to_string()
};
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<unknown location>".to_string());
error!("RUST PANIC at {location}: {payload}");
}));
info!("logging initialised");
});
}
/// Run a closure that produces a result, catching any panic and
/// converting it to an error string. Required because our JNI
/// functions are `extern "system"` and panicking across that
/// boundary is undefined behaviour (and aborts the process).
fn catch<F: FnOnce() -> Result<u64, String>>(label: &str, f: F) -> jlong {
let r = panic::catch_unwind(AssertUnwindSafe(f));
match r {
Ok(Ok(n)) => {
info!("{label} ok n={n}");
n as jlong
}
Ok(Err(e)) => {
error!("{label} returned err: {e}");
-1
}
Err(_) => {
// The panic hook already logged the details.
error!("{label} panicked (caught)");
-2
}
}
}
/// Synth a 440 Hz mono sine for `duration_ms` directly through the
/// default output device. Returns a status code (0 = OK, non-zero =
/// error code). Errors are also logged to logcat.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_playSine440(
_env: JNIEnv,
_class: JClass,
duration_ms: jlong,
) -> jlong {
ensure_logging();
let dur = Duration::from_millis(duration_ms.max(0) as u64);
catch("playSine440", || play_sine_inner(440.0, dur))
}
fn play_sine_inner(freq_hz: f32, duration: Duration) -> Result<u64, String> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "no default output device".to_string())?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let cfg = device
.default_output_config()
.map_err(|e| format!("default_output_config: {e}"))?;
info!(
"playback device='{}' sample_rate={} channels={} fmt={:?}",
device_name,
cfg.sample_rate().0,
cfg.channels(),
cfg.sample_format()
);
let sample_rate = cfg.sample_rate().0 as f32;
let channels = cfg.channels() as usize;
let total_frames = (duration.as_secs_f32() * sample_rate) as u64;
let phase = Arc::new(Mutex::new(0.0f32));
let frames_emitted = Arc::new(Mutex::new(0u64));
let done = Arc::new(Mutex::new(false));
let stream_config: cpal::StreamConfig = cfg.clone().into();
fn err_fn(e: cpal::StreamError) {
error!("playback stream error: {e}");
}
let stream = match cfg.sample_format() {
SampleFormat::F32 => build_play_stream::<f32>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
SampleFormat::I16 => build_play_stream::<i16>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
SampleFormat::U16 => build_play_stream::<u16>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
other => return Err(format!("unsupported sample format: {other:?}")),
};
stream
.play()
.map_err(|e| format!("play(): {e}"))?;
// Wait until either the producer reaches total_frames or a generous timeout.
let timeout = duration + Duration::from_millis(500);
let start = std::time::Instant::now();
loop {
if *done.lock().unwrap() {
break;
}
if start.elapsed() > timeout {
warn!("playback timeout reached");
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(stream);
let final_frames = *frames_emitted.lock().unwrap();
Ok(final_frames)
}
fn build_play_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
sample_rate: f32,
freq_hz: f32,
channels: usize,
total_frames: u64,
phase: Arc<Mutex<f32>>,
frames_emitted: Arc<Mutex<u64>>,
done: Arc<Mutex<bool>>,
err_fn: fn(cpal::StreamError),
) -> Result<cpal::Stream, String>
where
T: SizedSample + FromSineSample + Send + 'static,
{
let phase_inc = 2.0 * std::f32::consts::PI * freq_hz / sample_rate;
let stream = device
.build_output_stream(
config,
move |out: &mut [T], _| {
let mut ph = phase.lock().unwrap();
let mut emitted = frames_emitted.lock().unwrap();
for frame in out.chunks_mut(channels) {
if *emitted >= total_frames {
for s in frame.iter_mut() {
*s = T::from_f32(0.0);
}
*done.lock().unwrap() = true;
} else {
let v = (*ph).sin() * 0.5;
*ph = (*ph + phase_inc) % (2.0 * std::f32::consts::PI);
for s in frame.iter_mut() {
*s = T::from_f32(v);
}
*emitted += 1;
}
}
},
err_fn,
None,
)
.map_err(|e| format!("build_output_stream: {e}"))?;
Ok(stream)
}
pub trait FromSineSample {
fn from_f32(v: f32) -> Self;
}
impl FromSineSample for f32 {
fn from_f32(v: f32) -> Self {
v
}
}
impl FromSineSample for i16 {
fn from_f32(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
impl FromSineSample for u16 {
fn from_f32(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
(s + i32::from(i16::MAX) + 1) as u16
}
}
/// Record `duration_ms` of mono 16-bit PCM to `path` from the default
/// input device. Returns frames captured (>= 0) or -1 on error.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_record1sToFile(
mut env: JNIEnv,
_class: JClass,
path: JString,
duration_ms: jlong,
) -> jlong {
ensure_logging();
let path_str: String = match env.get_string(&path) {
Ok(s) => s.into(),
Err(e) => {
error!("get_string(path) failed: {e}");
return -1;
}
};
let dur = Duration::from_millis(duration_ms.max(0) as u64);
catch("record1sToFile", move || record_inner(&path_str, dur))
}
fn record_inner(path: &str, duration: Duration) -> Result<u64, String> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| "no default input device".to_string())?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let cfg = device
.default_input_config()
.map_err(|e| format!("default_input_config: {e}"))?;
info!(
"capture device='{}' sample_rate={} channels={} fmt={:?}",
device_name,
cfg.sample_rate().0,
cfg.channels(),
cfg.sample_format()
);
let spec = WavSpec {
channels: 1,
sample_rate: cfg.sample_rate().0,
bits_per_sample: 16,
sample_format: HoundSampleFormat::Int,
};
let writer = Arc::new(Mutex::new(Some(
WavWriter::create(path, spec).map_err(|e| format!("wav create: {e}"))?,
)));
let frames = Arc::new(Mutex::new(0u64));
let channels = cfg.channels() as usize;
let stream_config: cpal::StreamConfig = cfg.clone().into();
fn err_fn(e: cpal::StreamError) {
error!("capture stream error: {e}");
}
let stream = match cfg.sample_format() {
SampleFormat::F32 => build_capture_stream::<f32>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
SampleFormat::I16 => build_capture_stream::<i16>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
SampleFormat::U16 => build_capture_stream::<u16>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
other => return Err(format!("unsupported sample format: {other:?}")),
};
stream
.play()
.map_err(|e| format!("capture play(): {e}"))?;
std::thread::sleep(duration);
drop(stream);
if let Some(w) = writer.lock().unwrap().take() {
w.finalize().map_err(|e| format!("wav finalize: {e}"))?;
}
let n = *frames.lock().unwrap();
Ok(n)
}
fn build_capture_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
writer: Arc<Mutex<Option<WavWriter<std::io::BufWriter<std::fs::File>>>>>,
frames: Arc<Mutex<u64>>,
channels: usize,
err_fn: fn(cpal::StreamError),
) -> Result<cpal::Stream, String>
where
T: SizedSample + ToI16Sample + Send + 'static,
{
let stream = device
.build_input_stream(
config,
move |data: &[T], _| {
let mut w_guard = writer.lock().unwrap();
if let Some(w) = w_guard.as_mut() {
let mut n = 0u64;
for frame in data.chunks(channels) {
let mut acc: i32 = 0;
for s in frame {
acc += s.to_i16_sample() as i32;
}
let mono = (acc / frame.len() as i32) as i16;
let _ = w.write_sample(mono);
n += 1;
}
*frames.lock().unwrap() += n;
}
},
err_fn,
None,
)
.map_err(|e| format!("build_input_stream: {e}"))?;
Ok(stream)
}
pub trait ToI16Sample {
fn to_i16_sample(&self) -> i16;
}
impl ToI16Sample for i16 {
fn to_i16_sample(&self) -> i16 {
*self
}
}
impl ToI16Sample for u16 {
fn to_i16_sample(&self) -> i16 {
(i32::from(*self) - i32::from(i16::MAX) - 1) as i16
}
}
impl ToI16Sample for f32 {
fn to_i16_sample(&self) -> i16 {
let v = (*self * f32::from(i16::MAX)).clamp(f32::from(i16::MIN), f32::from(i16::MAX));
v as i16
}
}