feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
+435 -101
View File
@@ -20,6 +20,18 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample};
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::collections::hash_map::DefaultHasher;
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::hash::{Hash, Hasher};
use tokio::sync::mpsc;
use tracing::{debug, info};
// `error!` and `warn!` are used only inside the cpal capture /
@@ -80,12 +92,77 @@ pub struct AudioDeviceList {
/// Info about a single audio device.
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
/// Stable platform-reported device identifier.
pub id: String,
/// Human-readable device name from the OS.
pub name: String,
/// Additional device details useful for disambiguation.
pub details: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn desktop_device_id(device: &cpal::Device) -> Option<String> {
device.id().ok().map(|id| id.to_string())
}
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn short_device_id(id: &str) -> String {
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn describe_device(device: &cpal::Device) -> Option<AudioDeviceInfo> {
let id = desktop_device_id(device)?;
let description = device.description().ok();
let name = description
.as_ref()
.map(|d| d.name().trim().to_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| format!("Device {}", &short_device_id(&id)[..8]));
let mut details = Vec::new();
if let Some(description) = description.as_ref() {
if let Some(manufacturer) = description.manufacturer() {
details.push(manufacturer.to_owned());
}
if let Some(driver) = description.driver() {
details.push(driver.to_owned());
}
let device_type = description.device_type();
if device_type != cpal::device_description::DeviceType::Unknown {
details.push(format!("{device_type}"));
}
let interface_type = description.interface_type();
if interface_type != cpal::device_description::InterfaceType::Unknown {
details.push(format!("{interface_type}"));
}
}
details.push(format!("id={}", short_device_id(&id)));
Some(AudioDeviceInfo {
id,
name,
details: details.join(" · "),
is_default: false,
})
}
/// Enumerate available audio input and output devices.
/// On mobile platforms (iOS, Android) returns an empty list because
/// device selection is managed by the OS audio session.
@@ -100,38 +177,30 @@ pub fn list_audio_devices() -> AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
};
let Ok(host) = cpal::default_host() else {
return list;
};
let default_in = host.default_input_device();
let default_out = host.default_output_device();
let host = cpal::default_host();
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_in
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.input_devices
.push(AudioDeviceInfo { name, is_default });
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_out
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.output_devices
.push(AudioDeviceInfo { name, is_default });
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
}
@@ -172,12 +241,12 @@ pub struct AudioEngineConfig {
/// is rejected on Android because the P0 path intentionally has
/// no generic mobile-audio fallback.
pub mobile_voice_preset: bool,
/// Optional input device name override. When `None`, the system
/// default input device is used. Set to a device name from
/// Optional input device id override. When `None`, the system
/// default input device is used. Set to a device id from
/// [`list_audio_devices`] to pin a specific microphone.
pub input_device_name: Option<String>,
/// Optional output device name override.
pub output_device_name: Option<String>,
pub input_device_id: Option<String>,
/// Optional output device id override.
pub output_device_id: Option<String>,
/// Optional selector used by P1 VoiceActivity to publish VAD state.
#[doc(hidden)]
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -190,8 +259,8 @@ impl std::fmt::Debug for AudioEngineConfig {
.field("ptt_initial", &self.ptt_initial)
.field("effects", &self.effects)
.field("mobile_voice_preset", &self.mobile_voice_preset)
.field("input_device_name", &self.input_device_name)
.field("output_device_name", &self.output_device_name)
.field("input_device_id", &self.input_device_id)
.field("output_device_id", &self.output_device_id)
.field(
"voice_activity_selector",
&self.voice_activity_selector.as_ref().map(|_| "present"),
@@ -207,8 +276,8 @@ impl Default for AudioEngineConfig {
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
input_device_name: None,
output_device_name: None,
input_device_id: None,
output_device_id: None,
voice_activity_selector: None,
}
}
@@ -237,13 +306,13 @@ pub struct AudioEngine {
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
mic_gain: f32,
#[cfg(any(target_os = "ios", target_os = "macos"))]
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
// Option wrapped by Mutex so stop() can move them out. On Linux
@@ -277,7 +346,11 @@ pub struct AudioEngine {
/// unbinds effects and stops the service in SDD-115 reverse
/// order.
#[cfg(target_os = "android")]
_android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
_android_voice_unit: Arc<Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>>,
/// Persist the Android stream request so route-change reopen uses the
/// same effect and latency policy as the original session start.
#[cfg(target_os = "android")]
android_voice_stream_config: Arc<Mutex<crate::mobile_voice_backend::AndroidVoiceStreamConfig>>,
/// SDD-108 §1/§2: refcount-composable audio-mode controller.
/// Snapshots `AudioManager.getMode()` on the 0 → 1 transition and
/// restores it on the 1 → 0 transition. Held in a `Mutex` so the
@@ -286,7 +359,7 @@ pub struct AudioEngine {
/// mode lifecycle is bound to the voice-session lifecycle
/// (SDD-108 §3).
#[cfg(target_os = "android")]
audio_mode_stack: Mutex<crate::mode_stack::ModeStack>,
audio_mode_stack: Arc<Mutex<crate::mode_stack::ModeStack>>,
// Hand the inbound-voice forwarder task a shutdown signal.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
/// True if the capture stream actually opened. If false (typical
@@ -385,6 +458,183 @@ fn open_ios_voice_backend(
}
impl AudioEngine {
#[cfg(target_os = "android")]
fn spawn_android_backend_event_task(
android_voice_unit: Arc<Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>>,
android_voice_stream_config: Arc<
Mutex<crate::mobile_voice_backend::AndroidVoiceStreamConfig>,
>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
mic_gain: f32,
audio_mode_stack: Arc<Mutex<crate::mode_stack::ModeStack>>,
mut event_rx: crate::mobile_voice_backend::BackendEventRx,
) {
use crate::mobile_voice_backend::BackendEvent;
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
BackendEvent::Disconnected => {
warn!(target: "chanora_audio", "android: backend disconnected; reopening voice unit");
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = android_voice_unit.lock().unwrap().take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
warn!(
target: "chanora_audio",
error = %e,
"android: failed to close disconnected voice unit before reopen"
);
}
}
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service restart dispatched after backend disconnect"
);
}
match android_get_audio_mode() {
Ok(current_mode) => {
if current_mode != ANDROID_MODE_IN_COMMUNICATION {
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
Ok(()) => info!(
target: "chanora_audio",
prior_mode = current_mode,
"android: AudioManager mode re-engaged after backend disconnect"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
prior_mode = current_mode,
"android: failed to re-engage MODE_IN_COMMUNICATION after backend disconnect"
),
}
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: AudioManager.getMode failed during backend reopen"
),
}
let cfg_av = android_voice_stream_config.lock().unwrap().clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: voice_out_tx.clone(),
transmit_active: transmit_gate.flag_arc(),
frames_sent: frames_sent.clone(),
mic_gain,
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_activity_selector: voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
};
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
Ok(mut reopened) => {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
match reopened.start() {
Ok(()) => {
if let Some(next_rx) = reopened.take_event_rx() {
Self::spawn_android_backend_event_task(
android_voice_unit.clone(),
android_voice_stream_config.clone(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
mic_gain,
audio_mode_stack.clone(),
next_rx,
);
}
crate::android_voice_unit::register_global_event_sender(
reopened.event_sender(),
);
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus re-requested after backend disconnect"
);
}
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: bluetooth route re-engaged after backend disconnect"
);
}
*android_voice_unit.lock().unwrap() = Some(reopened);
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: reopened voice unit failed to start after backend disconnect"
),
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: failed to reopen voice unit after backend disconnect"
),
}
}
BackendEvent::FocusLost => {
warn!(
target: "chanora_audio",
"android: audio focus lost permanently (SDD-109); engine should leave session"
);
}
BackendEvent::FocusTransient => {
info!(
target: "chanora_audio",
"android: transient audio focus loss (SDD-109); pausing capture"
);
}
BackendEvent::FocusTransientCanDuck => {
info!(
target: "chanora_audio",
"android: transient audio focus loss with ducking (SDD-109); continuing"
);
}
BackendEvent::FocusGain => {
info!(
target: "chanora_audio",
"android: audio focus regained (SDD-109); resuming capture"
);
}
BackendEvent::BluetoothScoStateChanged(s) => {
info!(
target: "chanora_audio",
sco_state = s,
"android: Bluetooth SCO state changed (SDD-110)"
);
}
}
}
});
}
/// Start the engine: open capture + playback streams, spawn the
/// inbound-voice forwarder, return a handle.
pub fn start(
@@ -461,21 +711,22 @@ impl AudioEngine {
"starting audio engine: cpal host selected"
);
/// Helper: find a device by name, falling back to default.
fn find_device(
/// Helper: find a device by stable id, falling back to default.
fn find_device<DefaultFn, AllFn, Devices>(
host: &cpal::Host,
default_fn: fn(&cpal::Host) -> Option<cpal::Device>,
all_fn: fn(&cpal::Host) -> Result<cpal::Devices, cpal::DevicesError>,
default_fn: DefaultFn,
all_fn: AllFn,
prefer: Option<&str>,
) -> Option<cpal::Device> {
if let Some(name) = prefer {
) -> Option<cpal::Device>
where
DefaultFn: Fn(&cpal::Host) -> Option<cpal::Device>,
AllFn: Fn(&cpal::Host) -> Result<Devices, cpal::DevicesError>,
Devices: IntoIterator<Item = cpal::Device>,
{
if let Some(id) = prefer {
if let Ok(devices) = all_fn(host) {
for d in devices {
let dn = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if dn == name {
if desktop_device_id(&d).as_deref() == Some(id) {
return Some(d);
}
}
@@ -488,7 +739,7 @@ impl AudioEngine {
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_name.as_deref(),
cfg.input_device_id.as_deref(),
)
.ok_or(AudioError::NoInputDevice)?;
@@ -496,7 +747,7 @@ impl AudioEngine {
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_name.as_deref(),
cfg.output_device_id.as_deref(),
)
.ok_or(AudioError::NoOutputDevice)?;
@@ -729,7 +980,7 @@ impl AudioEngine {
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
use crate::mobile_voice_backend::{BackendEvent, MobileVoiceAudioBackend};
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
info!(target: "chanora_audio", "starting audio engine: Android Oboe backend");
@@ -799,7 +1050,7 @@ impl AudioEngine {
..Default::default()
};
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx,
voice_out_tx: voice_out_tx.clone(),
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
@@ -821,53 +1072,42 @@ impl AudioEngine {
)));
}
if let Some(mut event_rx) = android_voice_unit.take_event_rx() {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
BackendEvent::Disconnected => {
warn!(
target: "chanora_audio",
"android: backend disconnected event received; stream reconnect requires session restart"
);
}
BackendEvent::FocusLost => {
warn!(
target: "chanora_audio",
"android: audio focus lost permanently (SDD-109); engine should leave session"
);
}
BackendEvent::FocusTransient => {
info!(
target: "chanora_audio",
"android: transient audio focus loss (SDD-109); pausing capture"
);
}
BackendEvent::FocusTransientCanDuck => {
info!(
target: "chanora_audio",
"android: transient audio focus loss with ducking (SDD-109); continuing"
);
}
BackendEvent::FocusGain => {
info!(
target: "chanora_audio",
"android: audio focus regained (SDD-109); resuming capture"
);
}
BackendEvent::BluetoothScoStateChanged(s) => {
info!(
target: "chanora_audio",
sco_state = s,
"android: Bluetooth SCO state changed (SDD-110)"
);
}
}
}
});
let android_voice_unit = Arc::new(Mutex::new(Some(android_voice_unit)));
let android_voice_stream_config = Arc::new(Mutex::new(cfg_av.clone()));
let audio_mode_stack = Arc::new(Mutex::new(audio_mode_stack));
if let Some(event_rx) = android_voice_unit
.lock()
.unwrap()
.as_mut()
.and_then(|unit| unit.take_event_rx())
{
Self::spawn_android_backend_event_task(
android_voice_unit.clone(),
android_voice_stream_config.clone(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
cfg.voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
cfg.mic_gain,
audio_mode_stack.clone(),
event_rx,
);
}
crate::android_voice_unit::register_global_event_sender(android_voice_unit.event_sender());
crate::android_voice_unit::register_global_event_sender(
android_voice_unit
.lock()
.unwrap()
.as_ref()
.expect("android voice unit installed")
.event_sender(),
);
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
@@ -932,8 +1172,12 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
audio_mode_stack: Mutex::new(audio_mode_stack),
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
_android_voice_unit: android_voice_unit,
android_voice_stream_config,
audio_mode_stack,
shutdown_tx: Some(shutdown_tx),
capture_active,
})
@@ -1182,6 +1426,96 @@ impl AudioEngine {
}
}
/// Android-only: reopen the underlying Oboe voice backend after a
/// route/device change while preserving the engine-owned state.
pub fn android_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "android")]
{
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
unit.close().map_err(|e| {
AudioError::Backend(format!(
"android: failed to close Oboe voice unit during route restart: {e}"
))
})?;
}
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service restart dispatched after route change"
);
}
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus re-requested after route change"
);
}
let cfg_av = self.android_voice_stream_config.lock().unwrap().clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain,
handler: self.audio_handler.clone(),
output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(),
voice_activity_selector: self.voice_activity_selector.clone(),
audio_processing_config: self.audio_processing_config.clone(),
audio_processing_stats: self.audio_processing_stats.clone(),
};
let mut reopened = crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params)
.map_err(|e| {
AudioError::Backend(format!("android: failed to reopen Oboe voice unit: {e}"))
})?;
reopened.start().map_err(|e| {
AudioError::Backend(format!("android: failed to restart Oboe voice unit: {e}"))
})?;
if let Some(event_rx) = reopened.take_event_rx() {
Self::spawn_android_backend_event_task(
self._android_voice_unit.clone(),
self.android_voice_stream_config.clone(),
self.voice_out_tx.clone(),
self.transmit_gate.clone(),
self.frames_sent.clone(),
self.audio_handler.clone(),
self.output_gain.clone(),
self.output_muted.clone(),
self.voice_activity_selector.clone(),
self.audio_processing_config.clone(),
self.audio_processing_stats.clone(),
self.mic_gain,
self.audio_mode_stack.clone(),
event_rx,
);
}
crate::android_voice_unit::register_global_event_sender(reopened.event_sender());
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: bluetooth route re-engaged after route change"
);
}
let mut guard = self._android_voice_unit.lock().unwrap();
*guard = Some(reopened);
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// iOS-only: pause the underlying VoiceProcessingIO unit.
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
@@ -2086,7 +2420,7 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[doc(hidden)]
pub mod bench_seam {
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OpusEncoder, OutPacket};
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
use tokio::sync::mpsc;
/// Opaque handle wrapping a CaptureState plus the dummy mpsc