Files
chanora/crates/chanora_audio/src/android_voice_unit.rs
T

1216 lines
46 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 audiopus::{Application as OpusApp, Channels as OpusChannels, SampleRate as OpusSampleRate};
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,
};
use chanora_protocol::{AudioData, CodecType, OutAudio, 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,
};
// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
/// Matches the iOS and desktop constants; duplicated here so this
/// module is fully self-contained and cfg-gate-clean.
const FRAME_SAMPLES: usize = 960;
/// Maximum size of an encoded Opus frame in bytes (RFC 6716 §3.2.1).
const MAX_OPUS_FRAME: usize = 1275;
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
// FRAME_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
// and try-send the resulting packet on `voice_out_tx`.
struct AndroidCaptureState {
encoder: OpusEncoder,
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
/// cpal-style buffer-size jitter without reallocating.
pcm_accum: Vec<i16>,
opus_out: [u8; MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<Self, AudioError> {
let mut encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new (android): {e}")))?;
if let Err(e) = encoder.set_bitrate(audiopus::Bitrate::BitsPerSecond(32_000)) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_bitrate(32000) failed");
}
if let Err(e) = encoder.set_complexity(10) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_complexity(10) failed");
}
if let Err(e) = encoder.set_inband_fec(true) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_inband_fec(true) failed");
}
if let Err(e) = encoder.set_packet_loss_perc(5) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_packet_loss_perc(5) failed");
}
info!(
target: "chanora_audio",
bitrate_bps = 32_000,
complexity = 10,
inband_fec = true,
packet_loss_perc = 5,
"android Oboe opus encoder tuned for VoIP"
);
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
opus_out: [0u8; MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
mic_gain,
})
}
/// Consume i16 mono frames from Oboe, accumulate to FRAME_SAMPLES,
/// encode + send when PTT is held. Oboe delivers at the device's
/// native sample rate (always 48 kHz for modern Android per SRS-210),
/// so no resampling is needed.
fn ingest(&mut self, samples: &[i16]) {
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
// Mic-gain application.
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
self.pcm_accum.extend_from_slice(samples);
} else {
let gain = self.mic_gain;
self.pcm_accum.extend(samples.iter().map(|&s| {
let scaled = (s as f32) * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
}
// Drain complete 20 ms frames.
while self.pcm_accum.len() >= FRAME_SAMPLES {
let mut frame = [0i16; FRAME_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES]);
self.pcm_accum.drain(..FRAME_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &self.opus_out[..len],
});
match self.voice_out_tx.try_send(packet) {
Ok(()) => {
self.frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
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");
}
}
}
}
}
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(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>>>,
}
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;
}
}
// Non-blocking pull from AudioHandler (same pattern as iOS VPIO).
match self.handler.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch already zeroed above.
}
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 mut peak: i16 = 0;
for (i, dst) in frames.iter_mut().enumerate() {
if muted {
*dst = 0;
continue;
}
let l = scratch[i * 2];
let r = scratch[i * 2 + 1];
let mono = (l + r) * 0.5 * gain;
let clamped = mono.clamp(-1.0, 1.0);
let sample = (clamped * i16::MAX as f32) as i16;
*dst = sample;
if sample.unsigned_abs() > peak.unsigned_abs() {
peak = sample;
}
}
}));
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");
}
}
}
/// Bundle of engine-owned state shared with the Oboe audio callbacks.
/// Mirrors the parameter set that iOS `IosVoiceUnit::start()` receives
/// from the engine (SDD-120 amendment: Android Oboe-only audio path).
pub struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
pub voice_out_tx: mpsc::Sender<OutPacket>,
/// PTT transmission gate — true when the user holds the PTT key.
pub transmit_active: Arc<AtomicBool>,
/// Counter incremented per encoded frame sent.
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound解码+混合 feeds into; the Oboe output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback).
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
}
// --- 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).
#[allow(clippy::too_many_arguments)]
pub fn open(
cfg: &AndroidVoiceStreamConfig,
params: VoiceAudioParams,
) -> Result<Self, BackendError> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
// SDD-120: build the capture state that the Oboe input callback
// will own via Arc<Mutex>. Same Opus VoIP tuning as iOS and
// desktop (32 kbps, complexity 10, inband FEC, 5 % PLC).
let capture_state = Arc::new(Mutex::new(
AndroidCaptureState::new(
params.voice_out_tx,
params.transmit_active,
params.frames_sent,
params.mic_gain,
)
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
));
// Scratch buffer for the output callback (realtime-safe
// pre-allocation). 8192 floats covers the largest practical
// burst size at 48 kHz with headroom.
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);
let session_id = None;
warn!(
target: "chanora_audio",
"android: oboe-rs get_session_id is skipped because oboe 0.6.1 \
panics on some Android allocated-session values; hardware \
effects are disabled for this stream"
);
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(if cfg.request_low_latency {
PerformanceMode::LowLatency
} else {
PerformanceMode::None
})
.set_sharing_mode(if cfg.request_exclusive {
SharingMode::Exclusive
} else {
SharingMode::Shared
})
.set_usage(Usage::VoiceCommunication)
.set_content_type(oboe::ContentType::Speech);
let output_cb = OutputCallback {
handler: params.handler.clone(),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
scratch: scratch.clone(),
};
let output_builder = output_builder.set_callback(output_cb);
let mut output_stream = match output_builder.open_stream() {
Ok(s) => s,
Err(e) => {
warn!(
target: "chanora_audio",
error = ?e,
"android: primary output stream open failed; retrying with Shared sharing mode"
);
Self::open_output_fallback(
cfg,
&event_tx,
params.handler.clone(),
params.output_gain.clone(),
params.output_muted.clone(),
scratch.clone(),
)?
}
};
let output_perf = perf_from_oboe(output_stream.get_performance_mode());
let output_share = share_from_oboe(output_stream.get_sharing_mode());
let output_sample_rate = output_stream.get_sample_rate();
let output_frames_per_burst = output_stream.get_frames_per_burst();
// SDD-112 / SRS-210: structured "stream opened" event with
// achieved values. No PII; only platform-reported scalars.
info!(
target: "chanora_audio",
event = "audio.android.stream_opened",
input_perf = %input_perf,
input_share = %input_share,
input_sample_rate,
input_frames_per_burst,
output_perf = %output_perf,
output_share = %output_share,
output_sample_rate,
output_frames_per_burst,
session_id = ?session_id,
"android: voice streams opened"
);
// --- SDD-113 hardware effects -----------------------------
let hw_effects = if let Some(sid) = session_id {
attach_hardware_effects(sid, &cfg.effects)
} else {
warn!(
target: "chanora_audio",
"android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage"
);
HardwareEffectHandles::default()
};
// --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 ---
// Publish the diagnostics snapshot. Per-effect engagement is
// derived from (a) the JNI handle (`Hardware`) or (b) the
// requested-effects mask (`Software` fallback) or (c) neither
// (`None`). The achieved input preset readback is `Unknown`
// until the oboe-rs wrapper exposes a getter (SWE4-UV-052
// follow-through). No PII per SDD-090.
let aec = effect_engagement(cfg.effects.aec, hw_effects.aec.is_some());
let ns = effect_engagement(cfg.effects.noise_suppression, hw_effects.ns.is_some());
let agc = effect_engagement(cfg.effects.agc, hw_effects.agc.is_some());
let diagnostics = AndroidAudioDiagnostics {
// SDD-112 items 4..7: requested-side pinned values.
requested_performance_mode: if cfg.request_low_latency {
"LowLatency"
} else {
"None"
},
requested_usage: "VoiceCommunication",
requested_content_type: "Speech",
requested_input_preset: "VoiceCommunication",
requested_sharing_mode: if cfg.request_exclusive {
"Exclusive"
} else {
"Shared"
},
requested_sample_rate_hz: cfg.sample_rate,
// SDD-112 item 4 / 7 achieved side.
achieved_performance_mode: input_perf,
achieved_sharing_mode: input_share,
achieved_input_preset: AchievedInputPreset::Unknown,
achieved_sample_rate_hz: input_sample_rate.max(0) as u32,
achieved_frames_per_burst: input_frames_per_burst.max(0) as u32,
// SDD-113 item 7 / SRS-212 per-effect engagement.
aec,
ns,
agc,
// SDD-116 / SRS-210 latency-tier classification.
latency_tier: latency_tier_for(input_perf),
};
publish_android_audio_diagnostics(diagnostics);
Ok(Self {
input: 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>,
scratch: Arc<Mutex<Vec<f32>>>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
handler,
output_gain,
output_muted,
event_tx: event_tx.clone(),
scratch,
};
let builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
.set_sample_rate(cfg.sample_rate as i32)
.set_channel_count::<Mono>()
.set_format::<i16>()
.set_performance_mode(PerformanceMode::LowLatency)
.set_sharing_mode(SharingMode::Shared)
.set_usage(Usage::VoiceCommunication)
.set_content_type(oboe::ContentType::Speech)
.set_callback(cb);
builder
.open_stream()
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
}
/// Clone of the event sender, for JNI focus / SCO listeners
/// registered on the engine's behalf.
pub fn event_sender(&self) -> BackendEventTx {
self.event_tx.clone()
}
}
impl MobileVoiceAudioBackend for AndroidVoiceUnit {
fn start(&mut self) -> Result<(), BackendError> {
if let Some(s) = self.input.as_mut() {
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::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");
}
}
}
// --- 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();
warn!(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
}
}
}