feat: stabilize voice activity and audio routing
This commit is contained in:
+435
-101
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user