//! 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 } }