Files
chanora/crates/chanora_audio/src/android_voice_unit.rs
T
Edison Jwa 08d7ace25d fix(audio,flutter): restore VAD on iOS and fix Android build
- Add iOS to voiceActivityTransmitAvailable — iOS has CoreML Silero
  VAD pipeline (AppleCoreMlVadWorker) but was excluded by DEC-030
  gating that predated the CoreML integration
- Inline deleted VadWorkerPolicy in android_voice_unit.rs — PR #37
  removed the enum from vad/mod.rs but missed updating Android
2026-06-10 09:18:32 +09:00

1821 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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")]
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<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
// --- 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<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
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<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
input_sample_rate_hz: u32,
resample_pos: f64,
resample_last: i16,
resample_scratch: Vec<i16>,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
fallback_warned_backend: Option<crate::VadBackend>,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
input_sample_rate_hz: u32,
) -> Result<Self, AudioError> {
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<Mutex<AndroidCaptureState>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
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<AtomicU32>,
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
render_reference: Arc<RenderReferenceBuffer>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
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<AudioStreamAsync<OboeInput, InputCallback>>,
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
render_producer_shutdown: Arc<AtomicBool>,
// 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<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<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
/// 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<Self, BackendError> {
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::<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 {
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<i32> = input_stream.as_ref().and_then(|s| s.get_raw_session_id());
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Stereo>()
.set_format::<f32>()
.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<Mutex<AndroidCaptureState>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> 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 {
state: capture_state.clone(),
audio_processing_stats: audio_processing_stats.clone(),
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,
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, 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::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Stereo>()
.set_format::<f32>()
.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<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
) -> Arc<AtomicBool> {
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<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(|| {
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<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::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<AndroidGlobalObject> {
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<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>>,
> = std::sync::OnceLock::new();
fn global_event_tx_slot(
) -> &'static std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>> {
GLOBAL_BACKEND_EVENT_TX.get_or_init(|| std::sync::Mutex::new(None))
}
pub(crate) fn register_global_event_sender(tx: BackendEventTx) {
if let Ok(mut g) = global_event_tx_slot().lock() {
*g = Some(tx);
}
}
pub(crate) fn clear_global_event_sender() {
if let Ok(mut g) = global_event_tx_slot().lock() {
*g = None;
}
}
fn try_send_backend_event(event: BackendEvent) {
if let Ok(g) = global_event_tx_slot().lock() {
if let Some(tx) = g.as_ref() {
if tx.send(event).is_err() {
warn!(
target: "chanora_audio",
"android: global BackendEvent channel closed; event dropped"
);
}
}
}
}
// --- 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.context().is_null() {
warn!(
target: "chanora_audio",
method,
"android: ndk_context not initialised; voice service call skipped"
);
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
}
}
})
.unwrap_or(false)
}
fn load_app_class<'local>(
env: &mut jni::Env<'local>,
context_obj: &jni::objects::JObject<'local>,
slash_name: &str,
) -> Option<jni::objects::JClass<'local>> {
match env.find_class(jni::strings::JNIString::new(slash_name)) {
Ok(c) => return Some(c),
Err(e) => {
let _ = env.exception_clear();
debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
}
}
let loader = match env
.call_method(
context_obj,
jni::jni_str!("getClassLoader"),
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
&[],
)
.and_then(|v| v.l())
{
Ok(loader) => loader,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, "android: Context.getClassLoader failed");
return None;
}
};
let dotted_name = slash_name.replace('/', ".");
let class_name = match env.new_string(&dotted_name) {
Ok(s) => s,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: class-name string allocation failed");
return None;
}
};
let class_name_obj = jni::objects::JObject::from(class_name);
match env
.call_method(
&loader,
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) => 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");
None
}
}
}
// --- SDD-109 / SDD-110 JNI callbacks: focus + SCO events ----------
//
// Kotlin-side OnAudioFocusChangeListener and BroadcastReceiver for
// ACTION_SCO_AUDIO_STATE_UPDATED call these Rust entry points via
// JNI. Each function marshals the platform event into a BackendEvent
// and posts it through the global event sender registered by the
// engine at voice start.
//
// SDD-115 callback safety: every entry point is wrapped in
// catch_unwind so a panic in the Rust engine can never unwind
// into the JVM.
/// SDD-109: audio focus change published by Kotlin's
/// `AndroidAudioFocusController`. `state` is the `focusChange`
/// value from `OnAudioFocusChangeListener`.
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidAudioFocusController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
'local,
>(
_env: jni::EnvUnowned<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
let _ = catch_unwind(AssertUnwindSafe(|| {
// AUDIOFOCUS_LOSS = -1, LOSS_TRANSIENT = -2, LOSS_TRANSIENT_CAN_DUCK = -3,
// GAIN = 1 (AudioManager.AUDIOFOCUS_REQUEST_GRANTED is also 1, but we only
// call this from the listener callback so the values are well-known).
let event = match state {
-1 => BackendEvent::FocusLost,
-2 => BackendEvent::FocusTransient,
-3 => BackendEvent::FocusTransientCanDuck,
1 | 2 | 3 | 4 => BackendEvent::FocusGain,
_ => {
warn!(
target: "chanora_audio",
state,
"android: unknown audio focus change value; treating as FocusLost"
);
BackendEvent::FocusLost
}
};
try_send_backend_event(event);
}));
}
/// SDD-110: Bluetooth SCO state change published by Kotlin's
/// `AndroidBluetoothScoController`. `state` is the `STATE`
/// value from `ACTION_SCO_AUDIO_STATE_UPDATED`:
/// - `ACTION_SCO_AUDIO_STATE_UPDATED` is always fired with `EXTRA_SCO_AUDIO_STATE`
/// - `SCO_STATE_CONNECTING = 0`, `SCO_STATE_CONNECTED = 1`, `SCO_STATE_DISCONNECTED = 2`
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidBluetoothScoController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
'local,
>(
_env: jni::EnvUnowned<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
let _ = catch_unwind(AssertUnwindSafe(|| {
try_send_backend_event(BackendEvent::BluetoothScoStateChanged(state));
}));
}
/// SDD-115 integration: start the Android audio focus listener.
/// Called by the engine after the voice unit is started. Uses JNI
/// to invoke `AndroidAudioFocusController.start(Context)`.
pub fn chanora_android_request_audio_focus() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidAudioFocusController",
"start",
)
}
/// SDD-115 integration: stop the Android audio focus listener.
/// Called by the engine on voice stop.
pub fn chanora_android_abandon_audio_focus() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidAudioFocusController",
"stop",
)
}
/// SDD-115 integration: start Bluetooth SCO.
/// Called by the engine after the voice unit is started.
pub fn chanora_android_start_bluetooth_sco() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidBluetoothScoController",
"start",
)
}
/// SDD-115 integration: stop Bluetooth SCO.
/// Called by the engine on voice stop.
pub fn chanora_android_stop_bluetooth_sco() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidBluetoothScoController",
"stop",
)
}
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
use jni::objects::{JObject, JValue};
let ctx = ndk_context::android_context();
if ctx.context().is_null() {
warn!(
target: "chanora_audio",
class = fqcn,
method,
"android: ndk_context not initialised; call skipped"
);
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
}
}
})
.unwrap_or(false)
}