fix(audio): harden realtime callback paths

This commit is contained in:
Edison Jwa
2026-06-08 19:40:03 +09:00
parent d83539436e
commit 8606eb48c8
12 changed files with 1372 additions and 528 deletions
+89 -14
View File
@@ -981,7 +981,6 @@ impl AudioEngine {
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
@@ -1085,11 +1084,25 @@ impl AudioEngine {
audio_processing_stats: audio_processing_stats.clone(),
};
let mut android_voice_unit =
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
})?;
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
Ok(unit) => unit,
Err(e) => {
Self::rollback_android_startup_resources(&mut audio_mode_stack);
return Err(AudioError::Backend(format!(
"android: failed to open Oboe voice unit: {e}"
)));
}
};
if let Err(e) = android_voice_unit.start() {
if let Err(close_err) = android_voice_unit.close() {
warn!(
target: "chanora_audio",
error = %close_err,
"android: AndroidVoiceUnit::close failed during startup rollback"
);
}
Self::rollback_android_startup_resources(&mut audio_mode_stack);
return Err(AudioError::Backend(format!(
"android: failed to start Oboe voice unit: {e}"
)));
@@ -1326,6 +1339,43 @@ impl AudioEngine {
})
}
#[cfg(target_os = "android")]
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
crate::mode_stack::ModeRelease::LastRelease { prior } => {
match android_set_audio_mode(prior) {
Ok(()) => info!(
target: "chanora_audio",
restored_mode = prior,
"android: AudioManager mode restored during startup rollback"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
restored_mode = prior,
"android: failed to restore AudioManager mode during startup rollback"
),
}
}
crate::mode_stack::ModeRelease::StillHeld => {
info!(
target: "chanora_audio",
"android: audio mode still held during startup rollback"
);
}
crate::mode_stack::ModeRelease::AlreadyReleased => {}
}
if crate::android_voice_unit::chanora_android_stop_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service stopped during startup rollback"
);
}
}
/// Stop the engine. Idempotent.
pub fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
@@ -1720,6 +1770,33 @@ impl Drop for AudioEngine {
}
}
#[cfg(any(test, target_os = "android"))]
fn release_android_audio_mode_for_startup_rollback(
audio_mode_stack: &mut crate::mode_stack::ModeStack,
) -> crate::mode_stack::ModeRelease {
audio_mode_stack.release()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_startup_rollback_releases_acquired_mode_snapshot() {
let mut stack = crate::mode_stack::ModeStack::new();
let _ = stack.acquire(7);
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
assert_eq!(
release,
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
);
assert_eq!(stack.refcount(), 0);
assert_eq!(stack.snapshot(), None);
}
}
// ---------- Capture pipeline ----------
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -1762,9 +1839,12 @@ fn try_open_capture(
in_sample_rate,
in_channels,
mic_gain,
voice_out_tx,
crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"cpal-capture",
)?,
transmit_active,
frames_sent,
audio_processing_stats,
)));
@@ -1799,11 +1879,10 @@ struct CaptureState {
/// Linux ALSA defaults).
resample_last: f32,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
/// The PTT transmission gate. Read once per outbound frame; the
/// CaptureState never mutates this flag.
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
/// Pre-allocated mono downmix buffer. Resized in-place each
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
/// invariant: this avoids the heap allocation that the prior fix
@@ -1836,8 +1915,7 @@ struct CaptureState {
/// Time-based gating is robust to cpal buffer-size and sample-rate
/// changes that a fixed callback-count would not be.
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
const LEVEL_METER_INTERVAL: std::time::Duration =
std::time::Duration::from_millis(33);
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
@@ -1846,9 +1924,8 @@ impl CaptureState {
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self {
Self {
@@ -1862,7 +1939,6 @@ impl CaptureState {
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
mono_scratch: Vec::with_capacity(4096),
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
@@ -1959,7 +2035,6 @@ impl CaptureState {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {