#[cfg(target_os = "macos")]
pub(crate) mod browser_media;
mod call_input;
mod fixture;
#[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(dead_code))]
pub(crate) mod hex_microphone;
pub(crate) mod idle_sleep;
mod input_format;
pub(crate) mod input_level;
pub(crate) mod instant;
#[cfg(target_os = "macos")]
mod lid;
#[cfg(any(target_os = "windows", test))]
mod mix_format;
pub(crate) mod output_ducking;
#[cfg(target_os = "macos")]
pub(crate) mod recording_environment;
mod transport;
#[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(dead_code))]
pub(crate) mod voice_filter;
#[cfg(target_os = "windows")]
mod wasapi_capture;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
static AUDIO_SERVICE_RESTART_GENERATION: AtomicU64 = AtomicU64::new(0);
static INPUT_ROUTE_GENERATION: AtomicU64 = AtomicU64::new(0);
static STREAM_GLITCHED_AT: Mutex<Option<Instant>> = Mutex::new(None);
static AVOID_BLUETOOTH_MICROPHONE: AtomicBool = AtomicBool::new(true);
pub fn uses_fixture() -> bool {
fixture::path().is_some()
}
pub fn set_avoid_bluetooth_microphone(avoid: bool) {
if AVOID_BLUETOOTH_MICROPHONE.swap(avoid, Ordering::AcqRel) != avoid {
INPUT_ROUTE_GENERATION.fetch_add(1, Ordering::AcqRel);
}
}
fn avoid_bluetooth_microphone() -> bool {
AVOID_BLUETOOTH_MICROPHONE.load(Ordering::Acquire)
}
pub fn since_last_stream_glitch() -> Option<Duration> {
STREAM_GLITCHED_AT
.lock()
.unwrap_or_else(|error| error.into_inner())
.map(|at| at.elapsed())
}
pub fn notify_audio_service_restarted() {
AUDIO_SERVICE_RESTART_GENERATION.fetch_add(1, Ordering::AcqRel);
}
pub fn audio_service_restart_generation() -> u64 {
AUDIO_SERVICE_RESTART_GENERATION.load(Ordering::Acquire)
}
pub fn input_route_generation() -> u64 {
INPUT_ROUTE_GENERATION.load(Ordering::Acquire)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn notify_input_route_changed() {
INPUT_ROUTE_GENERATION.fetch_add(1, Ordering::AcqRel);
}
static OUTPUT_ROUTE_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn output_route_generation() -> u64 {
OUTPUT_ROUTE_GENERATION
.load(Ordering::Acquire)
.wrapping_add(audio_service_restart_generation())
}
#[cfg(target_os = "macos")]
fn notify_output_route_changed() {
OUTPUT_ROUTE_GENERATION.fetch_add(1, Ordering::AcqRel);
}
#[cfg(target_os = "macos")]
pub fn watch_input_devices() {
use std::ffi::c_void;
use std::sync::Once;
#[repr(C)]
struct AudioObjectPropertyAddress {
m_selector: u32,
m_scope: u32,
m_element: u32,
}
type PropertyListenerProc =
unsafe extern "C" fn(u32, u32, *const AudioObjectPropertyAddress, *mut c_void) -> i32;
unsafe extern "C" {
fn AudioObjectAddPropertyListener(
in_object_id: u32,
in_address: *const AudioObjectPropertyAddress,
in_listener: PropertyListenerProc,
in_client_data: *mut c_void,
) -> i32;
}
unsafe extern "C" fn on_service_restarted(
_in_object_id: u32,
_in_number_addresses: u32,
_in_addresses: *const AudioObjectPropertyAddress,
_in_client_data: *mut c_void,
) -> i32 {
tracing::warn!("CoreAudio daemon (coreaudiod) restarted; recovering microphone stream");
notify_audio_service_restarted();
0
}
unsafe extern "C" fn on_inputs_changed(
_in_object_id: u32,
_in_number_addresses: u32,
_in_addresses: *const AudioObjectPropertyAddress,
_in_client_data: *mut c_void,
) -> i32 {
notify_input_route_changed();
0
}
unsafe extern "C" fn on_outputs_changed(
_in_object_id: u32,
_in_number_addresses: u32,
_in_addresses: *const AudioObjectPropertyAddress,
_in_client_data: *mut c_void,
) -> i32 {
notify_output_route_changed();
0
}
unsafe extern "C" fn on_devices_changed(
_in_object_id: u32,
_in_number_addresses: u32,
_in_addresses: *const AudioObjectPropertyAddress,
_in_client_data: *mut c_void,
) -> i32 {
notify_input_route_changed();
notify_output_route_changed();
0
}
static REGISTER: Once = Once::new();
REGISTER.call_once(|| {
const SYSTEM_OBJECT: u32 = 1;
const SERVICE_RESTARTED_SELECTOR: u32 = u32::from_be_bytes(*b"srst");
const DEFAULT_INPUT_SELECTOR: u32 = u32::from_be_bytes(*b"dIn ");
const DEFAULT_OUTPUT_SELECTOR: u32 = u32::from_be_bytes(*b"dOut");
const DEVICES_SELECTOR: u32 = u32::from_be_bytes(*b"dev#");
const GLOBAL_SCOPE: u32 = u32::from_be_bytes(*b"glob");
let listeners: [(u32, PropertyListenerProc); 4] = [
(SERVICE_RESTARTED_SELECTOR, on_service_restarted),
(DEFAULT_INPUT_SELECTOR, on_inputs_changed),
(DEFAULT_OUTPUT_SELECTOR, on_outputs_changed),
(DEVICES_SELECTOR, on_devices_changed),
];
for (selector, listener) in listeners {
let addr = AudioObjectPropertyAddress {
m_selector: selector,
m_scope: GLOBAL_SCOPE,
m_element: 0,
};
let status = unsafe {
AudioObjectAddPropertyListener(SYSTEM_OBJECT, &addr, listener, std::ptr::null_mut())
};
if status != 0 {
tracing::warn!(status, "could not watch CoreAudio input devices");
}
}
lid::watch(notify_input_route_changed);
});
}
use color_eyre::eyre::{Result, WrapErr, eyre};
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{Device, SizedSample, Stream, StreamConfig};
use transport::{Transport, transport};
#[cfg(all(test, target_os = "macos"))]
use instant::scale_mach_ticks;
pub use instant::{CaptureInstant, duration_for_frames};
pub struct AudioInput {
_stream: Option<input_format::InputStream>,
chunks: Receiver<(Vec<f32>, CaptureInstant)>,
pub sample_rate: u32,
pub device_name: String,
pub uid: Option<String>,
pub selection: Selection,
stream_errors: Receiver<String>,
watch_service_restarts: bool,
restart_generation: u64,
hex_microphone: crate::audio::hex_microphone::CaptureFeedSwitch,
stall_after: Option<Duration>,
last_delivery: Mutex<Instant>,
}
pub(crate) const STREAM_STALL_TIMEOUT: Duration = Duration::from_secs(4);
pub enum AudioInputEvent {
Chunk {
samples: Vec<f32>,
captured_through: CaptureInstant,
},
Timeout,
StreamFailed(String),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Selection {
#[default]
InUse,
Unlisted,
Unopenable,
LidClosed,
}
#[allow(clippy::large_enum_variant)]
pub enum Opened {
Input(Box<AudioInput>),
Unchanged {
selection: Selection,
},
}
type InputOpenResult = (u64, Result<Opened, String>);
struct OpenRequest {
device_override: Option<String>,
selected_device: Option<String>,
fallback: SelectedFallback,
keep: Option<String>,
avoids_bluetooth: bool,
call_input: Option<String>,
}
impl OpenRequest {
fn route(&self, current: Route) -> Route {
Route {
avoid_bluetooth: current.avoid_bluetooth && self.avoids_bluetooth,
..current
}
}
}
type Opener = Arc<dyn Fn(&OpenRequest) -> Result<Opened> + Send + Sync>;
pub struct RecoveringAudioInput {
input: Option<AudioInput>,
device_override: Option<String>,
selected_device: Option<String>,
active_revision: u64,
recovery: MicrophoneRecovery,
replacement: Option<Receiver<InputOpenResult>>,
route_generation: u64,
selection_retry: Duration,
reports_new_devices: bool,
opener: Opener,
route_generation_source: fn() -> u64,
outage: Option<Option<String>>,
retry_warning: Option<String>,
}
#[cfg(any(target_os = "linux", target_os = "windows", test))]
pub fn capture_idle(recording: bool, start_pending: bool, finishing: bool) -> bool {
!recording && !start_pending && !finishing
}
pub enum RecoveringAudioInputEvent {
Chunk {
samples: Vec<f32>,
captured_through: CaptureInstant,
},
Timeout,
Interrupted,
Reopened,
OpenFailed(String),
}
const MICROPHONE_RETRY_INITIAL: Duration = Duration::from_millis(250);
const MICROPHONE_RETRY_MAX: Duration = Duration::from_secs(5);
const SELECTED_MICROPHONE_ATTEMPTS: usize = 3;
const SELECTED_MICROPHONE_RETRY_DELAY: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SelectedFallback {
Immediate,
AfterRetries,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MicrophoneRecoveryReason {
RouteChanged,
SelectionChanged,
StreamFailed,
}
struct MicrophoneRecovery {
next_attempt: Option<Instant>,
delay: Duration,
reason: Option<MicrophoneRecoveryReason>,
target_revision: Option<u64>,
}
impl Default for MicrophoneRecovery {
fn default() -> Self {
Self {
next_attempt: None,
delay: MICROPHONE_RETRY_INITIAL,
reason: None,
target_revision: None,
}
}
}
impl MicrophoneRecovery {
fn request_selection_change(&mut self, revision: u64, now: Instant) {
if self.target_revision != Some(revision) {
self.next_attempt = Some(now);
self.delay = MICROPHONE_RETRY_INITIAL;
self.target_revision = Some(revision);
if self.reason != Some(MicrophoneRecoveryReason::StreamFailed) {
self.reason = Some(MicrophoneRecoveryReason::SelectionChanged);
}
}
}
fn request_route_check(&mut self, at: Instant) {
match self.reason {
None => {
self.reason = Some(MicrophoneRecoveryReason::RouteChanged);
self.next_attempt = Some(at);
self.delay = MICROPHONE_RETRY_INITIAL;
}
Some(MicrophoneRecoveryReason::RouteChanged) => {
self.next_attempt = Some(self.next_attempt.map_or(at, |next| next.min(at)));
}
Some(_) => {}
}
}
fn request_stream_recovery(&mut self, now: Instant) {
if self.reason != Some(MicrophoneRecoveryReason::StreamFailed) {
self.next_attempt = Some(now);
self.delay = MICROPHONE_RETRY_INITIAL;
self.reason = Some(MicrophoneRecoveryReason::StreamFailed);
}
}
fn blocks_audio(&self) -> bool {
self.reason == Some(MicrophoneRecoveryReason::StreamFailed)
}
fn should_attempt(&self, now: Instant) -> bool {
self.next_attempt.is_some_and(|next| now >= next)
}
fn failed(&mut self, now: Instant) {
self.next_attempt = Some(now + self.delay);
self.delay = self.delay.saturating_mul(2).min(MICROPHONE_RETRY_MAX);
}
fn recovered(&mut self) {
self.next_attempt = None;
self.delay = MICROPHONE_RETRY_INITIAL;
self.reason = None;
self.target_revision = None;
}
}
impl AudioInput {
#[cfg(test)]
pub fn channel_for_test() -> (Self, Sender<(Vec<f32>, CaptureInstant)>) {
let (sender, chunks) = mpsc::channel();
let (_, stream_errors) = mpsc::channel();
(
Self {
_stream: None,
chunks,
stream_errors,
sample_rate: 48_000,
device_name: "Test microphone".into(),
uid: None,
selection: Selection::InUse,
watch_service_restarts: false,
restart_generation: audio_service_restart_generation(),
hex_microphone: crate::audio::hex_microphone::capture_feed(48_000).1,
stall_after: None,
last_delivery: Mutex::new(Instant::now()),
},
sender,
)
}
#[cfg(test)]
fn watch_stalls_for_test(&mut self, after: Duration) {
self.stall_after = Some(after);
*self
.last_delivery
.lock()
.unwrap_or_else(|error| error.into_inner()) = Instant::now();
}
#[cfg(test)]
pub fn watch_service_restarts_for_test(&mut self) {
self.watch_service_restarts = true;
self.restart_generation = audio_service_restart_generation();
}
#[cfg(target_os = "windows")]
pub fn open_system_default() -> Result<Self> {
match open_configured_input(&OpenRequest {
device_override: None,
selected_device: None,
fallback: SelectedFallback::Immediate,
keep: None,
avoids_bluetooth: true,
call_input: None,
})? {
Opened::Input(input) => Ok(*input),
Opened::Unchanged { .. } => unreachable!("nothing to keep"),
}
}
#[cfg(target_os = "windows")]
pub fn open_matching(query: &str) -> Result<Self> {
let host = cpal::default_host();
let (devices, names) = input_devices_of(&host)?;
let query_lower = query.to_lowercase();
let index = names
.iter()
.position(|name| name.to_lowercase().contains(&query_lower))
.ok_or_else(|| eyre!("microphone is unavailable: {query}"))?;
Self::open_device(devices[index].clone(), names[index].clone(), false)
}
fn open_device(device: Device, device_name: String, follows_default: bool) -> Result<Self> {
#[cfg(target_os = "macos")]
watch_input_devices();
let uid = device.id().ok().map(|id| id.id().to_owned());
let restart_generation = audio_service_restart_generation();
let started = input_format::start_stream_with_fallback(
&device,
&device_name,
follows_default.then(|| device_name.clone()),
)?;
Ok(Self {
_stream: Some(started.stream),
chunks: started.chunks,
sample_rate: started.sample_rate,
device_name,
uid,
selection: Selection::InUse,
stream_errors: started.stream_errors,
watch_service_restarts: true,
restart_generation,
hex_microphone: started.hex_microphone,
stall_after: Some(STREAM_STALL_TIMEOUT),
last_delivery: Mutex::new(Instant::now()),
})
}
pub fn feed_hex_microphone(&self, on: bool) {
self.hex_microphone.set(on);
}
pub fn recv_timeout(&self, timeout: Duration) -> AudioInputEvent {
if self.watch_service_restarts
&& audio_service_restart_generation() != self.restart_generation
{
return AudioInputEvent::StreamFailed("CoreAudio service restarted".into());
}
if let Ok(error) = self.stream_errors.try_recv() {
return AudioInputEvent::StreamFailed(error);
}
let received = self.chunks.recv_timeout(timeout);
if self.watch_service_restarts
&& audio_service_restart_generation() != self.restart_generation
{
return AudioInputEvent::StreamFailed("CoreAudio service restarted".into());
}
if let Ok(error) = self.stream_errors.try_recv() {
return AudioInputEvent::StreamFailed(error);
}
match received {
Ok((samples, captured_through)) => {
if self.stall_after.is_some() {
*self
.last_delivery
.lock()
.unwrap_or_else(|error| error.into_inner()) = Instant::now();
}
AudioInputEvent::Chunk {
samples,
captured_through,
}
}
Err(RecvTimeoutError::Timeout) => {
let stalled = self.stall_after.is_some_and(|after| {
self.last_delivery
.lock()
.unwrap_or_else(|error| error.into_inner())
.elapsed()
>= after
});
if stalled {
AudioInputEvent::StreamFailed("microphone stopped delivering audio".into())
} else {
AudioInputEvent::Timeout
}
}
Err(RecvTimeoutError::Disconnected) => {
AudioInputEvent::StreamFailed("microphone audio channel disconnected".into())
}
}
}
}
impl RecoveringAudioInput {
pub fn open(
device_override: Option<&str>,
selection_revision: u64,
selected_device: Option<&str>,
) -> Self {
let mut input = Self::closed(device_override, selection_revision, selected_device);
let opened = open_configured_input(&OpenRequest {
device_override: device_override.map(str::to_owned),
selected_device: selected_device.map(str::to_owned),
fallback: SelectedFallback::Immediate,
keep: None,
avoids_bluetooth: true,
call_input: None,
})
.and_then(|opened| match opened {
Opened::Input(input) => Ok(*input),
Opened::Unchanged { .. } => Err(eyre!("nothing was open to keep")),
});
input.finish_initial_open(opened, Instant::now());
input
}
fn finish_initial_open(&mut self, opened: Result<AudioInput>, now: Instant) {
match opened {
Ok(opened) => {
self.install(opened);
self.after_resolving(now);
}
Err(error) => {
tracing::warn!(%error, "could not open dictation microphone at startup; retrying");
self.outage.get_or_insert(Some(error.to_string()));
self.recovery.request_stream_recovery(now);
self.recovery.failed(now);
}
}
}
pub fn closed(
device_override: Option<&str>,
selection_revision: u64,
selected_device: Option<&str>,
) -> Self {
Self {
input: None,
device_override: device_override.map(str::to_owned),
selected_device: selected_device.map(str::to_owned),
active_revision: selection_revision,
recovery: MicrophoneRecovery::default(),
replacement: None,
route_generation: input_route_generation(),
selection_retry: MICROPHONE_RETRY_INITIAL,
reports_new_devices: cfg!(target_os = "macos"),
opener: Arc::new(open_configured_input),
route_generation_source: input_route_generation,
outage: None,
retry_warning: None,
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn take_failure(&mut self) -> Option<String> {
self.outage.as_mut().and_then(Option::take)
}
#[cfg(test)]
fn with_opener_for_test(
selected_device: Option<&str>,
route_generation: fn() -> u64,
opener: impl Fn(&OpenRequest) -> Result<Opened> + Send + Sync + 'static,
) -> Self {
let mut input = Self::closed(None, 0, selected_device);
input.route_generation_source = route_generation;
input.route_generation = route_generation();
input.opener = Arc::new(opener);
input
}
#[cfg(test)]
pub fn failed_for_test(device_override: &str) -> Self {
let mut input = Self::closed(Some(device_override), 0, None);
input.finish_initial_open(Err(eyre!("{device_override} not found")), Instant::now());
input
}
#[cfg(test)]
pub fn pending_for_test() -> (Self, Sender<InputOpenResult>) {
let mut input = Self::closed(
Some("Hex nonexistent microphone for pending open test"),
0,
None,
);
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
(input, sender)
}
pub fn request_open(&mut self) {
if self.input.is_none() && self.replacement.is_none() && !self.is_recovering() {
self.start_replacement();
}
}
pub fn request_recovery(&mut self) {
self.recovery.request_stream_recovery(Instant::now());
}
pub fn is_recovering(&self) -> bool {
self.recovery.blocks_audio()
}
fn install(&mut self, input: AudioInput) {
if let Some(previous) = self.input.take() {
previous.feed_hex_microphone(false);
drop(previous);
}
input.feed_hex_microphone(true);
self.input = Some(input);
self.outage = None;
self.retry_warning = None;
}
pub fn close(&mut self) {
if let Some(previous) = self.input.take() {
previous.feed_hex_microphone(false);
}
self.replacement = None;
self.recovery.recovered();
self.outage = None;
self.retry_warning = None;
}
pub fn is_open(&self) -> bool {
self.input.is_some()
}
pub fn is_opening(&self) -> bool {
self.replacement.is_some()
}
pub fn is_active(&self) -> bool {
self.is_open() || self.is_opening() || self.is_recovering()
}
pub fn cancel_open(&mut self) {
if self.is_recovering() {
return;
}
self.replacement = None;
if self.input.is_none() {
self.recovery.recovered();
}
}
pub fn request_selection(&mut self, revision: u64, selected_device: Option<&str>) {
if self.device_override.is_some() || revision == self.active_revision {
return;
}
self.selected_device = selected_device.map(str::to_owned);
if self.input.is_none() && !self.is_recovering() {
let was_opening = self.replacement.take().is_some();
self.active_revision = revision;
self.recovery.recovered();
if was_opening {
self.start_replacement();
}
return;
}
self.recovery
.request_selection_change(revision, Instant::now());
}
pub fn recv_timeout(
&mut self,
timeout: Duration,
capture_idle: bool,
) -> RecoveringAudioInputEvent {
let now = Instant::now();
if let Some((opened_revision, result)) = self.poll_replacement() {
let target_revision = self
.recovery
.target_revision
.unwrap_or(self.active_revision);
if opened_revision != target_revision {
self.recovery.next_attempt = Some(now);
return RecoveringAudioInputEvent::Timeout;
}
match result {
Ok(Opened::Unchanged { selection }) => {
if let Some(input) = &mut self.input {
input.selection = selection;
}
self.active_revision = opened_revision;
self.recovery.recovered();
self.after_resolving(now);
}
Ok(Opened::Input(replacement)) if capture_idle || self.input.is_none() => {
self.install(*replacement);
self.active_revision = opened_revision;
self.recovery.recovered();
self.after_resolving(now);
tracing::info!(device = %self.device_name(), "dictation microphone reopened");
return RecoveringAudioInputEvent::Reopened;
}
Ok(Opened::Input(_)) => {
self.recovery.failed(now);
}
Err(error) => {
if self.input.is_none() && !self.is_recovering() {
self.recovery.recovered();
return RecoveringAudioInputEvent::OpenFailed(error);
}
self.recovery.failed(now);
if self.retry_warning.as_ref() != Some(&error) {
tracing::warn!(
%error,
"could not reopen dictation microphone; retrying"
);
self.retry_warning = Some(error.clone());
}
if self.recovery.blocks_audio() {
self.outage.get_or_insert(Some(error));
}
}
}
}
if self.input.is_some() && self.device_override.is_none() && self.replacement.is_none() {
let generation = (self.route_generation_source)();
if generation != self.route_generation {
self.route_generation = generation;
self.recovery.request_route_check(now);
}
}
if (capture_idle || self.input.is_none())
&& self.recovery.should_attempt(now)
&& self.replacement.is_none()
{
self.start_replacement();
}
if self.recovery.blocks_audio() {
std::thread::sleep(timeout);
return RecoveringAudioInputEvent::Timeout;
}
let Some(input) = self.input.as_ref() else {
std::thread::sleep(timeout);
return RecoveringAudioInputEvent::Timeout;
};
match input.recv_timeout(timeout) {
AudioInputEvent::Chunk {
samples,
captured_through,
} => RecoveringAudioInputEvent::Chunk {
samples,
captured_through,
},
AudioInputEvent::Timeout => RecoveringAudioInputEvent::Timeout,
AudioInputEvent::StreamFailed(error) => {
tracing::warn!(%error, device = %input.device_name, "microphone stream stopped; reopening it");
self.recovery.request_stream_recovery(Instant::now());
RecoveringAudioInputEvent::Interrupted
}
}
}
pub fn sample_rate(&self) -> u32 {
self.input
.as_ref()
.expect("microphone sample rate requested while closed")
.sample_rate
}
pub fn device_name(&self) -> &str {
&self
.input
.as_ref()
.expect("microphone name requested while closed")
.device_name
}
fn start_replacement(&mut self) {
let revision = self
.recovery
.target_revision
.unwrap_or(self.active_revision);
let keep = self
.input
.as_ref()
.filter(|_| !self.recovery.blocks_audio())
.map(|input| input.device_name.clone());
let request = OpenRequest {
device_override: self.device_override.clone(),
selected_device: self.selected_device.clone(),
fallback: SelectedFallback::AfterRetries,
keep,
avoids_bluetooth: true,
call_input: None,
};
self.route_generation = (self.route_generation_source)();
let opener = self.opener.clone();
let (sender, receiver) = mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = opener(&request).map_err(|error| error.to_string());
let _ = sender.send((revision, result));
});
self.replacement = Some(receiver);
}
fn after_resolving(&mut self, now: Instant) {
let look_again = match self.input.as_ref().map(|input| input.selection) {
Some(Selection::Unopenable) => true,
Some(Selection::Unlisted | Selection::LidClosed) => !self.reports_new_devices,
Some(Selection::InUse) | None => false,
};
if look_again {
self.recovery
.request_route_check(now + self.selection_retry);
self.selection_retry = self
.selection_retry
.saturating_mul(2)
.min(MICROPHONE_RETRY_MAX);
} else {
self.selection_retry = MICROPHONE_RETRY_INITIAL;
}
}
fn poll_replacement(&mut self) -> Option<InputOpenResult> {
let result = match self.replacement.as_ref()?.try_recv() {
Ok(result) => result,
Err(TryRecvError::Empty) => return None,
Err(TryRecvError::Disconnected) => (
self.active_revision,
Err("microphone replacement worker stopped".into()),
),
};
self.replacement = None;
Some(result)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InputTarget {
Named(usize),
SystemDefault,
StandIn(usize),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct Route {
lid_closed: bool,
avoid_bluetooth: bool,
}
pub(crate) const LID_CLOSED_ERROR: &str = "the built-in microphone is off while the lid is closed";
pub(crate) const NO_MICROPHONE_ERROR: &str = "no microphone is available";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InputPlan {
target: InputTarget,
selection: Selection,
}
fn resolve_input(
device_override: Option<&str>,
selected: Option<&str>,
names: &[String],
default: Option<&str>,
transport: impl Fn(usize) -> Transport,
route: Route,
) -> std::result::Result<InputPlan, String> {
let is_hex = crate::audio::hex_microphone::is_hex_virtual_device_name;
let position = |name: &str| names.iter().position(|listed| listed == name);
let is_built_in = |index: usize| transport(index) == Transport::BuiltIn;
let avoided = |index: usize| route.avoid_bluetooth && transport(index) == Transport::Bluetooth;
let off = |index: usize| route.lid_closed && is_built_in(index);
if let Some(name) = device_override {
if is_hex(name) {
return Err(format!(
"Hex cannot capture from its own virtual microphone: {name}"
));
}
return position(name)
.map(|index| InputPlan {
target: InputTarget::Named(index),
selection: Selection::InUse,
})
.ok_or_else(|| format!("microphone is unavailable: {name}"));
}
let selection = match selected.filter(|name| !is_hex(name)) {
Some(name) => match position(name) {
Some(index) if off(index) => Selection::LidClosed,
Some(index) => {
return Ok(InputPlan {
target: InputTarget::Named(index),
selection: Selection::InUse,
});
}
None => Selection::Unlisted,
},
None => Selection::InUse,
};
let default_usable = !names.is_empty()
&& default.is_some_and(|name| !is_hex(name) && !position(name).is_some_and(off));
let default_avoided = default.and_then(position).is_some_and(avoided);
if default_usable && !default_avoided {
return Ok(InputPlan {
target: InputTarget::SystemDefault,
selection,
});
}
let usable = || {
(0..names.len()).filter(|&index| {
!is_hex(&names[index])
&& !off(index)
&& !matches!(transport(index), Transport::Virtual | Transport::Continuity)
})
};
usable()
.find(|&index| is_built_in(index))
.or_else(|| usable().find(|&index| !avoided(index)))
.map(InputTarget::StandIn)
.or_else(|| default_usable.then_some(InputTarget::SystemDefault))
.or_else(|| usable().next().map(InputTarget::StandIn))
.map(|target| InputPlan { target, selection })
.ok_or_else(|| {
if (0..names.len()).any(off) {
LID_CLOSED_ERROR.into()
} else {
NO_MICROPHONE_ERROR.into()
}
})
}
fn current_route() -> Route {
Route {
lid_closed: lid_closed(),
avoid_bluetooth: avoid_bluetooth_microphone(),
}
}
#[cfg(not(target_os = "macos"))]
fn lid_closed() -> bool {
false
}
#[cfg(target_os = "macos")]
use lid::closed as lid_closed;
fn input_devices_of(host: &cpal::Host) -> Result<(Vec<Device>, Vec<String>)> {
Ok(host
.input_devices()
.wrap_err("could not enumerate input devices")?
.filter_map(|device| device_name(&device).map(|name| (device, name)))
.unzip())
}
fn default_input_of(host: &cpal::Host) -> Option<(Device, String)> {
let device = host.default_input_device()?;
let name = device_name(&device)?;
Some((device, name))
}
fn device_name(device: &Device) -> Option<String> {
description_name(device.description())
}
fn description_name<E: std::fmt::Display>(
description: std::result::Result<cpal::DeviceDescription, E>,
) -> Option<String> {
match description {
Ok(description) => Some(description.name().to_owned()),
Err(error) => {
tracing::debug!(%error, "skipping an input device whose name cannot be read");
None
}
}
}
fn open_configured_input(request: &OpenRequest) -> Result<Opened> {
if let Some(path) = fixture::path() {
if request.keep.as_deref() == Some(fixture::NAME) {
return Ok(Opened::Unchanged {
selection: Selection::InUse,
});
}
return Ok(Opened::Input(Box::new(fixture::open(path)?)));
}
let host = cpal::default_host();
let (devices, names) = input_devices_of(&host)?;
let (default, default_name) = default_input_of(&host).unzip();
let transports = devices.iter().map(transport).collect::<Vec<_>>();
let route = request.route(current_route());
let resolve = |selected: Option<&str>| {
resolve_input(
request.device_override.as_deref(),
selected,
&names,
default_name.as_deref(),
|index| transports[index],
route,
)
.map_err(|error| eyre!(error))
};
let open = |plan: InputPlan| -> Result<Opened> {
let (device, name, follows_default) = match plan.target {
InputTarget::Named(index) | InputTarget::StandIn(index) => {
(devices[index].clone(), names[index].clone(), false)
}
InputTarget::SystemDefault => (
default.clone().expect("resolved default"),
default_name.clone().expect("resolved default"),
true,
),
};
if request.keep.as_deref() == Some(name.as_str()) {
return Ok(Opened::Unchanged {
selection: plan.selection,
});
}
if let InputTarget::StandIn(_) = plan.target {
tracing::warn!(
default = default_name.as_deref().unwrap_or("none"),
using = %name,
lid_closed = route.lid_closed,
avoid_bluetooth = route.avoid_bluetooth,
"the system default input is Hex Microphone, off with the lid closed, Bluetooth, or missing; using another microphone"
);
}
match plan.selection {
Selection::InUse => {}
Selection::Unlisted => tracing::info!(
selected = request.selected_device.as_deref(),
"the saved microphone is not connected; using the system default"
),
Selection::Unopenable => tracing::warn!(
selected = request.selected_device.as_deref(),
"the saved microphone would not open; using the system default"
),
Selection::LidClosed => tracing::info!(
selected = request.selected_device.as_deref(),
"the saved microphone is off while the lid is closed; using the system default"
),
}
let mut input = AudioInput::open_device(device, name, follows_default)?;
input.selection = plan.selection;
#[cfg(target_os = "linux")]
if follows_default && route.avoid_bluetooth {
crate::linux::input_route::move_streams_off_bluetooth();
}
Ok(Opened::Input(Box::new(input)))
};
if let Some(index) =
(request.call_input.as_deref()).and_then(|uid| call_input::listed_at(uid, &devices, &names))
{
let target = InputTarget::Named(index);
match open(InputPlan {
target,
selection: Selection::InUse,
}) {
Ok(opened) => return Ok(opened),
Err(error) => tracing::warn!(%error, "the input the call uses would not open"),
}
}
let plan = resolve(request.selected_device.as_deref())?;
let (Some(selected), InputTarget::Named(_), None) = (
request.selected_device.as_deref(),
plan.target,
request.device_override.as_deref(),
) else {
return open(plan);
};
open_selected(
selected,
request.fallback,
|| open(plan),
|| {
let mut fallback = resolve(None)?;
fallback.selection = Selection::Unopenable;
open(fallback)
},
std::thread::sleep,
)
}
pub fn open_meeting_input(
selected_device: Option<&str>,
call_input: Option<&str>,
keep: Option<&str>,
) -> Result<Opened> {
open_configured_input(&OpenRequest {
device_override: None,
selected_device: selected_device.map(str::to_owned),
fallback: SelectedFallback::Immediate,
keep: keep.map(str::to_owned),
avoids_bluetooth: false,
call_input: call_input.map(str::to_owned),
})
}
fn open_selected<T>(
device: &str,
fallback: SelectedFallback,
mut open_selected: impl FnMut() -> Result<T>,
open_default: impl FnOnce() -> Result<T>,
mut sleep: impl FnMut(Duration),
) -> Result<T> {
let attempts = if fallback == SelectedFallback::AfterRetries {
SELECTED_MICROPHONE_ATTEMPTS
} else {
1
};
let mut attempt = 1;
let error = loop {
match open_selected() {
Ok(input) => return Ok(input),
Err(error) if attempt >= attempts => break error,
Err(error) => {
tracing::debug!(%error, device, attempt, "could not open the selected microphone; retrying");
attempt += 1;
sleep(SELECTED_MICROPHONE_RETRY_DELAY);
}
}
};
tracing::debug!(%error, device, "the saved microphone would not open");
open_default()
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InputDevices {
pub names: Vec<String>,
pub system_default: Option<String>,
pub default_is_hex_microphone: bool,
pub off_with_lid_closed: Option<String>,
pub default_is_off_with_lid_closed: bool,
pub bluetooth_default_passed_over: Option<String>,
}
pub fn input_devices() -> Result<InputDevices> {
#[cfg(target_os = "macos")]
watch_input_devices();
let host = cpal::default_host();
let (devices, listed) = input_devices_of(&host)?;
let default = default_input_of(&host).map(|(_, name)| name);
let default_is_hex_microphone = default
.as_deref()
.is_some_and(crate::audio::hex_microphone::is_hex_virtual_device_name);
let transports = devices.iter().map(transport).collect::<Vec<_>>();
let route = current_route();
let off_with_lid_closed = (0..devices.len())
.find(|&index| route.lid_closed && transports[index] == Transport::BuiltIn)
.map(|index| listed[index].clone());
let default_is_off_with_lid_closed =
off_with_lid_closed.is_some() && default == off_with_lid_closed;
let plan = resolve_input(
None,
None,
&listed,
default.as_deref(),
|index| transports[index],
route,
)
.ok();
let bluetooth_default_passed_over = default
.clone()
.filter(|name| {
listed
.iter()
.position(|listed| listed == name)
.is_some_and(|index| transports[index] == Transport::Bluetooth)
})
.filter(|_| matches!(plan.map(|plan| plan.target), Some(InputTarget::StandIn(_))));
let system_default = plan
.and_then(|plan| match plan.target {
InputTarget::SystemDefault => default.clone(),
InputTarget::Named(index) | InputTarget::StandIn(index) => Some(listed[index].clone()),
})
.filter(|_| cfg!(not(target_os = "linux")));
#[cfg(target_os = "linux")]
let (system_default, bluetooth_default_passed_over) = match route
.avoid_bluetooth
.then(crate::linux::input_route::bluetooth_stand_in)
.flatten()
{
Some(stand_in) => (Some(stand_in.input), Some(stand_in.bluetooth)),
None => (system_default, bluetooth_default_passed_over),
};
let mut names = listed
.into_iter()
.filter(|name| !crate::audio::hex_microphone::is_hex_virtual_device_name(name))
.collect::<Vec<_>>();
names.sort_by_key(|name| name.to_lowercase());
names.dedup();
Ok(InputDevices {
names,
system_default,
default_is_hex_microphone,
off_with_lid_closed,
default_is_off_with_lid_closed,
bluetooth_default_passed_over,
})
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub fn input_device_names() -> Result<Vec<String>> {
input_devices().map(|devices| devices.names)
}
fn mono<T>(samples: &[T], channels: usize, convert: impl Fn(&T) -> f32) -> Vec<f32> {
samples
.chunks(channels)
.map(|frame| frame.iter().map(&convert).sum::<f32>() / channels as f32)
.collect()
}
#[derive(Clone)]
struct StreamErrorReporter {
sender: SyncSender<String>,
default_name: Option<String>,
}
impl StreamErrorReporter {
fn report(&self, error: cpal::Error) {
#[cfg(target_os = "windows")]
if error.kind() == cpal::ErrorKind::StreamInvalidated
&& let Some(name) = &self.default_name
&& cpal::default_host()
.default_input_device()
.and_then(|device| device_name(&device))
.is_some_and(|current| current != *name)
{
tracing::info!(from = %name, "the default microphone changed");
notify_input_route_changed();
return;
}
#[cfg(not(target_os = "windows"))]
let _ = &self.default_name;
report_stream_error(&self.sender, error);
}
}
fn report_stream_error(sender: &SyncSender<String>, error: cpal::Error) {
if matches!(
error.kind(),
cpal::ErrorKind::Xrun | cpal::ErrorKind::RealtimeDenied
) {
tracing::warn!(%error, "microphone stream glitched; continuing");
*STREAM_GLITCHED_AT
.lock()
.unwrap_or_else(|error| error.into_inner()) = Some(Instant::now());
return;
}
tracing::error!(%error, "microphone stream failed");
let _ = sender.try_send(error.to_string());
}
fn build_stream<T: SizedSample>(
device: &Device,
config: &StreamConfig,
channels: usize,
sender: Sender<(Vec<f32>, CaptureInstant)>,
reporter: StreamErrorReporter,
mut feed: crate::audio::hex_microphone::CaptureFeed,
convert: impl Fn(T) -> f32 + Send + 'static,
) -> Result<Stream> {
let sample_rate = config.sample_rate;
device
.build_input_stream(
*config,
move |data: &[T], info| {
let captured_through = captured_through(info, data.len() / channels, sample_rate);
let mono_samples = mono(data, channels, |sample| convert(*sample));
feed.push(&mono_samples);
let _ = sender.send((mono_samples, captured_through));
},
move |error| reporter.report(error),
None,
)
.wrap_err_with(|| format!("could not open {} microphone stream", T::FORMAT))
}
fn captured_through(
info: &cpal::InputCallbackInfo,
frames: usize,
sample_rate: u32,
) -> CaptureInstant {
let captured_at = CaptureInstant::from_stream(info.timestamp().capture);
let duration = duration_for_frames(frames, sample_rate);
captured_at.checked_add(duration).unwrap_or(captured_at)
}
#[cfg(test)]
mod tests {
use super::*;
fn listed(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_owned()).collect()
}
const OPEN: Route = Route {
lid_closed: false,
avoid_bluetooth: false,
};
const CLOSED: Route = Route {
lid_closed: true,
avoid_bluetooth: false,
};
fn other(_: usize) -> Transport {
Transport::Other
}
fn built_in_at(at: usize) -> impl Fn(usize) -> Transport {
move |index| {
if index == at {
Transport::BuiltIn
} else {
Transport::Other
}
}
}
#[test]
fn a_headphone_jack_microphone_is_used_with_the_lid_closed() {
use InputTarget::{Named, StandIn, SystemDefault};
let names = listed(&["MacBook Pro Microphone", "External Microphone"]);
let transport = built_in_at(0);
let found = |target, selection| Ok(InputPlan { target, selection });
assert_eq!(
resolve_input(
None,
None,
&names,
Some("External Microphone"),
&transport,
CLOSED
),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
resolve_input(
None,
Some("External Microphone"),
&names,
Some("MacBook Pro Microphone"),
&transport,
CLOSED
),
found(Named(1), Selection::InUse)
);
assert_eq!(
resolve_input(
None,
None,
&names,
Some("MacBook Pro Microphone"),
&transport,
CLOSED
),
found(StandIn(1), Selection::InUse)
);
}
#[test]
fn system_default_passes_over_a_bluetooth_default() {
use InputTarget::{Named, StandIn, SystemDefault};
use Transport::{Bluetooth, BuiltIn, Other};
let names = listed(&[
"Hex Microphone",
"AirPods Pro",
"MacBook Pro Microphone",
"USB Mic",
]);
let transports = [Other, Bluetooth, BuiltIn, Other];
let transport = |index: usize| transports[index];
let avoid = Route {
lid_closed: false,
avoid_bluetooth: true,
};
let found = |target, selection| Ok(InputPlan { target, selection });
let plan = |selected, default, route| {
resolve_input(None, selected, &names, default, transport, route)
};
assert_eq!(
plan(None, Some("AirPods Pro"), avoid),
found(StandIn(2), Selection::InUse)
);
assert_eq!(
plan(Some("Gone"), Some("AirPods Pro"), avoid),
found(StandIn(2), Selection::Unlisted)
);
assert_eq!(
plan(Some("AirPods Pro"), Some("USB Mic"), avoid),
found(Named(1), Selection::InUse)
);
assert_eq!(
resolve_input(
Some("AirPods Pro"),
None,
&names,
Some("AirPods Pro"),
transport,
avoid
),
found(Named(1), Selection::InUse)
);
assert_eq!(
plan(None, Some("USB Mic"), avoid),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
plan(None, Some("AirPods Pro"), OPEN),
found(SystemDefault, Selection::InUse)
);
let closed = Route {
lid_closed: true,
..avoid
};
assert_eq!(
plan(None, Some("AirPods Pro"), closed),
found(StandIn(3), Selection::InUse)
);
let no_built_in = listed(&["Hex Microphone", "AirPods Pro", "USB Mic"]);
let no_built_in_transports = [Other, Bluetooth, Other];
assert_eq!(
resolve_input(
None,
None,
&no_built_in,
Some("Hex Microphone"),
|index| no_built_in_transports[index],
avoid
),
found(StandIn(2), Selection::InUse)
);
}
#[test]
fn a_meeting_keeps_the_bluetooth_default_a_call_uses() {
use Transport::{Bluetooth, BuiltIn};
let names = listed(&["AirPods Pro", "MacBook Pro Microphone"]);
let transport = |index: usize| [Bluetooth, BuiltIn][index];
let current = Route {
lid_closed: false,
avoid_bluetooth: true,
};
let request = |avoids_bluetooth| OpenRequest {
device_override: None,
selected_device: None,
fallback: SelectedFallback::Immediate,
keep: None,
avoids_bluetooth,
call_input: None,
};
let target = |request: OpenRequest| {
resolve_input(
None,
None,
&names,
Some("AirPods Pro"),
transport,
request.route(current),
)
.map(|plan| plan.target)
};
assert_eq!(target(request(true)), Ok(InputTarget::StandIn(1)));
assert_eq!(target(request(false)), Ok(InputTarget::SystemDefault));
}
#[test]
fn a_bluetooth_default_is_kept_when_nothing_else_is_usable() {
use InputTarget::SystemDefault;
use Transport::{Bluetooth, BuiltIn, Other};
let avoid = Route {
lid_closed: false,
avoid_bluetooth: true,
};
let found = |target| {
Ok(InputPlan {
target,
selection: Selection::InUse,
})
};
let only_bluetooth = listed(&["Hex Microphone", "AirPods Pro", "Beats"]);
let bluetooth = |index: usize| [Other, Bluetooth, Bluetooth][index];
assert_eq!(
resolve_input(
None,
None,
&only_bluetooth,
Some("AirPods Pro"),
bluetooth,
avoid
),
found(SystemDefault)
);
let lid = listed(&["AirPods Pro", "MacBook Pro Microphone"]);
let lid_transports = |index: usize| [Bluetooth, BuiltIn][index];
assert_eq!(
resolve_input(
None,
None,
&lid,
Some("AirPods Pro"),
lid_transports,
Route {
lid_closed: true,
avoid_bluetooth: true,
}
),
found(SystemDefault)
);
}
#[test]
fn a_virtual_input_never_stands_in() {
use InputTarget::{Named, StandIn, SystemDefault};
use Transport::{Bluetooth, BuiltIn, Other, Virtual};
let found = |target, selection| Ok(InputPlan { target, selection });
let avoid = Route {
lid_closed: false,
avoid_bluetooth: true,
};
let lid = listed(&["MacBook Pro Microphone", "Microsoft Teams Audio"]);
let lid_transports = |index: usize| [BuiltIn, Virtual][index];
assert_eq!(
resolve_input(
None,
None,
&lid,
Some("MacBook Pro Microphone"),
lid_transports,
CLOSED
),
Err(LID_CLOSED_ERROR.into())
);
let with_usb = listed(&["MacBook Pro Microphone", "Microsoft Teams Audio", "USB Mic"]);
let usb_transports = |index: usize| [BuiltIn, Virtual, Other][index];
assert_eq!(
resolve_input(
None,
None,
&with_usb,
Some("MacBook Pro Microphone"),
usb_transports,
CLOSED
),
found(StandIn(2), Selection::InUse)
);
let headset = listed(&["Microsoft Teams Audio", "AirPods Pro"]);
let headset_transports = |index: usize| [Virtual, Bluetooth][index];
assert_eq!(
resolve_input(
None,
None,
&headset,
Some("AirPods Pro"),
headset_transports,
avoid
),
found(SystemDefault, Selection::InUse)
);
let virtual_only = listed(&["Hex Microphone", "Microsoft Teams Audio"]);
let virtual_transports = |index: usize| [Virtual, Virtual][index];
assert_eq!(
resolve_input(
None,
None,
&virtual_only,
Some("Hex Microphone"),
virtual_transports,
OPEN
),
Err(NO_MICROPHONE_ERROR.into())
);
assert_eq!(
resolve_input(
None,
None,
&headset,
Some("Microsoft Teams Audio"),
headset_transports,
OPEN
),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
resolve_input(
None,
Some("Microsoft Teams Audio"),
&headset,
Some("AirPods Pro"),
headset_transports,
OPEN
),
found(Named(0), Selection::InUse)
);
assert_eq!(
resolve_input(
Some("Microsoft Teams Audio"),
None,
&headset,
Some("AirPods Pro"),
headset_transports,
OPEN
),
found(Named(0), Selection::InUse)
);
}
#[test]
fn an_iphone_microphone_never_stands_in() {
use InputTarget::{Named, SystemDefault};
use Transport::{Bluetooth, BuiltIn, Continuity};
let found = |target, selection| Ok(InputPlan { target, selection });
let lid = listed(&["MacBook Pro Microphone", "iPhone Microphone"]);
let lid_transports = |index: usize| [BuiltIn, Continuity][index];
assert_eq!(
resolve_input(
None,
None,
&lid,
Some("MacBook Pro Microphone"),
lid_transports,
CLOSED
),
Err(LID_CLOSED_ERROR.into())
);
let headset = listed(&["iPhone Microphone", "AirPods Pro"]);
let headset_transports = |index: usize| [Continuity, Bluetooth][index];
let avoid = Route {
lid_closed: false,
avoid_bluetooth: true,
};
assert_eq!(
resolve_input(
None,
None,
&headset,
Some("AirPods Pro"),
headset_transports,
avoid
),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
resolve_input(
None,
None,
&headset,
Some("iPhone Microphone"),
headset_transports,
OPEN
),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
resolve_input(
None,
Some("iPhone Microphone"),
&headset,
Some("AirPods Pro"),
headset_transports,
OPEN
),
found(Named(0), Selection::InUse)
);
assert_eq!(
resolve_input(
Some("iPhone Microphone"),
None,
&headset,
Some("AirPods Pro"),
headset_transports,
OPEN
),
found(Named(0), Selection::InUse)
);
}
#[test]
fn a_microphone_that_cannot_open_is_reported_once_per_outage() {
let mut input = RecoveringAudioInput::failed_for_test("Gone microphone");
let failure = input
.take_failure()
.expect("the startup failure is reported");
assert!(failure.contains("Gone microphone"), "{failure}");
assert_eq!(input.take_failure(), None);
input.outage.get_or_insert(Some("still gone".into()));
assert_eq!(input.take_failure(), None);
input.close();
input.outage.get_or_insert(Some("gone again".into()));
assert_eq!(input.take_failure().as_deref(), Some("gone again"));
}
#[test]
fn an_unreadable_device_name_is_skipped_instead_of_panicking() {
let unreadable: std::result::Result<cpal::DeviceDescription, cpal::Error> =
Err(cpal::ErrorKind::DeviceNotAvailable.into());
assert_eq!(description_name(unreadable), None);
let readable: std::result::Result<_, cpal::Error> = Ok(
cpal::DeviceDescriptionBuilder::new("MacBook Pro Microphone")
.manufacturer("Apple Inc.")
.build(),
);
assert_eq!(
description_name(readable).as_deref(),
Some("MacBook Pro Microphone")
);
}
#[test]
fn inputs_resolve_to_the_selection_then_the_system_default() {
use InputTarget::{Named, StandIn, SystemDefault};
let names = listed(&[
"Hex Microphone",
"KIT PODS",
"MacBook Pro Microphone",
"USB Mic",
]);
let built_in = built_in_at(2);
let plan =
|selected, default| resolve_input(None, selected, &names, default, &built_in, OPEN);
let found = |target, selection| Ok(InputPlan { target, selection });
assert_eq!(
plan(Some("USB Mic"), Some("KIT PODS")),
found(Named(3), Selection::InUse)
);
assert_eq!(
plan(None, Some("KIT PODS")),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
plan(Some("Studio Display Microphone"), Some("KIT PODS")),
found(SystemDefault, Selection::Unlisted)
);
assert_eq!(
plan(None, Some("Hex Microphone")),
found(StandIn(2), Selection::InUse)
);
assert_eq!(
plan(Some("Gone"), Some("Hex Microphone")),
found(StandIn(2), Selection::Unlisted)
);
let no_built_in = resolve_input(None, None, &names, Some("Hex Microphone"), other, OPEN);
assert_eq!(no_built_in, found(StandIn(1), Selection::InUse));
assert_eq!(plan(None, None), found(StandIn(2), Selection::InUse));
assert_eq!(
plan(Some("Hex Microphone"), Some("KIT PODS")),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
resolve_input(
None,
None,
&listed(&["Hex Microphone"]),
Some("Hex Microphone"),
&built_in,
OPEN
),
Err(NO_MICROPHONE_ERROR.into())
);
assert_eq!(
resolve_input(None, Some("USB Mic"), &[], None, &built_in, OPEN),
Err(NO_MICROPHONE_ERROR.into())
);
assert_eq!(
resolve_input(
None,
None,
&[],
Some("Default Audio Device"),
&built_in,
OPEN
),
Err(NO_MICROPHONE_ERROR.into())
);
}
#[test]
fn a_closed_lid_passes_over_the_built_in_microphone() {
use InputTarget::{Named, StandIn, SystemDefault};
let names = listed(&[
"Hex Microphone",
"MacBook Pro Microphone",
"Studio Display Microphone",
"USB Mic",
]);
let built_in = built_in_at(1);
let closed =
|selected, default| resolve_input(None, selected, &names, default, &built_in, CLOSED);
let found = |target, selection| Ok(InputPlan { target, selection });
assert_eq!(
closed(None, Some("MacBook Pro Microphone")),
found(StandIn(2), Selection::InUse)
);
assert_eq!(
closed(None, Some("Hex Microphone")),
found(StandIn(2), Selection::InUse)
);
assert_eq!(closed(None, None), found(StandIn(2), Selection::InUse));
assert_eq!(
closed(None, Some("USB Mic")),
found(SystemDefault, Selection::InUse)
);
assert_eq!(
closed(Some("MacBook Pro Microphone"), Some("USB Mic")),
found(SystemDefault, Selection::LidClosed)
);
assert_eq!(
closed(
Some("MacBook Pro Microphone"),
Some("MacBook Pro Microphone")
),
found(StandIn(2), Selection::LidClosed)
);
assert_eq!(
closed(Some("USB Mic"), Some("MacBook Pro Microphone")),
found(Named(3), Selection::InUse)
);
assert_eq!(
resolve_input(
None,
None,
&names,
Some("MacBook Pro Microphone"),
&built_in,
OPEN
),
found(SystemDefault, Selection::InUse)
);
let only_built_in = listed(&["Hex Microphone", "MacBook Pro Microphone"]);
for default in [Some("MacBook Pro Microphone"), Some("Hex Microphone"), None] {
assert_eq!(
resolve_input(None, None, &only_built_in, default, &built_in, CLOSED),
Err(LID_CLOSED_ERROR.into())
);
}
assert_eq!(
resolve_input(
None,
Some("MacBook Pro Microphone"),
&only_built_in,
Some("MacBook Pro Microphone"),
&built_in,
CLOSED
),
Err(LID_CLOSED_ERROR.into())
);
}
#[test]
fn device_override_wins_and_never_falls_back() {
let names = listed(&["Hex Microphone", "KIT PODS", "USB Mic"]);
let plan = |device_override| {
resolve_input(
device_override,
Some("KIT PODS"),
&names,
Some("KIT PODS"),
other,
OPEN,
)
};
assert_eq!(
plan(Some("USB Mic")),
Ok(InputPlan {
target: InputTarget::Named(2),
selection: Selection::InUse,
})
);
assert_eq!(
plan(Some("Missing Mic")),
Err("microphone is unavailable: Missing Mic".into())
);
assert!(
plan(Some("Hex Microphone"))
.unwrap_err()
.contains("its own virtual microphone")
);
}
static TEST_ROUTE_GENERATION: AtomicU64 = AtomicU64::new(0);
fn test_route_generation() -> u64 {
TEST_ROUTE_GENERATION.load(Ordering::Acquire)
}
fn settle(input: &mut RecoveringAudioInput, capture_idle: bool) -> RecoveringAudioInputEvent {
let mut event = input.recv_timeout(Duration::ZERO, capture_idle);
let deadline = Instant::now() + Duration::from_secs(5);
while input.is_opening() && Instant::now() < deadline {
event = input.recv_timeout(Duration::from_millis(1), capture_idle);
}
event
}
fn named_input(name: &str) -> AudioInput {
let (mut input, samples) = AudioInput::channel_for_test();
std::mem::forget(samples);
input.device_name = name.into();
input
}
#[test]
fn a_default_change_switches_only_while_capture_is_idle() {
let default = Arc::new(Mutex::new("KIT PODS".to_owned()));
let opener_default = default.clone();
let mut input = RecoveringAudioInput::with_opener_for_test(
None,
test_route_generation,
move |request| {
let name = opener_default.lock().unwrap().clone();
if request.keep.as_deref() == Some(name.as_str()) {
return Ok(Opened::Unchanged {
selection: Selection::InUse,
});
}
Ok(Opened::Input(Box::new(named_input(&name))))
},
);
input.finish_initial_open(Ok(named_input("KIT PODS")), Instant::now());
*default.lock().unwrap() = "MacBook Pro Microphone".into();
TEST_ROUTE_GENERATION.fetch_add(1, Ordering::AcqRel);
assert!(matches!(
settle(&mut input, false),
RecoveringAudioInputEvent::Timeout
));
assert!(!input.is_opening());
assert_eq!(input.device_name(), "KIT PODS");
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Reopened
));
assert_eq!(input.device_name(), "MacBook Pro Microphone");
TEST_ROUTE_GENERATION.fetch_add(1, Ordering::AcqRel);
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Timeout
));
assert!(input.recovery.reason.is_none());
assert_eq!(input.device_name(), "MacBook Pro Microphone");
}
fn standing_in(
missing: Selection,
reports_new_devices: bool,
route_generation: fn() -> u64,
) -> (
RecoveringAudioInput,
Arc<std::sync::atomic::AtomicBool>,
Instant,
) {
let connected = Arc::new(std::sync::atomic::AtomicBool::new(false));
let opener_connected = connected.clone();
let mut input = RecoveringAudioInput::with_opener_for_test(
Some("USB Mic"),
route_generation,
move |request| {
let (name, selection) = if opener_connected.load(Ordering::Acquire) {
("USB Mic", Selection::InUse)
} else {
("KIT PODS", missing)
};
if request.keep.as_deref() == Some(name) {
return Ok(Opened::Unchanged { selection });
}
let mut opened = named_input(name);
opened.selection = selection;
Ok(Opened::Input(Box::new(opened)))
},
);
input.reports_new_devices = reports_new_devices;
let mut stand_in = named_input("KIT PODS");
stand_in.selection = missing;
let started = Instant::now();
input.finish_initial_open(Ok(stand_in), started);
(input, connected, started)
}
#[test]
fn a_missing_selection_uses_the_default_until_it_returns() {
let (mut input, connected, started) = standing_in(Selection::Unlisted, false, || 0);
assert_eq!(
input.recovery.next_attempt,
Some(started + MICROPHONE_RETRY_INITIAL)
);
input.recovery.next_attempt = Some(Instant::now());
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Timeout
));
assert_eq!(input.device_name(), "KIT PODS");
assert_eq!(input.selection_retry, MICROPHONE_RETRY_INITIAL * 4);
connected.store(true, Ordering::Release);
input.recovery.next_attempt = Some(Instant::now());
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Reopened
));
assert_eq!(input.device_name(), "USB Mic");
assert_eq!(input.input.as_ref().unwrap().selection, Selection::InUse);
assert!(input.recovery.next_attempt.is_none());
assert_eq!(input.selected_device.as_deref(), Some("USB Mic"));
}
static DEVICE_ARRIVALS: AtomicU64 = AtomicU64::new(0);
#[test]
fn where_the_os_reports_new_devices_only_a_failed_open_is_retried() {
let arrivals = || DEVICE_ARRIVALS.load(Ordering::Acquire);
let (mut input, connected, _) = standing_in(Selection::Unlisted, true, arrivals);
assert!(input.recovery.next_attempt.is_none());
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Timeout
));
assert!(!input.is_opening());
connected.store(true, Ordering::Release);
DEVICE_ARRIVALS.fetch_add(1, Ordering::AcqRel);
assert!(matches!(
settle(&mut input, true),
RecoveringAudioInputEvent::Reopened
));
assert_eq!(input.device_name(), "USB Mic");
let (input, _, started) = standing_in(Selection::Unopenable, true, arrivals);
assert_eq!(
input.recovery.next_attempt,
Some(started + MICROPHONE_RETRY_INITIAL)
);
}
#[test]
fn only_an_idle_capture_lets_the_microphone_switch() {
assert!(capture_idle(false, false, false));
assert!(!capture_idle(true, false, false));
assert!(!capture_idle(false, true, false));
assert!(!capture_idle(false, false, true));
}
#[cfg(target_os = "macos")]
#[test]
fn mach_event_ticks_are_converted_to_nanoseconds() {
assert_eq!(scale_mach_ticks(72_000_000, 125, 3), 3_000_000_000);
assert_eq!(scale_mach_ticks(3_000_000_000, 1, 1), 3_000_000_000);
}
#[test]
fn stream_failures_are_visible_to_the_audio_owner() {
let (sender, errors) = mpsc::sync_channel(1);
report_stream_error(
&sender,
cpal::Error::with_message(
cpal::ErrorKind::StreamInvalidated,
"Device sample rate changed",
),
);
assert_eq!(errors.try_recv().unwrap(), "Device sample rate changed");
}
#[test]
fn overload_glitches_do_not_interrupt_the_stream() {
let (sender, errors) = mpsc::sync_channel(1);
report_stream_error(&sender, cpal::ErrorKind::Xrun.into());
report_stream_error(&sender, cpal::ErrorKind::RealtimeDenied.into());
assert!(errors.try_recv().is_err());
report_stream_error(&sender, cpal::ErrorKind::DeviceNotAvailable.into());
assert!(errors.try_recv().is_ok());
}
#[test]
fn cold_open_retries_the_selected_microphone_before_falling_back() {
let open = |fallback, failures: usize| {
let mut attempts = 0;
let mut sleeps = Vec::new();
let result = open_selected(
"USB microphone",
fallback,
|| {
attempts += 1;
if attempts > failures {
Ok("selected")
} else {
Err(eyre!("device is waking"))
}
},
|| Ok("automatic"),
|delay| sleeps.push(delay),
)
.map_err(|error| error.to_string());
(result, attempts, sleeps.len())
};
assert_eq!(
open(SelectedFallback::AfterRetries, 2),
(Ok("selected"), 3, 2)
);
assert_eq!(
open(SelectedFallback::AfterRetries, 3),
(Ok("automatic"), 3, 2)
);
assert_eq!(
open(SelectedFallback::Immediate, 1),
(Ok("automatic"), 1, 0)
);
}
#[test]
fn closed_input_applies_selection_without_opening() {
let mut input = RecoveringAudioInput::closed(None, 3, Some("Old microphone"));
input.request_selection(4, Some("Next microphone"));
assert!(!input.is_open());
assert_eq!(input.active_revision, 4);
assert_eq!(input.selected_device.as_deref(), Some("Next microphone"));
assert!(input.replacement.is_none());
}
#[test]
fn failed_startup_retry_keeps_backoff_without_an_open_stream() {
let mut input = RecoveringAudioInput::closed(None, 3, Some("Missing microphone"));
let started = Instant::now();
input.finish_initial_open(Err(eyre!("device unavailable")), started);
assert!(input.is_recovering());
assert!(
!input
.recovery
.should_attempt(started + Duration::from_millis(249))
);
assert!(
input
.recovery
.should_attempt(started + Duration::from_millis(250))
);
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
sender.send((3, Err("device unavailable".into()))).unwrap();
let before = Instant::now();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Timeout
));
let after = Instant::now();
assert!(!input.is_open());
assert!(!input.is_opening());
assert!(input.is_recovering());
let next_attempt = input.recovery.next_attempt.unwrap();
assert!(next_attempt >= before + Duration::from_millis(500));
assert!(next_attempt <= after + Duration::from_millis(500));
input.cancel_open();
input.request_open();
assert!(input.is_recovering());
assert!(!input.is_opening());
assert_eq!(input.recovery.next_attempt, Some(next_attempt));
input.request_selection(4, Some("Next microphone"));
assert!(input.is_recovering());
assert_eq!(input.recovery.target_revision, Some(4));
assert_eq!(input.active_revision, 3);
assert_eq!(input.selected_device.as_deref(), Some("Next microphone"));
assert!(input.recovery.should_attempt(Instant::now()));
}
#[test]
fn only_the_listeners_current_input_feeds_hex_microphone() {
let (meeting, _) = AudioInput::channel_for_test();
let (first, _) = AudioInput::channel_for_test();
let (replacement, _) = AudioInput::channel_for_test();
let first_feed = first.hex_microphone.clone();
let replacement_feed = replacement.hex_microphone.clone();
let mut input = RecoveringAudioInput::closed(None, 3, Some("Test microphone"));
input.finish_initial_open(Ok(first), Instant::now());
assert!(first_feed.is_on());
assert!(!meeting.hex_microphone.is_on());
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
sender
.send((3, Ok(Opened::Input(Box::new(replacement)))))
.unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Reopened
));
assert!(!first_feed.is_on());
assert!(replacement_feed.is_on());
input.close();
assert!(!replacement_feed.is_on());
}
#[test]
fn startup_retry_recovers_metadata_and_audio_without_restarting() {
let mut input = RecoveringAudioInput::closed(None, 3, Some("Test microphone"));
input.finish_initial_open(Err(eyre!("device unavailable")), Instant::now());
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
let (opened, samples) = AudioInput::channel_for_test();
sender
.send((3, Ok(Opened::Input(Box::new(opened)))))
.unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Reopened
));
assert!(!input.is_recovering());
assert!(!input.is_opening());
assert!(input.recovery.next_attempt.is_none());
assert_eq!(input.sample_rate(), 48_000);
assert_eq!(input.device_name(), "Test microphone");
let at = CaptureInstant::from_nanos(60_000_000_000);
samples.send((vec![0.25; 480], at)).unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Chunk { samples, captured_through }
if samples == vec![0.25; 480] && captured_through == at
));
}
#[test]
fn startup_recovery_opens_even_when_a_capture_is_pending() {
let mut input = RecoveringAudioInput::closed(
Some("Hex nonexistent microphone for pending recovery test"),
3,
None,
);
input.request_recovery();
input.request_selection(4, Some("Ignored selection under CLI override"));
assert_eq!(input.active_revision, 3);
assert!(input.selected_device.is_none());
assert!(matches!(
input.recv_timeout(Duration::ZERO, false),
RecoveringAudioInputEvent::Timeout
));
assert!(input.is_opening());
input.cancel_open();
assert!(input.is_opening());
input.close();
assert!(!input.is_opening());
assert!(!input.is_recovering());
assert!(input.recovery.next_attempt.is_none());
}
#[test]
fn failed_on_demand_open_does_not_retry_while_idle() {
let mut input = RecoveringAudioInput::closed(None, 3, None);
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
sender.send((3, Err("device unavailable".into()))).unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::OpenFailed(error) if error == "device unavailable"
));
assert!(!input.is_recovering());
assert!(!input.is_opening());
assert!(input.recovery.next_attempt.is_none());
}
#[test]
fn microphone_recovery_retries_with_bounded_backoff_and_resets() {
let started = Instant::now();
let mut recovery = MicrophoneRecovery::default();
recovery.request_selection_change(1, started);
assert!(!recovery.blocks_audio());
assert!(recovery.should_attempt(started));
recovery.failed(started);
recovery.request_selection_change(1, started + Duration::from_millis(100));
assert!(!recovery.should_attempt(started + Duration::from_millis(249)));
assert!(recovery.should_attempt(started + Duration::from_millis(250)));
let mut now = started + Duration::from_millis(250);
for expected_delay in [500, 1_000, 2_000, 4_000, 5_000, 5_000] {
recovery.failed(now);
assert!(!recovery.should_attempt(now + Duration::from_millis(expected_delay - 1)));
now += Duration::from_millis(expected_delay);
assert!(recovery.should_attempt(now));
}
recovery.recovered();
assert!(!recovery.blocks_audio());
assert!(!recovery.should_attempt(now));
recovery.request_stream_recovery(now);
assert!(recovery.blocks_audio());
recovery.failed(now);
recovery.request_stream_recovery(now + Duration::from_millis(100));
recovery.request_selection_change(2, now + Duration::from_millis(100));
assert!(recovery.blocks_audio());
assert!(recovery.should_attempt(now + Duration::from_millis(100)));
}
#[test]
fn audio_service_restart_interrupts_stream_and_recovers() {
let mut input = RecoveringAudioInput::closed(None, 1, Some("Test microphone"));
let (mut opened, _samples) = AudioInput::channel_for_test();
opened.watch_service_restarts_for_test();
input.finish_initial_open(Ok(opened), Instant::now());
assert!(input.is_open());
notify_audio_service_restarted();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Interrupted
));
assert!(input.is_recovering());
let (sender, receiver) = mpsc::channel();
input.replacement = Some(receiver);
let (reopened, samples_after) = AudioInput::channel_for_test();
sender
.send((1, Ok(Opened::Input(Box::new(reopened)))))
.unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Reopened
));
assert!(!input.is_recovering());
let at = CaptureInstant::from_nanos(10_000_000_000);
samples_after.send((vec![0.5; 240], at)).unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Chunk { samples, captured_through }
if samples == vec![0.5; 240] && captured_through == at
));
}
#[test]
fn a_stream_that_stops_delivering_audio_is_reopened() {
let stall = Duration::from_millis(400);
let mut input = RecoveringAudioInput::closed(None, 1, Some("Test microphone"));
let (mut opened, samples) = AudioInput::channel_for_test();
opened.watch_stalls_for_test(stall);
input.finish_initial_open(Ok(opened), Instant::now());
let at = CaptureInstant::from_nanos(10_000_000_000);
std::thread::sleep(stall / 4);
samples.send((vec![0.5; 240], at)).unwrap();
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Chunk { .. }
));
std::thread::sleep(stall / 4);
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Timeout
));
std::thread::sleep(stall);
assert!(matches!(
input.recv_timeout(Duration::ZERO, true),
RecoveringAudioInputEvent::Interrupted
));
assert!(input.is_recovering());
}
}