From 5a1d9027957d8dcd3594b4db66dc4ac8227c18c8 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 21:24:16 +0900 Subject: [PATCH] fix(audio): migrate Android JNI paths --- .../chanora_audio/src/android_render_ring.rs | 8 + .../chanora_audio/src/android_voice_unit.rs | 358 +++++++++--------- crates/chanora_audio/src/engine.rs | 114 +++--- 3 files changed, 259 insertions(+), 221 deletions(-) diff --git a/crates/chanora_audio/src/android_render_ring.rs b/crates/chanora_audio/src/android_render_ring.rs index 668c9e7..a132ce3 100644 --- a/crates/chanora_audio/src/android_render_ring.rs +++ b/crates/chanora_audio/src/android_render_ring.rs @@ -49,6 +49,7 @@ pub(crate) struct AndroidRenderRingConsumer { } impl AndroidRenderRingConsumer { + #[cfg(test)] pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) { let mut chunks = out.chunks_exact_mut(2); for frame_out in &mut chunks { @@ -59,6 +60,13 @@ impl AndroidRenderRingConsumer { *sample = 0.0; } } + + pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) { + for frame_out in out { + let frame = self.frames.pop().unwrap_or([0.0, 0.0]); + *frame_out = (frame[0], frame[1]); + } + } } #[cfg(test)] diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 6f0d35d..69290d0 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -516,24 +516,39 @@ impl AudioOutputCallback for OutputCallback { frames: &mut [(f32, f32)], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| { - let buf: &mut [f32] = bytemuck::cast_slice_mut::<(f32, f32), f32>(frames); - self.pcm_consumer.drain_into_zero_filling(buf); + self.pcm_consumer.drain_stereo_into_zero_filling(frames); let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed)); let muted = self.output_muted.load(Ordering::Relaxed); if muted { - for s in buf.iter_mut() { - *s = 0.0; + for frame in frames.iter_mut() { + *frame = (0.0, 0.0); } } else if gain != 1.0 { - for s in buf.iter_mut() { - *s *= gain; + for (left, right) in frames.iter_mut() { + *left *= gain; + *right *= gain; } } + let mut sum_squares = 0.0_f32; + for (left, right) in frames.iter() { + sum_squares += left * left + right * right; + } + let sample_count = frames.len() * 2; + let dbfs = if sample_count == 0 { + -120.0 + } else { + let rms = (sum_squares / sample_count as f32).sqrt(); + if rms <= 0.000_001 { + -120.0 + } else { + 20.0 * rms.log10() + } + }; self.audio_processing_stats - .update_render(crate::frame::dbfs(buf), frames.len() as u32); + .update_render(dbfs, frames.len() as u32); - for chunk in buf.chunks_exact(2) { - self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5; + for (left, right) in frames.iter() { + self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5; self.pending_render_ref_len += 1; if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES { self.render_reference.write(&self.pending_render_ref); @@ -587,11 +602,13 @@ pub struct AndroidVoiceUnit { #[derive(Default)] struct HardwareEffectHandles { - aec: Option, - ns: Option, - agc: Option, + aec: Option, + ns: Option, + agc: Option, } +type AndroidGlobalObject = jni::refs::Global>; + impl AndroidVoiceUnit { /// Open the input + output streams (SDD-111 + SDD-112) and, /// once a session id is available, attach SDD-113 hardware @@ -1297,62 +1314,71 @@ fn attach_hardware_effects_inner( session_id: AudioSessionId, effects: &crate::AudioEffects, ) -> HardwareEffectHandles { + with_android_env("hardware effects", |env| { + let mut handles = HardwareEffectHandles::default(); + if effects.aec { + handles.aec = create_effect( + env, + "android/media/audiofx/AcousticEchoCanceler", + session_id, + "AEC", + ); + } + if effects.noise_suppression { + handles.ns = create_effect( + env, + "android/media/audiofx/NoiseSuppressor", + session_id, + "NS", + ); + } + if effects.agc { + handles.agc = create_effect( + env, + "android/media/audiofx/AutomaticGainControl", + session_id, + "AGC", + ); + } + handles + }) + .unwrap_or_default() +} + +fn with_android_env( + operation: &str, + op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R, +) -> Option { let ctx = ndk_context::android_context(); if ctx.vm().is_null() { warn!( target: "chanora_audio", - "android: ndk_context vm null; cannot bind hardware effects (software fallback engages)" + operation, + "android: ndk_context vm null; JNI call skipped" ); - return HardwareEffectHandles::default(); + return None; } - let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } { - Ok(v) => v, - Err(e) => { - warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound"); - return HardwareEffectHandles::default(); - } - }; - let mut env = match jvm.attach_current_thread() { - Ok(e) => e, - Err(e) => { - warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound"); - return HardwareEffectHandles::default(); - } - }; - let mut handles = HardwareEffectHandles::default(); - if effects.aec { - handles.aec = create_effect( - &mut env, - "android/media/audiofx/AcousticEchoCanceler", - session_id, - "AEC", - ); + let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) }; + match jvm.attach_current_thread(|env| Ok::(op(env))) { + Ok(value) => Some(value), + Err(e) => { + warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed"); + None + } } - if effects.noise_suppression { - handles.ns = create_effect( - &mut env, - "android/media/audiofx/NoiseSuppressor", - session_id, - "NS", - ); - } - if effects.agc { - handles.agc = create_effect( - &mut env, - "android/media/audiofx/AutomaticGainControl", - session_id, - "AGC", - ); - } - handles } /// SDD-113 item 3: probe the static `isAvailable()` on each effect /// class before calling `create(int)`. Returns `false` on any JNI /// failure so the caller engages the software fallback. -fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool { - match env.call_static_method(class, "isAvailable", "()Z", &[]) { +fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool { + match env.call_static_method( + class, + jni::jni_str!("isAvailable"), + jni::jni_sig!("()Z"), + &[], + ) { Ok(v) => match v.z() { Ok(b) => b, Err(e) => { @@ -1370,14 +1396,14 @@ fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, labe } fn create_effect( - env: &mut jni::JNIEnv, + env: &mut jni::Env<'_>, fqcn: &str, session_id: AudioSessionId, label: &str, -) -> Option { +) -> Option { use jni::objects::JValue; // Class.create(int) -> ClassInstance|null - let class = match env.find_class(fqcn) { + let class = match env.find_class(jni::strings::JNIString::new(fqcn)) { Ok(c) => c, Err(e) => { warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages"); @@ -1393,10 +1419,18 @@ fn create_effect( ); return None; } + let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};")) + { + Ok(sig) => sig, + Err(e) => { + warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed"); + return None; + } + }; let inst = match env.call_static_method( &class, - "create", - &format!("(I)L{fqcn};"), + jni::jni_str!("create"), + create_sig.method_signature(), &[JValue::Int(session_id)], ) { Ok(v) => match v.l() { @@ -1421,8 +1455,8 @@ fn create_effect( // setEnabled(true) -> int (success code) if let Err(e) = env.call_method( &inst, - "setEnabled", - "(Z)I", + jni::jni_str!("setEnabled"), + jni::jni_sig!("(Z)I"), &[JValue::Bool(jni::sys::JNI_TRUE)], ) { let _ = env.exception_clear(); @@ -1458,34 +1492,28 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) { if aec.is_none() && ns.is_none() && agc.is_none() { return; } - let ctx = ndk_context::android_context(); - if ctx.vm().is_null() { - return; - } - // SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad. - let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } { - Ok(v) => v, - Err(_) => return, - }; - let mut env = match jvm.attach_current_thread() { - Ok(e) => e, - Err(_) => return, - }; - for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] { - if let Some(g) = effect { - let _ = env.call_method( - g.as_obj(), - "setEnabled", - "(Z)I", - &[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)], - ); - let _ = env.exception_clear(); - let _ = env.call_method(g.as_obj(), "release", "()V", &[]); - let _ = env.exception_clear(); - drop(g); - info!(target: "chanora_audio", effect = label, "android: hardware effect released"); + let _ = with_android_env("release hardware effects", |env| { + for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] { + if let Some(g) = effect { + let _ = env.call_method( + g.as_obj(), + jni::jni_str!("setEnabled"), + jni::jni_sig!("(Z)I"), + &[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)], + ); + env.exception_clear(); + let _ = env.call_method( + g.as_obj(), + jni::jni_str!("release"), + jni::jni_sig!("()V"), + &[], + ); + env.exception_clear(); + drop(g); + info!(target: "chanora_audio", effect = label, "android: hardware effect released"); + } } - } + }); } // --- Process-global BackendEvent sender for JNI callbacks -------- @@ -1560,7 +1588,7 @@ pub fn chanora_android_stop_voice_service() -> bool { fn call_voice_service_static(method: &str) -> bool { use jni::objects::{JObject, JValue}; let ctx = ndk_context::android_context(); - if ctx.vm().is_null() || ctx.context().is_null() { + if ctx.context().is_null() { warn!( target: "chanora_audio", method, @@ -1568,54 +1596,41 @@ fn call_voice_service_static(method: &str) -> bool { ); return false; } - // SAFETY: vm/context populated by chanora_bridge::android_init at - // JNI_OnLoad + initChanoraContext; both pointers are valid for - // the process lifetime. - let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } { - Ok(v) => v, - Err(e) => { - warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed"); - return false; + + with_android_env("voice foreground service", |env| { + // SAFETY: ndk_context::context() is the application Context + // jobject; valid global ref for process lifetime. + let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) }; + let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) { + Some(c) => c, + None => return false, + }; + match env.call_static_method( + &class, + jni::strings::JNIString::new(method), + jni::jni_sig!("(Landroid/content/Context;)V"), + &[JValue::Object(&context_obj)], + ) { + Ok(_) => { + info!(target: "chanora_audio", method, "android: voice foreground service call dispatched"); + true + } + Err(e) => { + env.exception_clear(); + warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed"); + false + } } - }; - let mut env = match jvm.attach_current_thread() { - Ok(e) => e, - Err(e) => { - warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed"); - return false; - } - }; - // SAFETY: ndk_context::context() is the application Context - // jobject; valid global ref for process lifetime. - let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; - let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) { - Some(c) => c, - None => return false, - }; - match env.call_static_method( - &class, - method, - "(Landroid/content/Context;)V", - &[JValue::Object(&context_obj)], - ) { - Ok(_) => { - info!(target: "chanora_audio", method, "android: voice foreground service call dispatched"); - true - } - Err(e) => { - let _ = env.exception_clear(); - warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed"); - false - } - } + }) + .unwrap_or(false) } fn load_app_class<'local>( - env: &mut jni::JNIEnv<'local>, + env: &mut jni::Env<'local>, context_obj: &jni::objects::JObject<'local>, slash_name: &str, ) -> Option> { - match env.find_class(slash_name) { + match env.find_class(jni::strings::JNIString::new(slash_name)) { Ok(c) => return Some(c), Err(e) => { let _ = env.exception_clear(); @@ -1626,8 +1641,8 @@ fn load_app_class<'local>( let loader = match env .call_method( context_obj, - "getClassLoader", - "()Ljava/lang/ClassLoader;", + jni::jni_str!("getClassLoader"), + jni::jni_sig!("()Ljava/lang/ClassLoader;"), &[], ) .and_then(|v| v.l()) @@ -1652,13 +1667,20 @@ fn load_app_class<'local>( match env .call_method( &loader, - "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", + jni::jni_str!("loadClass"), + jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"), &[jni::objects::JValue::Object(&class_name_obj)], ) .and_then(|v| v.l()) { - Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)), + Ok(class_obj) => match env.cast_local::(class_obj) { + Ok(class) => Some(class), + Err(e) => { + env.exception_clear(); + warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object"); + None + } + }, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed"); @@ -1689,7 +1711,7 @@ fn load_app_class<'local>( pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange< 'local, >( - _env: jni::JNIEnv<'local>, + _env: jni::EnvUnowned<'local>, _class: jni::objects::JClass<'local>, state: jni::sys::jint, ) { @@ -1727,7 +1749,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange< 'local, >( - _env: jni::JNIEnv<'local>, + _env: jni::EnvUnowned<'local>, _class: jni::objects::JClass<'local>, state: jni::sys::jint, ) { @@ -1776,7 +1798,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool { fn call_static_void_context(fqcn: &str, method: &str) -> bool { use jni::objects::{JObject, JValue}; let ctx = ndk_context::android_context(); - if ctx.vm().is_null() || ctx.context().is_null() { + if ctx.context().is_null() { warn!( target: "chanora_audio", class = fqcn, @@ -1785,39 +1807,29 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool { ); return false; } - let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } { - Ok(v) => v, - Err(e) => { - warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed"); - return false; + + with_android_env("static context call", |env| { + let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) }; + let class = match load_app_class(env, &context_obj, fqcn) { + Some(c) => c, + None => return false, + }; + match env.call_static_method( + &class, + jni::strings::JNIString::new(method), + jni::jni_sig!("(Landroid/content/Context;)V"), + &[JValue::Object(&context_obj)], + ) { + Ok(_) => { + info!(target: "chanora_audio", class = fqcn, method, "android: dispatched"); + true + } + Err(e) => { + env.exception_clear(); + warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed"); + false + } } - }; - let mut env = match jvm.attach_current_thread() { - Ok(e) => e, - Err(e) => { - warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed"); - return false; - } - }; - let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; - let class = match load_app_class(&mut env, &context_obj, fqcn) { - Some(c) => c, - None => return false, - }; - match env.call_static_method( - &class, - method, - "(Landroid/content/Context;)V", - &[JValue::Object(&context_obj)], - ) { - Ok(_) => { - info!(target: "chanora_audio", class = fqcn, method, "android: dispatched"); - true - } - Err(e) => { - let _ = env.exception_clear(); - warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed"); - false - } - } + }) + .unwrap_or(false) } diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 40d79e7..9bfce2e 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -2448,6 +2448,13 @@ impl std::fmt::Display for AudioModeError { #[cfg(target_os = "android")] impl std::error::Error for AudioModeError {} +#[cfg(target_os = "android")] +impl From for AudioModeError { + fn from(value: jni::errors::Error) -> Self { + Self::JniAttachFailed(value.to_string()) + } +} + /// JNI helper shared by `android_get_audio_mode` and /// `android_set_audio_mode`: attach to the current thread and return /// the `AudioManager` jobject. Centralised so SDD-108's two platform @@ -2455,7 +2462,10 @@ impl std::error::Error for AudioModeError {} #[cfg(target_os = "android")] fn android_audio_manager_call(op: F) -> Result where - F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result + F: for<'local> FnOnce( + &mut jni::Env<'local>, + &jni::objects::JObject<'local>, + ) -> Result + std::panic::UnwindSafe, { use jni::objects::{JObject, JString, JValue}; @@ -2472,42 +2482,40 @@ where // at a live JavaVM* set by our bridge_init JNI hook. The // unsafe block contains only the cast required by // `JavaVM::from_raw`. - let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) } - .map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?; - let mut env = jvm - .attach_current_thread() - .map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?; + let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }; + jvm.attach_current_thread(|env| -> Result { + let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) }; - let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; - - let service_name: JString = - env.new_string("audio") + let service_name: JString = + env.new_string("audio") + .map_err(|e| AudioModeError::MethodCallFailed { + method: "new_string", + detail: e.to_string(), + })?; + let service_name_obj = JObject::from(service_name); + let audio_manager = env + .call_method( + &context_obj, + jni::jni_str!("getSystemService"), + jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"), + &[JValue::Object(&service_name_obj)], + ) .map_err(|e| AudioModeError::MethodCallFailed { - method: "new_string", + method: "getSystemService", detail: e.to_string(), + })? + .l() + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getSystemService", + detail: format!("obj cast: {e}"), })?; - let audio_manager = env - .call_method( - &context_obj, - "getSystemService", - "(Ljava/lang/String;)Ljava/lang/Object;", - &[JValue::Object(&service_name.into())], - ) - .map_err(|e| AudioModeError::MethodCallFailed { - method: "getSystemService", - detail: e.to_string(), - })? - .l() - .map_err(|e| AudioModeError::MethodCallFailed { - method: "getSystemService", - detail: format!("obj cast: {e}"), - })?; - if audio_manager.is_null() { - return Err(AudioModeError::Other( - "AudioManager service is null".to_string(), - )); - } - op(&mut env, &audio_manager) + if audio_manager.is_null() { + return Err(AudioModeError::Other( + "AudioManager service is null".to_string(), + )); + } + op(env, &audio_manager) + }) }); match result { Ok(inner) => inner, @@ -2526,16 +2534,21 @@ where #[cfg(target_os = "android")] pub fn android_get_audio_mode() -> Result { android_audio_manager_call(|env, audio_manager| { - env.call_method(audio_manager, "getMode", "()I", &[]) - .map_err(|e| AudioModeError::MethodCallFailed { - method: "getMode", - detail: e.to_string(), - })? - .i() - .map_err(|e| AudioModeError::MethodCallFailed { - method: "getMode", - detail: format!("int cast: {e}"), - }) + env.call_method( + audio_manager, + jni::jni_str!("getMode"), + jni::jni_sig!("()I"), + &[], + ) + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getMode", + detail: e.to_string(), + })? + .i() + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getMode", + detail: format!("int cast: {e}"), + }) }) } @@ -2549,11 +2562,16 @@ pub fn android_get_audio_mode() -> Result { pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { use jni::objects::JValue; android_audio_manager_call(move |env, audio_manager| { - env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)]) - .map_err(|e| AudioModeError::MethodCallFailed { - method: "setMode", - detail: e.to_string(), - })?; + env.call_method( + audio_manager, + jni::jni_str!("setMode"), + jni::jni_sig!("(I)V"), + &[JValue::Int(mode)], + ) + .map_err(|e| AudioModeError::MethodCallFailed { + method: "setMode", + detail: e.to_string(), + })?; Ok(()) }) }