diff --git a/poc/audio-capture-playback-android-spike/Cargo.lock b/poc/audio-capture-playback-android-spike/Cargo.lock new file mode 100644 index 0000000..cb648f1 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/Cargo.lock @@ -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", +] diff --git a/poc/audio-capture-playback-android-spike/Cargo.toml b/poc/audio-capture-playback-android-spike/Cargo.toml new file mode 100644 index 0000000..33642d6 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/Cargo.toml @@ -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] diff --git a/poc/audio-capture-playback-android-spike/README.md b/poc/audio-capture-playback-android-spike/README.md new file mode 100644 index 0000000..ad954bd --- /dev/null +++ b/poc/audio-capture-playback-android-spike/README.md @@ -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 ... 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. diff --git a/poc/audio-capture-playback-android-spike/VERIFICATION.md b/poc/audio-capture-playback-android-spike/VERIFICATION.md new file mode 100644 index 0000000..0fe2048 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/VERIFICATION.md @@ -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. diff --git a/poc/audio-capture-playback-android-spike/android/app/build.gradle.kts b/poc/audio-capture-playback-android-spike/android/app/build.gradle.kts new file mode 100644 index 0000000..bd0cf97 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/app/build.gradle.kts @@ -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) +} diff --git a/poc/audio-capture-playback-android-spike/android/app/src/main/AndroidManifest.xml b/poc/audio-capture-playback-android-spike/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9201bd9 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/MainActivity.kt b/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/MainActivity.kt new file mode 100644 index 0000000..33501e5 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/MainActivity.kt @@ -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, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQ_RECORD) { + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + doRecord() + } else { + setStatus("RECORD_AUDIO permission denied") + } + } + } +} diff --git a/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/NativeAudio.kt b/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/NativeAudio.kt new file mode 100644 index 0000000..3cb8dd0 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/app/src/main/java/app/chanora/poc/audio/NativeAudio.kt @@ -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 +} diff --git a/poc/audio-capture-playback-android-spike/android/build.gradle.kts b/poc/audio-capture-playback-android-spike/android/build.gradle.kts new file mode 100644 index 0000000..c6f03e0 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/build.gradle.kts @@ -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 +} diff --git a/poc/audio-capture-playback-android-spike/android/gradle.properties b/poc/audio-capture-playback-android-spike/android/gradle.properties new file mode 100644 index 0000000..2318707 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.jar b/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.properties b/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..48c0a02 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/poc/audio-capture-playback-android-spike/android/gradlew b/poc/audio-capture-playback-android-spike/android/gradlew new file mode 100755 index 0000000..97de990 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/gradlew @@ -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" "$@" diff --git a/poc/audio-capture-playback-android-spike/android/settings.gradle.kts b/poc/audio-capture-playback-android-spike/android/settings.gradle.kts new file mode 100644 index 0000000..e1db0d1 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/android/settings.gradle.kts @@ -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") diff --git a/poc/audio-capture-playback-android-spike/src/lib.rs b/poc/audio-capture-playback-android-spike/src/lib.rs new file mode 100644 index 0000000..2db1d29 --- /dev/null +++ b/poc/audio-capture-playback-android-spike/src/lib.rs @@ -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::() { + s.clone() + } else { + "".to_string() + }; + let location = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "".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 Result>(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 { + 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(|_| "".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::( + &device, + &stream_config, + sample_rate, + freq_hz, + channels, + total_frames, + phase.clone(), + frames_emitted.clone(), + done.clone(), + err_fn, + )?, + SampleFormat::I16 => build_play_stream::( + &device, + &stream_config, + sample_rate, + freq_hz, + channels, + total_frames, + phase.clone(), + frames_emitted.clone(), + done.clone(), + err_fn, + )?, + SampleFormat::U16 => build_play_stream::( + &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( + device: &cpal::Device, + config: &cpal::StreamConfig, + sample_rate: f32, + freq_hz: f32, + channels: usize, + total_frames: u64, + phase: Arc>, + frames_emitted: Arc>, + done: Arc>, + err_fn: fn(cpal::StreamError), +) -> Result +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 { + 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(|_| "".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::( + &device, &stream_config, writer.clone(), frames.clone(), channels, err_fn, + )?, + SampleFormat::I16 => build_capture_stream::( + &device, &stream_config, writer.clone(), frames.clone(), channels, err_fn, + )?, + SampleFormat::U16 => build_capture_stream::( + &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( + device: &cpal::Device, + config: &cpal::StreamConfig, + writer: Arc>>>>, + frames: Arc>, + channels: usize, + err_fn: fn(cpal::StreamError), +) -> Result +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 + } +}