1785 lines
68 KiB
Rust
1785 lines
68 KiB
Rust
//! 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::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 chanora_protocol::OutPacket;
|
||
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, 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;
|
||
|
||
struct RenderReferenceBuffer {
|
||
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
||
write_idx: std::sync::atomic::AtomicUsize,
|
||
}
|
||
|
||
impl RenderReferenceBuffer {
|
||
fn new() -> Arc<Self> {
|
||
Arc::new(Self {
|
||
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||
})
|
||
}
|
||
|
||
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||
unsafe {
|
||
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
||
as *mut [f32; RENDER_REF_SAMPLES];
|
||
(*slot).copy_from_slice(frame);
|
||
}
|
||
self.write_idx
|
||
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
||
}
|
||
|
||
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
||
self.buf[ri]
|
||
}
|
||
}
|
||
|
||
unsafe impl Send for RenderReferenceBuffer {}
|
||
unsafe impl Sync for 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<i16>,
|
||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||
transmit_active: Arc<AtomicBool>,
|
||
frames_sent: Arc<AtomicU32>,
|
||
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<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,
|
||
transmit_active,
|
||
frames_sent,
|
||
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 {
|
||
let resampled = self.resample_capture_to_48k(samples);
|
||
self.ingest_48k_i16(&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.pending_10ms_len = 0;
|
||
}
|
||
}
|
||
|
||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||
self.pcm_accum.clear();
|
||
return;
|
||
}
|
||
|
||
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.frames_sent,
|
||
&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]) -> Vec<i16> {
|
||
if samples.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
self.resample_scratch.clear();
|
||
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||
let mut pos = self.resample_pos;
|
||
while pos < samples.len() as f64 {
|
||
let i = pos.floor() as isize;
|
||
let frac = pos - i as f64;
|
||
let a = if i <= 0 {
|
||
self.resample_last as f64
|
||
} else {
|
||
samples[(i - 1) as usize] as f64
|
||
};
|
||
let b = if i < samples.len() as isize {
|
||
samples[i as usize] as f64
|
||
} else {
|
||
a
|
||
};
|
||
let value = (a + frac * (b - a))
|
||
.round()
|
||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||
self.resample_scratch.push(value);
|
||
pos += ratio;
|
||
}
|
||
self.resample_pos = pos - samples.len() as f64;
|
||
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
||
self.resample_scratch.clone()
|
||
}
|
||
|
||
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 => {
|
||
let path = crate::vad::silero_model_bundle_path();
|
||
self.silero_vad_worker =
|
||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
||
if self.silero_vad_worker.is_none() {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
||
);
|
||
}
|
||
}
|
||
_ => {
|
||
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;
|
||
}
|
||
|
||
let gain = self.mic_gain;
|
||
if (gain - 1.0).abs() < f32::EPSILON {
|
||
self.pcm_accum
|
||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||
} else {
|
||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
struct InputCallback {
|
||
state: Arc<Mutex<AndroidCaptureState>>,
|
||
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(|| {
|
||
if let Ok(mut s) = self.state.lock() {
|
||
s.ingest_i16(frames);
|
||
}
|
||
}));
|
||
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 mono i16 to the Oboe output buffer.
|
||
|
||
struct OutputCallback {
|
||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||
output_gain: Arc<AtomicU32>,
|
||
output_muted: Arc<AtomicBool>,
|
||
event_tx: BackendEventTx,
|
||
scratch: Arc<Mutex<Vec<f32>>>,
|
||
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 = (i16, Mono);
|
||
|
||
fn on_audio_ready(
|
||
&mut self,
|
||
_stream: &mut dyn AudioOutputStreamSafe,
|
||
frames: &mut [i16],
|
||
) -> DataCallbackResult {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||
let needed = frames.len() * 2; // stereo
|
||
let scratch = &mut self.scratch.lock().unwrap();
|
||
if scratch.len() < needed {
|
||
scratch.resize(needed, 0.0);
|
||
} else {
|
||
for s in &mut scratch[..needed] {
|
||
*s = 0.0;
|
||
}
|
||
}
|
||
match self.handler.try_lock() {
|
||
Ok(mut h) => {
|
||
let _ = h.fill_buffer(&mut scratch[..needed]);
|
||
}
|
||
Err(std::sync::TryLockError::WouldBlock) => {}
|
||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
"AudioHandler mutex poisoned: {}",
|
||
e
|
||
);
|
||
}
|
||
}
|
||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||
let muted = self.output_muted.load(Ordering::Relaxed);
|
||
let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||
&scratch[..needed],
|
||
frames,
|
||
gain,
|
||
muted,
|
||
);
|
||
self.audio_processing_stats
|
||
.update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32);
|
||
|
||
// Accumulate the full render callback into 10 ms mono chunks so
|
||
// AEC sees consistent reference timing even when output callbacks
|
||
// are shorter or longer than 10 ms.
|
||
for chunk in scratch[..needed].chunks_exact(2) {
|
||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 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>>,
|
||
|
||
// 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<jni::objects::GlobalRef>,
|
||
ns: Option<jni::objects::GlobalRef>,
|
||
agc: Option<jni::objects::GlobalRef>,
|
||
}
|
||
|
||
impl AndroidVoiceUnit {
|
||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||
/// once a session id is available, attach SDD-113 hardware
|
||
/// effects. The engine is expected to have already issued
|
||
/// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115
|
||
/// sequencing rules.
|
||
///
|
||
/// `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}")))?,
|
||
));
|
||
|
||
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
|
||
|
||
// --- 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(),
|
||
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()) {
|
||
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::<Mono>()
|
||
.set_format::<i16>()
|
||
.set_performance_mode(if cfg.request_low_latency {
|
||
PerformanceMode::LowLatency
|
||
} else {
|
||
PerformanceMode::None
|
||
})
|
||
.set_sharing_mode(if cfg.request_exclusive {
|
||
SharingMode::Exclusive
|
||
} else {
|
||
SharingMode::Shared
|
||
})
|
||
.set_usage(Usage::VoiceCommunication)
|
||
.set_content_type(oboe::ContentType::Speech);
|
||
|
||
let render_ref_for_output = render_ref_buf.clone();
|
||
let output_cb = OutputCallback {
|
||
handler: params.handler.clone(),
|
||
output_gain: params.output_gain.clone(),
|
||
output_muted: params.output_muted.clone(),
|
||
event_tx: event_tx.clone(),
|
||
scratch: scratch.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,
|
||
params.handler.clone(),
|
||
params.output_gain.clone(),
|
||
params.output_muted.clone(),
|
||
audio_processing_stats.clone(),
|
||
scratch.clone(),
|
||
render_ref_buf,
|
||
)?
|
||
}
|
||
};
|
||
|
||
let output_perf = perf_from_oboe(output_stream.get_performance_mode());
|
||
let output_share = share_from_oboe(output_stream.get_sharing_mode());
|
||
let output_sample_rate = output_stream.get_sample_rate();
|
||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||
|
||
// SDD-112 / SRS-210: structured "stream opened" event with
|
||
// achieved values. No PII; only platform-reported scalars.
|
||
info!(
|
||
target: "chanora_audio",
|
||
event = "audio.android.stream_opened",
|
||
input_perf = %input_perf,
|
||
input_share = %input_share,
|
||
input_sample_rate,
|
||
input_frames_per_burst,
|
||
output_perf = %output_perf,
|
||
output_share = %output_share,
|
||
output_sample_rate,
|
||
output_frames_per_burst,
|
||
session_id = ?session_id,
|
||
"android: voice streams opened"
|
||
);
|
||
|
||
// --- SDD-113 hardware effects -----------------------------
|
||
let hw_effects = if let Some(sid) = session_id {
|
||
attach_hardware_effects(sid, &cfg.effects)
|
||
} else {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
"android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage"
|
||
);
|
||
HardwareEffectHandles::default()
|
||
};
|
||
|
||
// --- SDD-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),
|
||
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>>,
|
||
) -> 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(),
|
||
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,
|
||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||
output_gain: Arc<AtomicU32>,
|
||
output_muted: Arc<AtomicBool>,
|
||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||
scratch: Arc<Mutex<Vec<f32>>>,
|
||
render_reference: Arc<RenderReferenceBuffer>,
|
||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||
let cb = OutputCallback {
|
||
handler,
|
||
output_gain,
|
||
output_muted,
|
||
event_tx: event_tx.clone(),
|
||
scratch,
|
||
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::<Mono>()
|
||
.set_format::<i16>()
|
||
.set_performance_mode(PerformanceMode::LowLatency)
|
||
.set_sharing_mode(SharingMode::Shared)
|
||
.set_usage(Usage::VoiceCommunication)
|
||
.set_content_type(oboe::ContentType::Speech)
|
||
.set_callback(cb);
|
||
builder
|
||
.open_stream()
|
||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||
}
|
||
|
||
/// Clone of the event sender, for JNI focus / SCO listeners
|
||
/// registered on the engine's behalf.
|
||
pub fn event_sender(&self) -> BackendEventTx {
|
||
self.event_tx.clone()
|
||
}
|
||
}
|
||
|
||
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.
|
||
release_hardware_effects(&mut self.hw_effects);
|
||
self.stop().ok();
|
||
// Dropping the Option drops the underlying AudioStreamAsync
|
||
// which Oboe-safe-closes the stream.
|
||
self.input = None;
|
||
self.output = None;
|
||
// SDD-116: clear the diagnostics slot so a stale snapshot
|
||
// does not survive past the voice session.
|
||
clear_android_audio_diagnostics();
|
||
Ok(())
|
||
}
|
||
|
||
fn session_id(&self) -> Option<AudioSessionId> {
|
||
self.session_id
|
||
}
|
||
|
||
fn take_event_rx(&mut self) -> Option<BackendEventRx> {
|
||
self.event_rx.take()
|
||
}
|
||
|
||
fn achieved_sample_rate(&self) -> u32 {
|
||
self.input_sample_rate.max(0) as u32
|
||
}
|
||
|
||
fn achieved_input_preset(&self) -> AchievedInputPreset {
|
||
// The oboe-rs wrapper does not expose a preset-readback as of
|
||
// 0.6.x; record `Unknown` until a readback path lands
|
||
// (SWE4-UV-052 follow-through).
|
||
AchievedInputPreset::Unknown
|
||
}
|
||
|
||
fn achieved_frames_per_burst(&self) -> u32 {
|
||
self.input_frames_per_burst.max(0) as u32
|
||
}
|
||
|
||
fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode {
|
||
self.input_perf
|
||
}
|
||
fn achieved_input_sharing_mode(&self) -> AchievedSharingMode {
|
||
self.input_share
|
||
}
|
||
fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode {
|
||
self.output_perf
|
||
}
|
||
fn achieved_output_sharing_mode(&self) -> AchievedSharingMode {
|
||
self.output_share
|
||
}
|
||
}
|
||
|
||
impl Drop for AndroidVoiceUnit {
|
||
fn drop(&mut self) {
|
||
// Belt-and-braces: if `close()` was not called explicitly,
|
||
// tear hardware effects down here so the JNI globals are
|
||
// released before the stream's session id evaporates.
|
||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||
// into the JVM (SDD-115 callback safety).
|
||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||
release_hardware_effects(&mut self.hw_effects);
|
||
// SDD-116: clear the diagnostics slot on Drop too.
|
||
clear_android_audio_diagnostics();
|
||
}));
|
||
}
|
||
}
|
||
|
||
// --- helpers -----------------------------------------------------
|
||
|
||
/// Derive per-effect engagement (SDD-113 item 7) from the request
|
||
/// mask and the JNI binding outcome. Hardware: JNI ref retained.
|
||
/// Software: requested but hardware binding failed → engine
|
||
/// software AEC/NS/AGC carries it. None: not requested.
|
||
fn effect_engagement(requested: bool, hardware_bound: bool) -> EffectEngagement {
|
||
match (requested, hardware_bound) {
|
||
(true, true) => EffectEngagement {
|
||
engaged: true,
|
||
engine: EffectEngine::Hardware,
|
||
},
|
||
(true, false) => EffectEngagement {
|
||
engaged: true,
|
||
engine: EffectEngine::Software,
|
||
},
|
||
(false, _) => EffectEngagement {
|
||
engaged: false,
|
||
engine: EffectEngine::None,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode {
|
||
match p {
|
||
PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency,
|
||
PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving,
|
||
PerformanceMode::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 {
|
||
let ctx = ndk_context::android_context();
|
||
if ctx.vm().is_null() {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||
);
|
||
return HardwareEffectHandles::default();
|
||
}
|
||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
||
return HardwareEffectHandles::default();
|
||
}
|
||
};
|
||
let mut env = match jvm.attach_current_thread() {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
||
return HardwareEffectHandles::default();
|
||
}
|
||
};
|
||
|
||
let mut handles = HardwareEffectHandles::default();
|
||
if effects.aec {
|
||
handles.aec = create_effect(
|
||
&mut env,
|
||
"android/media/audiofx/AcousticEchoCanceler",
|
||
session_id,
|
||
"AEC",
|
||
);
|
||
}
|
||
if effects.noise_suppression {
|
||
handles.ns = create_effect(
|
||
&mut env,
|
||
"android/media/audiofx/NoiseSuppressor",
|
||
session_id,
|
||
"NS",
|
||
);
|
||
}
|
||
if effects.agc {
|
||
handles.agc = create_effect(
|
||
&mut env,
|
||
"android/media/audiofx/AutomaticGainControl",
|
||
session_id,
|
||
"AGC",
|
||
);
|
||
}
|
||
handles
|
||
}
|
||
|
||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||
/// class before calling `create(int)`. Returns `false` on any JNI
|
||
/// failure so the caller engages the software fallback.
|
||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
||
Ok(v) => match v.z() {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() return cast failed");
|
||
false
|
||
}
|
||
},
|
||
Err(e) => {
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() threw");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn create_effect(
|
||
env: &mut jni::JNIEnv,
|
||
fqcn: &str,
|
||
session_id: AudioSessionId,
|
||
label: &str,
|
||
) -> Option<jni::objects::GlobalRef> {
|
||
use jni::objects::JValue;
|
||
// Class.create(int) -> ClassInstance|null
|
||
let class = match env.find_class(fqcn) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||
return None;
|
||
}
|
||
};
|
||
// SDD-113 item 3: probe isAvailable() before create(int).
|
||
if !effect_is_available(env, &class, label) {
|
||
info!(
|
||
target: "chanora_audio",
|
||
effect = label,
|
||
"android: hardware effect not available on this device — software fallback engages"
|
||
);
|
||
return None;
|
||
}
|
||
let inst = match env.call_static_method(
|
||
&class,
|
||
"create",
|
||
&format!("(I)L{fqcn};"),
|
||
&[JValue::Int(session_id)],
|
||
) {
|
||
Ok(v) => match v.l() {
|
||
Ok(o) => o,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() return cast failed");
|
||
return None;
|
||
}
|
||
},
|
||
Err(e) => {
|
||
// Likely an exception in JNI — clear so the next JNI
|
||
// call doesn't immediately abort.
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() threw — software fallback engages");
|
||
return None;
|
||
}
|
||
};
|
||
if inst.is_null() {
|
||
warn!(target: "chanora_audio", effect = label, "android: create() returned null (unsupported on device) — software fallback engages");
|
||
return None;
|
||
}
|
||
// setEnabled(true) -> int (success code)
|
||
if let Err(e) = env.call_method(
|
||
&inst,
|
||
"setEnabled",
|
||
"(Z)I",
|
||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||
) {
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: setEnabled(true) failed — software fallback engages");
|
||
return None;
|
||
}
|
||
match env.new_global_ref(&inst) {
|
||
Ok(g) => {
|
||
info!(target: "chanora_audio", effect = label, session_id, "android: hardware effect bound (SDD-113)");
|
||
Some(g)
|
||
}
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, effect = label, "android: new_global_ref failed");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
fn release_hardware_effects(handles: &mut HardwareEffectHandles) {
|
||
let result = catch_unwind(AssertUnwindSafe(|| release_hardware_effects_inner(handles)));
|
||
if result.is_err() {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
"android: release_hardware_effects panicked; caught at FFI boundary"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
||
let aec = handles.aec.take();
|
||
let ns = handles.ns.take();
|
||
let agc = handles.agc.take();
|
||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||
return;
|
||
}
|
||
let ctx = ndk_context::android_context();
|
||
if ctx.vm().is_null() {
|
||
return;
|
||
}
|
||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||
Ok(v) => v,
|
||
Err(_) => return,
|
||
};
|
||
let mut env = match jvm.attach_current_thread() {
|
||
Ok(e) => e,
|
||
Err(_) => return,
|
||
};
|
||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||
if let Some(g) = effect {
|
||
let _ = env.call_method(
|
||
g.as_obj(),
|
||
"setEnabled",
|
||
"(Z)I",
|
||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||
);
|
||
let _ = env.exception_clear();
|
||
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
||
let _ = env.exception_clear();
|
||
drop(g);
|
||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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.vm().is_null() || ctx.context().is_null() {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
method,
|
||
"android: ndk_context not initialised; voice service call skipped"
|
||
);
|
||
return false;
|
||
}
|
||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
||
// the process lifetime.
|
||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||
return false;
|
||
}
|
||
};
|
||
let mut env = match jvm.attach_current_thread() {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
||
return false;
|
||
}
|
||
};
|
||
// SAFETY: ndk_context::context() is the application Context
|
||
// jobject; valid global ref for process lifetime.
|
||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||
Some(c) => c,
|
||
None => return false,
|
||
};
|
||
match env.call_static_method(
|
||
&class,
|
||
method,
|
||
"(Landroid/content/Context;)V",
|
||
&[JValue::Object(&context_obj)],
|
||
) {
|
||
Ok(_) => {
|
||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||
true
|
||
}
|
||
Err(e) => {
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn load_app_class<'local>(
|
||
env: &mut jni::JNIEnv<'local>,
|
||
context_obj: &jni::objects::JObject<'local>,
|
||
slash_name: &str,
|
||
) -> Option<jni::objects::JClass<'local>> {
|
||
match env.find_class(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,
|
||
"getClassLoader",
|
||
"()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,
|
||
"loadClass",
|
||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||
&[jni::objects::JValue::Object(&class_name_obj)],
|
||
)
|
||
.and_then(|v| v.l())
|
||
{
|
||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||
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::JNIEnv<'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::JNIEnv<'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.vm().is_null() || ctx.context().is_null() {
|
||
warn!(
|
||
target: "chanora_audio",
|
||
class = fqcn,
|
||
method,
|
||
"android: ndk_context not initialised; call skipped"
|
||
);
|
||
return false;
|
||
}
|
||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
||
return false;
|
||
}
|
||
};
|
||
let mut env = match jvm.attach_current_thread() {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
||
return false;
|
||
}
|
||
};
|
||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
||
Some(c) => c,
|
||
None => return false,
|
||
};
|
||
match env.call_static_method(
|
||
&class,
|
||
method,
|
||
"(Landroid/content/Context;)V",
|
||
&[JValue::Object(&context_obj)],
|
||
) {
|
||
Ok(_) => {
|
||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||
true
|
||
}
|
||
Err(e) => {
|
||
let _ = env.exception_clear();
|
||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||
false
|
||
}
|
||
}
|
||
}
|