fix(audio): migrate Android JNI paths

This commit is contained in:
Edison Jwa
2026-06-08 21:24:16 +09:00
parent 901369b072
commit 5a1d902795
3 changed files with 259 additions and 221 deletions
@@ -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)]
+129 -117
View File
@@ -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<jni::objects::GlobalRef>,
ns: Option<jni::objects::GlobalRef>,
agc: Option<jni::objects::GlobalRef>,
aec: Option<AndroidGlobalObject>,
ns: Option<AndroidGlobalObject>,
agc: Option<AndroidGlobalObject>,
}
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
impl AndroidVoiceUnit {
/// Open the input + output streams (SDD-111 + SDD-112) and,
/// once a session id is available, attach SDD-113 hardware
@@ -1297,33 +1314,11 @@ fn attach_hardware_effects_inner(
session_id: AudioSessionId,
effects: &crate::AudioEffects,
) -> HardwareEffectHandles {
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)"
);
return HardwareEffectHandles::default();
}
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();
}
};
with_android_env("hardware effects", |env| {
let mut handles = HardwareEffectHandles::default();
if effects.aec {
handles.aec = create_effect(
&mut env,
env,
"android/media/audiofx/AcousticEchoCanceler",
session_id,
"AEC",
@@ -1331,7 +1326,7 @@ fn attach_hardware_effects_inner(
}
if effects.noise_suppression {
handles.ns = create_effect(
&mut env,
env,
"android/media/audiofx/NoiseSuppressor",
session_id,
"NS",
@@ -1339,20 +1334,51 @@ fn attach_hardware_effects_inner(
}
if effects.agc {
handles.agc = create_effect(
&mut env,
env,
"android/media/audiofx/AutomaticGainControl",
session_id,
"AGC",
);
}
handles
})
.unwrap_or_default()
}
fn with_android_env<R>(
operation: &str,
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
) -> Option<R> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
warn!(
target: "chanora_audio",
operation,
"android: ndk_context vm null; JNI call skipped"
);
return None;
}
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
Ok(value) => Some(value),
Err(e) => {
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
None
}
}
}
/// 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<jni::objects::GlobalRef> {
) -> Option<AndroidGlobalObject> {
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,
};
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(),
"setEnabled",
"(Z)I",
jni::jni_str!("setEnabled"),
jni::jni_sig!("(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();
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,34 +1596,19 @@ 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;
}
};
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;
}
};
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(ctx.context() as jni::sys::jobject) };
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
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,
method,
"(Landroid/content/Context;)V",
jni::strings::JNIString::new(method),
jni::jni_sig!("(Landroid/content/Context;)V"),
&[JValue::Object(&context_obj)],
) {
Ok(_) => {
@@ -1603,19 +1616,21 @@ fn call_voice_service_static(method: &str) -> bool {
true
}
Err(e) => {
let _ = env.exception_clear();
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<jni::objects::JClass<'local>> {
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::<jni::objects::JClass>(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,29 +1807,17 @@ 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;
}
};
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) {
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,
method,
"(Landroid/content/Context;)V",
jni::strings::JNIString::new(method),
jni::jni_sig!("(Landroid/content/Context;)V"),
&[JValue::Object(&context_obj)],
) {
Ok(_) => {
@@ -1815,9 +1825,11 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
true
}
Err(e) => {
let _ = env.exception_clear();
env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
false
}
}
})
.unwrap_or(false)
}
+32 -14
View File
@@ -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<jni::errors::Error> 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<F, R>(op: F) -> Result<R, AudioModeError>
where
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
F: for<'local> FnOnce(
&mut jni::Env<'local>,
&jni::objects::JObject<'local>,
) -> Result<R, AudioModeError>
+ std::panic::UnwindSafe,
{
use jni::objects::{JObject, JString, JValue};
@@ -2472,13 +2482,9 @@ 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 context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
let service_name: JString =
env.new_string("audio")
@@ -2486,12 +2492,13 @@ where
method: "new_string",
detail: e.to_string(),
})?;
let service_name_obj = JObject::from(service_name);
let audio_manager = env
.call_method(
&context_obj,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&service_name.into())],
jni::jni_str!("getSystemService"),
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
&[JValue::Object(&service_name_obj)],
)
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
@@ -2507,7 +2514,8 @@ where
"AudioManager service is null".to_string(),
));
}
op(&mut env, &audio_manager)
op(env, &audio_manager)
})
});
match result {
Ok(inner) => inner,
@@ -2526,7 +2534,12 @@ where
#[cfg(target_os = "android")]
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
android_audio_manager_call(|env, audio_manager| {
env.call_method(audio_manager, "getMode", "()I", &[])
env.call_method(
audio_manager,
jni::jni_str!("getMode"),
jni::jni_sig!("()I"),
&[],
)
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getMode",
detail: e.to_string(),
@@ -2549,7 +2562,12 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
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)])
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(),