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:
+929
@@ -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 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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
+134
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
BIN
Binary file not shown.
+5
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user