//! 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`
//! - ``
//!
//! 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")]
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder;
use tracing::{debug, info, warn};
use crate::audio_event_queue::{AudioCommand, AudioEventQueue};
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, VoiceAudioParams,
};
use tsclientlib::audio::AudioHandler;
use crate::{engine::SessionAudioId, AudioError};
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, Stereo, Usage,
};
use crate::processor::AudioProcessor;
// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
// --- Render-reference buffer for AEC (SDD-111 / SDD-120) ---------
//
// The output (render) callback writes the audio that will be played
// into this ring buffer. The capture callback reads the latest render
// frame and feeds it to WebRTC APM's `process_render` so AEC can
// subtract the speaker output from the microphone input.
//
// 4 slots × 10 ms × 48 kHz mono f32. One slot is always being written
// by the render callback; the capture callback reads the slot that was
// most recently completed.
const RENDER_REF_SLOTS: usize = 4;
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
type RenderReferenceBuffer =
crate::render_reference::RenderReferenceBuffer;
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Enhanced with WebRTC APM (AEC/NS/AGC) and VAD (voice activity
// detection). Oboe delivers 48 kHz mono i16 PCM in variable-size
// chunks. We accumulate into 10 ms frames, then:
//
// 1. i16 → f32 conversion
// 2. Read render reference (for AEC)
// 3. WebRtcApmProcessor::process_render + process_capture
// 4. VAD → VoiceActivityStateMachine → TransmitModeSelector
// 5. f32 → i16 conversion + mic gain
// 6. Accumulate to 20 ms → Opus encode → send
struct AndroidCaptureState {
encoder: OpusEncoder,
pcm_accum: Vec,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc,
mic_gain: f32,
voice_activity_selector: Option>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Option,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
audio_processing_config: Arc>,
audio_processing_stats: Arc,
render_reference: Arc,
input_sample_rate_hz: u32,
resample_pos: f64,
resample_last: i16,
resample_scratch: Vec,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
fallback_warned_backend: Option,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender,
transmit_active: Arc,
frames_sent: Arc,
mic_gain: f32,
voice_activity_selector: Option>,
audio_processing_config: Arc>,
audio_processing_stats: Arc,
render_reference: Arc,
input_sample_rate_hz: u32,
) -> Result {
let encoder = crate::opus_voice::new_voip_encoder("android")?;
// Seed the processor from the current shared config snapshot.
// `open()` may later resolve Platform-owned stages to WebRTC
// fallback (or keep them hardware-owned) once hardware-effect
// binding completes; that resolved config is pushed back into
// the live processor before the streams are started.
let webrtc_apm_config = audio_processing_config
.lock()
.map(|cfg| webrtc_apm_config_from_audio_config(&cfg))
.unwrap_or_default();
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"android",
)?,
transmit_active,
mic_gain,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_config,
audio_processing_stats,
render_reference,
input_sample_rate_hz: input_sample_rate_hz.max(1),
resample_pos: 0.0,
resample_last: 0,
resample_scratch: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
fallback_warned_backend: None,
})
}
/// Consume i16 mono frames from Oboe. Accumulate to 10 ms chunks,
/// process each through WebRTC APM + VAD, then encode 20 ms frames.
fn ingest_i16(&mut self, samples: &[i16]) {
self.audio_processing_stats
.record_callback_frames(samples.len() as u64);
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
self.resample_capture_to_48k(samples);
let resampled = std::mem::take(&mut self.resample_scratch);
self.ingest_48k_i16(&resampled);
self.resample_scratch = resampled;
return;
}
self.ingest_48k_i16(samples);
}
fn ingest_48k_i16(&mut self, samples: &[i16]) {
let mut offset = 0;
while offset < samples.len() {
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(samples.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&samples[offset..offset + take]);
self.pending_10ms_len += take;
offset += take;
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame);
self.encode_complete_20ms_frames();
self.pending_10ms_len = 0;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
self.encode_complete_20ms_frames();
}
fn encode_complete_20ms_frames(&mut self) {
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"android Oboe: voice_out queue full; dropping frame"
);
},
|| {
debug!(
target: "chanora_audio",
"android Oboe: voice_out closed; capture pipeline stopping"
);
},
);
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"android Oboe opus encode failed"
);
}
}
}
}
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
let result = crate::capture_resampler::resample_capture_to_48k(
samples,
self.input_sample_rate_hz,
&mut self.resample_pos,
&mut self.resample_last,
&mut self.resample_scratch,
);
if result.dropped {
self.audio_processing_stats.increment_callback_xrun();
}
result.output_len
}
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
self.input_sample_rate_hz = sample_rate_hz.max(1);
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
// Warm-up period: ONNX workers need ~100ms to process first frame.
// Don't flag as a problem if the capture has just started.
if self.capture_frame_seq < 128 {
info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"android: VAD backend warming up; using WebRTC fallback"
);
} else {
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
*dst = crate::frame::i16_to_f32(src);
}
let input_dbfs = crate::frame::dbfs(&frame);
let render_ref = self.render_reference.read_latest();
self.webrtc_apm_processor.process_render(&render_ref);
self.webrtc_apm_processor.process_capture(&mut frame);
let voice_activity_mode = self
.voice_activity_selector
.as_ref()
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
.unwrap_or(false);
if !voice_activity_mode {
self.silero_vad_worker = None;
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
let (vad_hangover, vad_backend) = self
.audio_processing_config
.try_lock()
.map(|cfg| (cfg.vad_hangover_ms, cfg.vad_backend))
.unwrap_or((
crate::voice_activity::VAD_HANGOVER_MS,
crate::VadBackend::WebrtcVad,
));
if voice_activity_mode {
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
}
// VAD backend switching only while VoiceActivity mode is active.
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed = voice_activity_mode
&& vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_changed) {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
self.silero_vad_worker = None;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
}
_ => {
self.silero_vad_worker = None;
}
}
self.vad_state.reset();
}
let (vad_probability, active) = if voice_activity_mode {
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
let capture_seq = self.capture_frame_seq;
let mut used_fallback_vad = false;
let vad = if vad_backend == crate::VadBackend::Disabled {
crate::vad::VadOutput {
probability: 1.0,
speech: true,
}
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
(vad.probability, self.vad_state.update(vad.speech))
} else {
(0.0, false)
};
if let Some(sel) = &self.voice_activity_selector {
sel.set_voice_activity_open(voice_activity_mode && active);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad_probability,
voice_activity_mode && active,
self.transmit_active.load(Ordering::Relaxed),
);
self.audio_processing_stats
.record_capture_frame(frame.iter().all(|sample| sample.abs() < 1.0e-6));
if !self.transmit_active.load(Ordering::Relaxed) {
return;
}
if crate::capture_accumulator::append_processed_i16_bounded(
&mut self.pcm_accum,
&frame,
self.mic_gain,
) {
self.audio_processing_stats.increment_callback_xrun();
}
}
}
struct InputCallback {
state: Arc>,
audio_processing_stats: Arc,
event_tx: BackendEventTx,
}
impl AudioInputCallback for InputCallback {
type FrameType = (i16, Mono);
fn on_audio_ready(
&mut self,
_stream: &mut dyn AudioInputStreamSafe,
frames: &[i16],
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
Ok(mut s) => s.ingest_i16(frames),
Err(std::sync::TryLockError::WouldBlock) => {
self.audio_processing_stats.increment_callback_xrun();
}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
}
}));
DataCallbackResult::Continue
}
fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) {
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");
}
}
}
// --- Output callback wiring (SDD-111 / SDD-120) ----
//
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
// from `AudioHandler::fill_buffer`, applies output gain + mute, and
// writes stereo f32 directly to the Oboe output buffer.
struct OutputCallback {
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
output_gain: Arc,
output_muted: Arc,
event_tx: BackendEventTx,
render_reference: Arc,
audio_processing_stats: Arc,
pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES],
pending_render_ref_len: usize,
}
impl AudioOutputCallback for OutputCallback {
type FrameType = (f32, Stereo);
fn on_audio_ready(
&mut self,
_stream: &mut dyn AudioOutputStreamSafe,
frames: &mut [(f32, f32)],
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| {
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 frame in frames.iter_mut() {
*frame = (0.0, 0.0);
}
} else if gain != 1.0 {
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(dbfs, frames.len() as u32);
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);
self.pending_render_ref_len = 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>,
output: Option>,
render_producer_shutdown: Arc,
// Recorded achieved values (SDD-112).
input_perf: AchievedPerformanceMode,
input_share: AchievedSharingMode,
output_perf: AchievedPerformanceMode,
output_share: AchievedSharingMode,
input_sample_rate: i32,
input_frames_per_burst: i32,
session_id: Option,
// 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,
}
#[derive(Default)]
struct HardwareEffectHandles {
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
/// effects. The engine is expected to have already issued
/// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115
/// sequencing rules.
///
/// `params` bundles the engine-owned state shared with the Oboe
/// audio callbacks (SDD-120 amendment: Android Oboe-only audio
/// path — capture pipeline, playback pull, and PTT gate).
pub(crate) fn open(
cfg: &AndroidVoiceStreamConfig,
params: VoiceAudioParams,
) -> Result {
let (event_tx, event_rx) = mpsc::unbounded_channel();
// Shared render-reference buffer (for AEC). The output
// callback writes; the capture callback reads.
let render_ref_buf = RenderReferenceBuffer::new();
let render_ref_for_capture = render_ref_buf.clone();
// Clone the APM config Arc before params is partially moved
// into the capture state constructor below.
let apm_config_clone = params.audio_processing_config.clone();
let audio_processing_stats = params.audio_processing_stats.clone();
let capture_state = Arc::new(Mutex::new(
AndroidCaptureState::new(
params.voice_out_tx,
params.transmit_active,
params.frames_sent,
params.mic_gain,
params.voice_activity_selector,
params.audio_processing_config,
audio_processing_stats.clone(),
render_ref_for_capture,
cfg.sample_rate,
)
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
));
// --- Open input stream (SDD-112) ---------------------------
let input_builder = AudioStreamBuilder::default()
.set_direction::()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::()
.set_format::()
.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 {
state: capture_state.clone(),
audio_processing_stats: audio_processing_stats.clone(),
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) => Some(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"
);
match Self::open_input_fallback(
cfg,
&event_tx,
capture_state.clone(),
audio_processing_stats.clone(),
) {
Ok(s) => Some(s),
Err(fallback_err) => {
warn!(
target: "chanora_audio",
error = %fallback_err,
"android: input unavailable after all fallbacks; continuing listen-only with output stream"
);
None
}
}
}
};
let input_perf = input_stream
.as_ref()
.map(|s| perf_from_oboe(s.get_performance_mode()))
.unwrap_or(AchievedPerformanceMode::None);
let input_share = input_stream
.as_ref()
.map(|s| share_from_oboe(s.get_sharing_mode()))
.unwrap_or(AchievedSharingMode::Shared);
let input_sample_rate = input_stream
.as_ref()
.map(|s| s.get_sample_rate())
.unwrap_or(0);
let input_frames_per_burst = input_stream
.as_mut()
.map(|s| s.get_frames_per_burst())
.unwrap_or(0);
// The oboe-rs fork (edisonjwa/oboe-rs 0.6.2) fixes the
// get_session_id() panic with unwrap_or_default(), but the
// SessionId enum still only models builder parameters (None =
// -1, Allocate = 0). The actual system audio session ID (>0)
// written by AAudio to mSessionId after stream open cannot be
// expressed in the current enum; get_raw_session_id() is a
// follow-up addition to the fork.
//
// For now: hardware effects require the system session ID.
// WebRTC APM software processing handles AEC/NS/AGC/HPF.
let session_id: Option = input_stream.as_ref().and_then(|s| s.get_raw_session_id());
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
.set_direction::()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::()
.set_format::()
.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
})
// Usage::Game avoids forcing the Legacy (OpenSL ES) data path
// that Usage::VoiceCommunication triggers on most devices.
// Android audio routing is already handled by
// AudioManager.MODE_IN_COMMUNICATION on the Flutter side.
.set_usage(Usage::Game)
.set_content_type(oboe::ContentType::Sonification);
let render_ref_for_output = render_ref_buf.clone();
let event_queue = params.event_producer.queue();
let render_ring =
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
let output_cb = OutputCallback {
pcm_consumer: render_ring.consumer(),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
render_reference: render_ref_for_output,
audio_processing_stats: audio_processing_stats.clone(),
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
pending_render_ref_len: 0,
};
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,
render_ring.consumer(),
params.output_gain.clone(),
params.output_muted.clone(),
audio_processing_stats.clone(),
render_ref_buf,
)?
}
};
let render_producer_shutdown = Self::spawn_render_producer(
params.handler,
AudioEventQueue::consumer(&event_queue),
render_ring.producer(),
);
let output_frames_per_burst = output_stream.get_frames_per_burst();
if output_frames_per_burst > 0 {
let desired = output_frames_per_burst * 2;
match output_stream.set_buffer_size_in_frames(desired) {
Ok(actual) => {
debug!(
target: "chanora_audio",
desired,
actual,
"android: output buffer size tuned"
);
}
Err(e) => {
warn!(
target: "chanora_audio",
error = ?e,
"android: output buffer size tuning failed; using device default"
);
}
}
}
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();
// 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-113 config resolution: hardware-available? -------
// Android gives the user a choice between hardware (JNI) and
// software (WebRTC APM) effects. The AudioProcessingConfig's
// EffectOwner fields encode that choice:
// Platform → prefer hardware; software fallback if missing
// WebrtcApm → always software WebRTC APM
// Off → disable effect entirely
//
// Here we resolve Platform → WebrtcApm for each effect whose
// hardware binding failed (or wasn't attempted). This is read
// by the capture callback's WebRtcApmProcessor.
{
use crate::audio_processing::EffectOwner;
let mut apm_cfg = apm_config_clone.lock().unwrap();
let hw_aec = hw_effects.aec.is_some();
let hw_ns = hw_effects.ns.is_some();
let hw_agc = hw_effects.agc.is_some();
if apm_cfg.aec == EffectOwner::Platform && !hw_aec {
apm_cfg.aec = EffectOwner::WebrtcApm;
}
if apm_cfg.ns == EffectOwner::Platform && !hw_ns {
apm_cfg.ns = EffectOwner::WebrtcApm;
}
if apm_cfg.agc == EffectOwner::Platform && !hw_agc {
apm_cfg.agc = EffectOwner::WebrtcApm;
}
if apm_cfg.processing_backend
== crate::audio_processing::AudioBackend::PlatformVoiceProcessing
&& (!hw_aec || !hw_ns || !hw_agc)
{
apm_cfg.processing_backend = crate::audio_processing::AudioBackend::WebrtcApm;
}
info!(
target: "chanora_audio",
aec = ?apm_cfg.aec,
ns = ?apm_cfg.ns,
agc = ?apm_cfg.agc,
hpf = apm_cfg.hpf_enabled,
hw_aec,
hw_ns,
hw_agc,
session_id,
"android: audio processing config resolved (hardware effects: aec={hw_aec} ns={hw_ns} agc={hw_agc})"
);
}
if let Ok(mut capture) = capture_state.lock() {
capture.set_input_sample_rate_hz(input_sample_rate.max(1) as u32);
let resolved_cfg = apm_config_clone
.lock()
.map(|cfg| webrtc_apm_config_from_audio_config(&cfg))
.unwrap_or_default();
capture.webrtc_apm_processor.apply_config(resolved_cfg);
}
// --- 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);
params
.audio_processing_stats
.set_actual_sample_rate_hz(input_sample_rate.max(0) as u32);
Ok(Self {
input: input_stream,
output: Some(output_stream),
render_producer_shutdown,
input_perf,
input_share,
output_perf,
output_share,
input_sample_rate,
input_frames_per_burst,
session_id,
hw_effects,
event_tx,
event_rx: Some(event_rx),
})
}
fn open_input_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
capture_state: Arc>,
audio_processing_stats: Arc,
) -> Result, 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 = Vec::new();
while let Some(preset_choice) = next_input_preset_after(&attempted_presets) {
attempted_presets.push(preset_choice);
let mut attempted_sharings: Vec = 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 {
state: capture_state.clone(),
audio_processing_stats: audio_processing_stats.clone(),
event_tx: event_tx.clone(),
};
let builder = AudioStreamBuilder::default()
.set_direction::()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::()
.set_format::()
.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,
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
output_gain: Arc,
output_muted: Arc,
audio_processing_stats: Arc,
render_reference: Arc,
) -> Result, BackendError> {
let cb = OutputCallback {
pcm_consumer,
output_gain,
output_muted,
event_tx: event_tx.clone(),
render_reference,
audio_processing_stats,
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
pending_render_ref_len: 0,
};
let builder = AudioStreamBuilder::default()
.set_direction::()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::()
.set_format::()
.set_performance_mode(PerformanceMode::LowLatency)
.set_sharing_mode(SharingMode::Shared)
// Same Usage::Game rationale as primary output builder above.
.set_usage(Usage::Game)
.set_content_type(oboe::ContentType::Sonification)
.set_callback(cb);
builder
.open_stream()
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
}
fn spawn_render_producer(
mut handler: AudioHandler,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
) -> Arc {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
tokio::spawn(async move {
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
if shutdown_for_task.load(Ordering::Relaxed) {
break;
}
for cmd in event_consumer.drain_controls() {
match cmd {
AudioCommand::SetVolume(id, vol) => {
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
q.volume = vol;
}
}
AudioCommand::RemoveClient(id) => {
handler.get_mut_queues().remove(&id);
}
}
}
for pkt in event_consumer.drain_packets(50) {
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
debug!(target: "chanora_audio", error = %e, "decode failed");
}
}
pull_scratch.fill(0.0);
let _ = handler.fill_buffer(&mut pull_scratch);
pcm_producer.push_frame_lossy(&pull_scratch);
}
});
shutdown
}
/// 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()
}
}
fn webrtc_apm_config_from_audio_config(
config: &crate::AudioProcessingConfig,
) -> crate::processor::webrtc_apm::WebRtcApmConfig {
crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(config)
}
impl MobileVoiceAudioBackend for AndroidVoiceUnit {
fn start(&mut self) -> Result<(), BackendError> {
if let Some(s) = self.input.as_mut() {
if let Err(e) = s.start() {
warn!(
target: "chanora_audio",
error = ?e,
"android: input start failed; continuing listen-only with output stream"
);
self.input = None;
}
}
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.
self.render_producer_shutdown.store(true, Ordering::Relaxed);
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 {
self.session_id
}
fn take_event_rx(&mut self) -> Option {
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(|| {
self.render_producer_shutdown.store(true, Ordering::Relaxed);
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::PowerSavingOffloaded => AchievedPerformanceMode::PowerSaving,
PerformanceMode::None => AchievedPerformanceMode::None,
}
}
fn share_from_oboe(s: SharingMode) -> AchievedSharingMode {
match s {
SharingMode::Exclusive => AchievedSharingMode::Exclusive,
SharingMode::Shared => AchievedSharingMode::Shared,
}
}
// --- 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 {
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",
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::(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::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) => {
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::Env<'_>,
fqcn: &str,
session_id: AudioSessionId,
label: &str,
) -> Option {
use jni::objects::JValue;
// Class.create(int) -> ClassInstance|null
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");
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 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,
jni::jni_str!("create"),
create_sig.method_signature(),
&[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,
jni::jni_str!("setEnabled"),
jni::jni_sig!("(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 _ = 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 --------
//
// Kotlin-side listeners (audio focus, Bluetooth SCO, device route
// changes) need to publish events into the Rust engine's event
// channel. Since the engine's `BackendEventTx` is created at voice
// start, we store it here as a process-global so the JNI callbacks
// can reach it without holding a direct Rust reference.
//
// Cleared on voice stop; the Kotlin listeners are idempotent when
// no sender is registered (they log and continue).
static GLOBAL_BACKEND_EVENT_TX: std::sync::OnceLock<
std::sync::Mutex