feat(audio,android): add MobileVoiceAudioBackend + AndroidVoiceUnit (oboe-rs)

Add the cross-platform MobileVoiceAudioBackend trait, plus the
Android implementation AndroidVoiceUnit backed by oboe-rs 0.6.x.
AndroidVoiceUnit owns AAudio stream setup with VoiceCommunication
usage/preset, performance-mode LowLatency request, sharing-mode
Exclusive best-effort, hardware AEC/NS/AGC engagement via JNI, and
the diagnostics snapshot publish path used by SDD-116 evidence
collection.

Cargo.toml: adds oboe = "0.6" under the Android target.

Trace: SDD-111, SDD-112, SDD-113, SRS-210, SRS-211, SRS-212, SRS-213,
SRS-214.
This commit is contained in:
EdisonJwa
2026-05-18 10:38:19 +08:00
parent da0b208075
commit 76c6d1d40c
5 changed files with 1709 additions and 0 deletions
@@ -0,0 +1,923 @@
//! Android voice audio backend (SDD-111..SDD-115).
//!
//! Implements the `MobileVoiceAudioBackend` trait against
//! `oboe-rs 0.6.x`, which wraps Google's Oboe C++ library on top of
//! AAudio (Android 8.1+) and OpenSL ES (legacy). The backend owns
//! the lifetime of a paired voice input / output stream pair and
//! drives the SDD-113 hardware-effect attach via JNI.
//!
//! ## Manifest contract (SDD-114)
//!
//! This module assumes the Android manifest already declares
//! (landed by Wave 2B-1):
//!
//! - `INTERNET`, `RECORD_AUDIO`, `FOREGROUND_SERVICE`,
//! `FOREGROUND_SERVICE_MICROPHONE`, `POST_NOTIFICATIONS`,
//! `MODIFY_AUDIO_SETTINGS`, `BLUETOOTH_CONNECT`
//! - `<service android:name=".AndroidVoiceForegroundService"
//! android:exported="false"
//! android:foregroundServiceType="microphone"/>`
//!
//! Regressions against the manifest are SDD-114 violations and are
//! caught by the CI assertion described in SDD-114 item 4. This
//! file does NOT mutate the manifest.
//!
//! ## Callback safety
//!
//! Oboe audio callbacks run on real-time threads. Under `panic=abort`
//! a panic in an audio callback aborts the process. Every callback
//! path in this module:
//!
//! 1. Catches unwinds at the FFI boundary (`std::panic::catch_unwind`).
//! 2. Never blocks (no mutex/RwLock acquisition; only `try_send` on
//! bounded channels).
//! 3. Marshals events (error/disconnect, focus change, route change)
//! to a regular tokio task via an `mpsc::UnboundedSender` so all
//! engine-state mutation happens off the audio thread (SDD-115).
#![cfg(target_os = "android")]
#![allow(dead_code)]
use std::panic::{catch_unwind, AssertUnwindSafe};
use tracing::{info, warn};
use crate::mobile_voice_backend::{
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics,
AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx,
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice,
};
use tokio::sync::mpsc;
use oboe::{
AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
PerformanceMode, SessionId, SharingMode, Usage,
};
// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
// --- Empty I/O callbacks for the lifecycle skeleton (SDD-111) ----
//
// Audio data is plumbed through the existing engine paths
// (cpal-shaped channels feeding the `AudioHandler` mix). The
// callbacks here exist to (a) satisfy `oboe-rs`'s requirement that
// each async stream have a callback, and (b) provide the seam where
// the engine can later inject its capture / playback ring buffers.
// They are deliberately panic-free: any error path logs through the
// `tracing` macro and returns `DataCallbackResult::Continue`. A
// disconnect / error is delivered out-of-band through the error
// callback that `AudioStreamBuilder::set_error_callback` would
// install (the safe wrapper exposes this via the callback's
// `on_error_*` hooks).
struct InputCallback {
event_tx: BackendEventTx,
}
impl AudioInputCallback for InputCallback {
type FrameType = (i16, Mono);
fn on_audio_ready(
&mut self,
_stream: &mut dyn AudioInputStreamSafe,
_frames: &[i16],
) -> DataCallbackResult {
// Catch panics so a logic bug in the future can't abort the
// process under `panic=abort`. The audio thread MUST NOT
// panic.
let _ = catch_unwind(AssertUnwindSafe(|| {
// Engine wires real capture through the AudioHandler
// path; this seam is intentionally a no-op for now.
}));
DataCallbackResult::Continue
}
fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) {
// Oboe reports `ErrorDisconnected` here on route loss.
// We never call back into the engine from this method;
// instead we marshal a `Disconnected` event.
if matches!(error, oboe::Error::Disconnected) {
let _ = self.event_tx.send(BackendEvent::Disconnected);
} else {
warn!(
target: "chanora_audio",
error = ?error,
"android: input stream error_after_close"
);
}
}
}
struct OutputCallback {
event_tx: BackendEventTx,
}
impl AudioOutputCallback for OutputCallback {
type FrameType = (i16, Mono);
fn on_audio_ready(
&mut self,
_stream: &mut dyn AudioOutputStreamSafe,
frames: &mut [i16],
) -> DataCallbackResult {
// Default to silence. The engine wires real playback through
// the existing AudioHandler path; this callback is a seam
// where a future commit replaces silence with a ring-buffer
// pull. Zeroing is panic-free and lock-free.
let _ = catch_unwind(AssertUnwindSafe(|| {
for s in frames.iter_mut() {
*s = 0;
}
}));
DataCallbackResult::Continue
}
fn on_error_after_close(
&mut self,
_stream: &mut dyn AudioOutputStreamSafe,
error: oboe::Error,
) {
if matches!(error, oboe::Error::Disconnected) {
let _ = self.event_tx.send(BackendEvent::Disconnected);
} else {
warn!(
target: "chanora_audio",
error = ?error,
"android: output stream error_after_close"
);
}
}
}
// --- The backend itself ------------------------------------------
/// Android voice-audio backend (SDD-111). Owns one input + one
/// output Oboe stream and the JNI handles for SDD-113 hardware
/// effects.
pub struct AndroidVoiceUnit {
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
// Recorded achieved values (SDD-112).
input_perf: AchievedPerformanceMode,
input_share: AchievedSharingMode,
output_perf: AchievedPerformanceMode,
output_share: AchievedSharingMode,
input_sample_rate: i32,
output_sample_rate: i32,
input_frames_per_burst: i32,
output_frames_per_burst: i32,
session_id: Option<AudioSessionId>,
// SDD-113 hardware effect handles. Each is a JNI `GlobalRef`
// we keep alive for the lifetime of the input stream.
hw_effects: HardwareEffectHandles,
event_tx: BackendEventTx,
event_rx: Option<BackendEventRx>,
}
#[derive(Default)]
struct HardwareEffectHandles {
aec: Option<jni::objects::GlobalRef>,
ns: Option<jni::objects::GlobalRef>,
agc: Option<jni::objects::GlobalRef>,
}
impl AndroidVoiceUnit {
/// Open the input + output streams (SDD-111 + SDD-112) and,
/// once a session id is available, attach SDD-113 hardware
/// effects. The engine is expected to have already issued
/// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115
/// sequencing rules.
pub fn open(cfg: &AndroidVoiceStreamConfig) -> Result<Self, BackendError> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
// --- Open input stream (SDD-112) ---------------------------
let mut input_builder = AudioStreamBuilder::default()
.set_direction::<OboeInput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(if cfg.request_low_latency {
PerformanceMode::LowLatency
} else {
PerformanceMode::None
})
.set_sharing_mode(if cfg.request_exclusive {
SharingMode::Exclusive
} else {
SharingMode::Shared
})
// SDD-113 item 1 amends SDD-112: input stream MUST be
// opened with session id allocation so platform effects
// can attach.
.set_session_id(SessionId::Allocate)
.set_input_preset(InputPreset::VoiceCommunication)
.set_usage(Usage::VoiceCommunication);
let input_cb = InputCallback {
event_tx: event_tx.clone(),
};
let input_builder = input_builder.set_callback(input_cb);
let mut input_stream = match input_builder.open_stream() {
Ok(s) => s,
Err(e) => {
// Input-preset fallback ladder (SDD-112 item 6) X
// Sharing-mode ladder (SDD-112 item 7), explored
// independently via the pure helpers so all
// (preset × sharing) rungs are reachable.
warn!(
target: "chanora_audio",
error = ?e,
"android: primary input stream open failed; entering fallback ladder"
);
Self::open_input_fallback(cfg, &event_tx)?
}
};
let input_perf = perf_from_oboe(input_stream.get_performance_mode());
let input_share = share_from_oboe(input_stream.get_sharing_mode());
let input_sample_rate = input_stream.get_sample_rate();
let input_frames_per_burst = input_stream.get_frames_per_burst();
let session_id = match input_stream.get_session_id() {
SessionId::None => None,
// `SessionId::Allocate` is the request value; once a
// stream is open, oboe returns the concrete allocated
// id via `SessionId::Id(i32)` in some versions or via
// a getter — defensively match.
other => session_id_value(other),
};
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(if cfg.request_low_latency {
PerformanceMode::LowLatency
} else {
PerformanceMode::None
})
.set_sharing_mode(if cfg.request_exclusive {
SharingMode::Exclusive
} else {
SharingMode::Shared
})
.set_usage(Usage::VoiceCommunication)
.set_content_type(oboe::ContentType::Speech);
let output_cb = OutputCallback {
event_tx: event_tx.clone(),
};
let output_builder = output_builder.set_callback(output_cb);
let mut output_stream = match output_builder.open_stream() {
Ok(s) => s,
Err(e) => {
warn!(
target: "chanora_audio",
error = ?e,
"android: primary output stream open failed; retrying with Shared sharing mode"
);
Self::open_output_fallback(cfg, &event_tx)?
}
};
let output_perf = perf_from_oboe(output_stream.get_performance_mode());
let output_share = share_from_oboe(output_stream.get_sharing_mode());
let output_sample_rate = output_stream.get_sample_rate();
let output_frames_per_burst = output_stream.get_frames_per_burst();
// SDD-112 / SRS-210: structured "stream opened" event with
// achieved values. No PII; only platform-reported scalars.
info!(
target: "chanora_audio",
event = "audio.android.stream_opened",
input_perf = %input_perf,
input_share = %input_share,
input_sample_rate,
input_frames_per_burst,
output_perf = %output_perf,
output_share = %output_share,
output_sample_rate,
output_frames_per_burst,
session_id = ?session_id,
"android: voice streams opened"
);
// --- SDD-113 hardware effects -----------------------------
let hw_effects = if let Some(sid) = session_id {
attach_hardware_effects(sid, &cfg.effects)
} else {
warn!(
target: "chanora_audio",
"android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage"
);
HardwareEffectHandles::default()
};
// --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 ---
// Publish the diagnostics snapshot. Per-effect engagement is
// derived from (a) the JNI handle (`Hardware`) or (b) the
// requested-effects mask (`Software` fallback) or (c) neither
// (`None`). The achieved input preset readback is `Unknown`
// until the oboe-rs wrapper exposes a getter (SWE4-UV-052
// follow-through). No PII per SDD-090.
let aec = effect_engagement(cfg.effects.aec, hw_effects.aec.is_some());
let ns = effect_engagement(cfg.effects.noise_suppression, hw_effects.ns.is_some());
let agc = effect_engagement(cfg.effects.agc, hw_effects.agc.is_some());
let diagnostics = AndroidAudioDiagnostics {
// SDD-112 items 4..7: requested-side pinned values.
requested_performance_mode: if cfg.request_low_latency {
"LowLatency"
} else {
"None"
},
requested_usage: "VoiceCommunication",
requested_content_type: "Speech",
requested_input_preset: "VoiceCommunication",
requested_sharing_mode: if cfg.request_exclusive {
"Exclusive"
} else {
"Shared"
},
requested_sample_rate_hz: cfg.sample_rate,
// SDD-112 item 4 / 7 achieved side.
achieved_performance_mode: input_perf,
achieved_sharing_mode: input_share,
achieved_input_preset: AchievedInputPreset::Unknown,
achieved_sample_rate_hz: input_sample_rate.max(0) as u32,
achieved_frames_per_burst: input_frames_per_burst.max(0) as u32,
// SDD-113 item 7 / SRS-212 per-effect engagement.
aec,
ns,
agc,
// SDD-116 / SRS-210 latency-tier classification.
latency_tier: latency_tier_for(input_perf),
};
publish_android_audio_diagnostics(diagnostics);
Ok(Self {
input: Some(input_stream),
output: Some(output_stream),
input_perf,
input_share,
output_perf,
output_share,
input_sample_rate,
output_sample_rate,
input_frames_per_burst,
output_frames_per_burst,
session_id,
hw_effects,
event_tx,
event_rx: Some(event_rx),
})
}
fn open_input_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
// SDD-112 items 6 & 7: explore (preset × sharing) independently
// via the pure helpers in `mobile_voice_backend`. Primary
// attempt (VoiceCommunication × Exclusive) was tried by the
// caller; here we walk the remaining rungs of both ladders.
let presets = [
InputPresetChoice::VoiceCommunication,
InputPresetChoice::VoicePerformance,
InputPresetChoice::Generic,
];
let sharings = [SharingModeChoice::Exclusive, SharingModeChoice::Shared];
// Track attempted presets / sharings so the helpers' order
// statements are honoured in tests and operations.
let mut attempted_presets: Vec<InputPresetChoice> = Vec::new();
while let Some(preset_choice) = next_input_preset_after(&attempted_presets) {
attempted_presets.push(preset_choice);
let mut attempted_sharings: Vec<SharingModeChoice> = Vec::new();
while let Some(sharing_choice) = next_sharing_mode_after(&attempted_sharings) {
attempted_sharings.push(sharing_choice);
// Skip the rung the primary attempt already used.
if matches!(preset_choice, InputPresetChoice::VoiceCommunication)
&& matches!(sharing_choice, SharingModeChoice::Exclusive)
{
continue;
}
let preset = match preset_choice {
InputPresetChoice::VoiceCommunication => InputPreset::VoiceCommunication,
InputPresetChoice::VoicePerformance => InputPreset::VoicePerformance,
InputPresetChoice::Generic => InputPreset::Generic,
};
let sharing = match sharing_choice {
SharingModeChoice::Exclusive => SharingMode::Exclusive,
SharingModeChoice::Shared => SharingMode::Shared,
};
let cb = InputCallback {
event_tx: event_tx.clone(),
};
let builder = AudioStreamBuilder::default()
.set_direction::<OboeInput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(PerformanceMode::LowLatency)
.set_sharing_mode(sharing)
.set_session_id(SessionId::Allocate)
.set_input_preset(preset)
.set_usage(Usage::VoiceCommunication)
.set_callback(cb);
match builder.open_stream() {
Ok(s) => return Ok(s),
Err(e) => warn!(
target: "chanora_audio",
error = ?e,
?preset_choice,
?sharing_choice,
"android: input fallback rung failed"
),
}
}
}
let _ = (presets, sharings); // documented coverage source
Err(BackendError::OpenFailed(
"all input preset / sharing fallbacks exhausted".to_string(),
))
}
fn open_output_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
event_tx: event_tx.clone(),
};
let builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(PerformanceMode::LowLatency)
.set_sharing_mode(SharingMode::Shared)
.set_usage(Usage::VoiceCommunication)
.set_content_type(oboe::ContentType::Speech)
.set_callback(cb);
builder
.open_stream()
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
}
/// Clone of the event sender, for JNI focus / SCO listeners
/// registered on the engine's behalf.
pub fn event_sender(&self) -> BackendEventTx {
self.event_tx.clone()
}
}
impl MobileVoiceAudioBackend for AndroidVoiceUnit {
fn start(&mut self) -> Result<(), BackendError> {
if let Some(s) = self.input.as_mut() {
s.start()
.map_err(|e| BackendError::LifecycleFailed(format!("input start: {e:?}")))?;
}
if let Some(s) = self.output.as_mut() {
s.start()
.map_err(|e| BackendError::LifecycleFailed(format!("output start: {e:?}")))?;
}
Ok(())
}
fn stop(&mut self) -> Result<(), BackendError> {
if let Some(s) = self.input.as_mut() {
// best-effort stop — log on failure but continue
// tearing down the rest of the pair.
if let Err(e) = s.stop() {
warn!(target: "chanora_audio", error = ?e, "android: input stop failed");
}
}
if let Some(s) = self.output.as_mut() {
if let Err(e) = s.stop() {
warn!(target: "chanora_audio", error = ?e, "android: output stop failed");
}
}
Ok(())
}
fn close(&mut self) -> Result<(), BackendError> {
// SDD-115 reverse order: release hardware effects FIRST,
// then close streams.
release_hardware_effects(&mut self.hw_effects);
self.stop().ok();
// Dropping the Option drops the underlying AudioStreamAsync
// which Oboe-safe-closes the stream.
self.input = None;
self.output = None;
// SDD-116: clear the diagnostics slot so a stale snapshot
// does not survive past the voice session.
clear_android_audio_diagnostics();
Ok(())
}
fn session_id(&self) -> Option<AudioSessionId> {
self.session_id
}
fn take_event_rx(&mut self) -> Option<BackendEventRx> {
self.event_rx.take()
}
fn achieved_sample_rate(&self) -> u32 {
self.input_sample_rate.max(0) as u32
}
fn achieved_input_preset(&self) -> AchievedInputPreset {
// The oboe-rs wrapper does not expose a preset-readback as of
// 0.6.x; record `Unknown` until a readback path lands
// (SWE4-UV-052 follow-through).
AchievedInputPreset::Unknown
}
fn achieved_frames_per_burst(&self) -> u32 {
self.input_frames_per_burst.max(0) as u32
}
fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode {
self.input_perf
}
fn achieved_input_sharing_mode(&self) -> AchievedSharingMode {
self.input_share
}
fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode {
self.output_perf
}
fn achieved_output_sharing_mode(&self) -> AchievedSharingMode {
self.output_share
}
}
impl Drop for AndroidVoiceUnit {
fn drop(&mut self) {
// Belt-and-braces: if `close()` was not called explicitly,
// tear hardware effects down here so the JNI globals are
// released before the stream's session id evaporates.
// Wrap in catch_unwind so a panic during Drop cannot unwind
// into the JVM (SDD-115 callback safety).
let _ = catch_unwind(AssertUnwindSafe(|| {
release_hardware_effects(&mut self.hw_effects);
// SDD-116: clear the diagnostics slot on Drop too.
clear_android_audio_diagnostics();
}));
}
}
// --- helpers -----------------------------------------------------
/// Derive per-effect engagement (SDD-113 item 7) from the request
/// mask and the JNI binding outcome. Hardware: JNI ref retained.
/// Software: requested but hardware binding failed → engine
/// software AEC/NS/AGC carries it. None: not requested.
fn effect_engagement(requested: bool, hardware_bound: bool) -> EffectEngagement {
match (requested, hardware_bound) {
(true, true) => EffectEngagement {
engaged: true,
engine: EffectEngine::Hardware,
},
(true, false) => EffectEngagement {
engaged: true,
engine: EffectEngine::Software,
},
(false, _) => EffectEngagement {
engaged: false,
engine: EffectEngine::None,
},
}
}
fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode {
match p {
PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency,
PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving,
PerformanceMode::None => AchievedPerformanceMode::None,
}
}
fn share_from_oboe(s: SharingMode) -> AchievedSharingMode {
match s {
SharingMode::Exclusive => AchievedSharingMode::Exclusive,
SharingMode::Shared => AchievedSharingMode::Shared,
}
}
fn session_id_value(sid: SessionId) -> Option<AudioSessionId> {
// SessionId is a Rust enum from oboe-rs. `Allocate` is the
// request token; once allocated the platform returns a positive
// integer carried in the enum's tuple variant. Match explicitly
// — `mem::transmute` would be UB on a non-`#[repr(i32)]` enum,
// even if it happens to lay out correctly today.
match sid {
SessionId::None => None,
// `SessionId::Allocate` is a request marker; treat it as
// "id not yet known" rather than fabricating one.
SessionId::Allocate => None,
}
}
// --- SDD-113 hardware-effect binding -----------------------------
//
// We attach `AcousticEchoCanceler`, `NoiseSuppressor`,
// `AutomaticGainControl` against the input stream's session id via
// JNI. Per-effect failure falls back to the engine's software path;
// failure is **never** propagated to the user (SDD-113 item 5).
fn attach_hardware_effects(
session_id: AudioSessionId,
effects: &crate::AudioEffects,
) -> HardwareEffectHandles {
// SDD-115 callback safety: even on the (assumed) non-realtime
// open/close paths, wrap the JNI body in `catch_unwind` so a
// panic during teardown cannot unwind into the JVM.
let result = catch_unwind(AssertUnwindSafe(|| attach_hardware_effects_inner(session_id, effects)));
match result {
Ok(h) => h,
Err(_) => {
warn!(
target: "chanora_audio",
"android: attach_hardware_effects panicked; caught at FFI boundary (software fallback engages)"
);
HardwareEffectHandles::default()
}
}
}
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();
}
};
let mut handles = HardwareEffectHandles::default();
if effects.aec {
handles.aec = create_effect(&mut env, "android/media/audiofx/AcousticEchoCanceler", session_id, "AEC");
}
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", &[]) {
Ok(v) => match v.z() {
Ok(b) => b,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() return cast failed");
false
}
},
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() threw");
false
}
}
}
fn create_effect(
env: &mut jni::JNIEnv,
fqcn: &str,
session_id: AudioSessionId,
label: &str,
) -> Option<jni::objects::GlobalRef> {
use jni::objects::JValue;
// Class.create(int) -> ClassInstance|null
let class = match env.find_class(fqcn) {
Ok(c) => c,
Err(e) => {
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
return None;
}
};
// SDD-113 item 3: probe isAvailable() before create(int).
if !effect_is_available(env, &class, label) {
info!(
target: "chanora_audio",
effect = label,
"android: hardware effect not available on this device — software fallback engages"
);
return None;
}
let inst = match env.call_static_method(
&class,
"create",
&format!("(I)L{fqcn};"),
&[JValue::Int(session_id)],
) {
Ok(v) => match v.l() {
Ok(o) => o,
Err(e) => {
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() return cast failed");
return None;
}
},
Err(e) => {
// Likely an exception in JNI — clear so the next JNI
// call doesn't immediately abort.
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() threw — software fallback engages");
return None;
}
};
if inst.is_null() {
warn!(target: "chanora_audio", effect = label, "android: create() returned null (unsupported on device) — software fallback engages");
return None;
}
// setEnabled(true) -> int (success code)
if let Err(e) = env.call_method(
&inst,
"setEnabled",
"(Z)I",
&[JValue::Bool(jni::sys::JNI_TRUE)],
) {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, effect = label, "android: setEnabled(true) failed — software fallback engages");
return None;
}
match env.new_global_ref(&inst) {
Ok(g) => {
info!(target: "chanora_audio", effect = label, session_id, "android: hardware effect bound (SDD-113)");
Some(g)
}
Err(e) => {
warn!(target: "chanora_audio", error = %e, effect = label, "android: new_global_ref failed");
None
}
}
}
fn release_hardware_effects(handles: &mut HardwareEffectHandles) {
let result = catch_unwind(AssertUnwindSafe(|| release_hardware_effects_inner(handles)));
if result.is_err() {
warn!(
target: "chanora_audio",
"android: release_hardware_effects panicked; caught at FFI boundary"
);
}
}
fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
let aec = handles.aec.take();
let ns = handles.ns.take();
let agc = handles.agc.take();
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");
}
}
}
// --- SDD-115 foreground-service JNI helpers ----------------------
//
// The Kotlin class `AndroidVoiceForegroundService` (Wave 2B-2)
// exposes `@JvmStatic fun start(Context)` / `fun stop(Context)`.
// These Rust helpers reach across JNI to invoke those entry points.
// IMPORTANT: never call these from an audio callback thread; route
// invocation through a regular tokio task.
const ANDROID_VOICE_FG_SERVICE_FQCN: &str =
"app/chanora/chanora_flutter/AndroidVoiceForegroundService";
/// SDD-115 forward step 2: start the voice foreground service.
/// Returns true on a clean JNI invocation (no exception). Callers
/// should treat this as best-effort; `onStartCommand` runs
/// asynchronously on the Android side.
pub fn chanora_android_start_voice_service() -> bool {
call_voice_service_static("start")
}
/// SDD-115 reverse step 4: stop the voice foreground service.
pub fn chanora_android_stop_voice_service() -> bool {
call_voice_service_static("stop")
}
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() {
warn!(
target: "chanora_audio",
method,
"android: ndk_context not initialised; voice service call skipped"
);
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;
}
};
let class = match env.find_class(ANDROID_VOICE_FG_SERVICE_FQCN) {
Ok(c) => c,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, method, "android: find_class 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) };
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
}
}
}