use std::task::Poll;
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::traits::Producer;
use crate::Error;
mod buffer;
#[cfg(target_os = "macos")]
mod channel;
mod permission;
#[cfg(target_os = "macos")]
mod screencapture;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Source {
Microphone(Option<String>),
System,
}
impl Default for Source {
fn default() -> Self {
Self::Microphone(None)
}
}
const FIRST_BUFFER_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Config {
pub source: Source,
pub sample_rate: Option<u32>,
pub channels: Option<u32>,
#[cfg(feature = "aec")]
pub aec: Option<crate::aec::Control>,
}
pub(crate) struct Samples {
pub data: Vec<f32>,
pub gap: bool,
recycle: Option<ringbuf::HeapProd<Vec<f32>>>,
}
impl Samples {
#[cfg(any(test, target_os = "macos"))]
pub(crate) fn plain(data: Vec<f32>, gap: bool) -> Self {
Self {
data,
gap,
recycle: None,
}
}
fn pooled(data: Vec<f32>, gap: bool, recycle: ringbuf::HeapProd<Vec<f32>>) -> Self {
Self {
data,
gap,
recycle: Some(recycle),
}
}
pub(crate) fn replace(&mut self, data: Vec<f32>) {
self.recycle();
self.data = data;
}
fn recycle(&mut self) {
let Some(mut recycle) = self.recycle.take() else {
return;
};
let mut data = std::mem::take(&mut self.data);
data.clear();
if recycle.try_push(data).is_err() {
}
}
}
impl Drop for Samples {
fn drop(&mut self) {
self.recycle();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Layout {
pub sample_rate: u32,
pub channels: u32,
}
#[derive(Debug)]
pub(crate) enum Failure {
Retry(Error),
Fatal(Error),
}
impl Failure {
pub(crate) fn retry(error: Error) -> Self {
Self::Retry(error)
}
pub(crate) fn fatal(error: Error) -> Self {
Self::Fatal(error)
}
fn cpal(error: cpal::Error) -> Self {
Self::classify(error.kind(), capture_err(error))
}
fn opening(error: cpal::Error, device: &str, layout: Layout) -> Self {
let Layout { sample_rate, channels } = layout;
let kind = error.kind();
let message = format!("microphone {device} at {sample_rate} Hz with {channels} channels: {error}");
let error = match kind {
cpal::ErrorKind::UnsupportedConfig | cpal::ErrorKind::InvalidInput => Error::Unsupported(message),
_ => Error::Capture(message),
};
Self::classify(kind, error)
}
fn classify(kind: cpal::ErrorKind, error: Error) -> Self {
if retryable(kind) {
Self::Retry(error)
} else {
Self::Fatal(error)
}
}
pub(crate) fn is_retryable(&self) -> bool {
matches!(self, Self::Retry(_))
}
pub(crate) fn into_error(self) -> Error {
match self {
Self::Retry(error) | Self::Fatal(error) => error,
}
}
}
pub(crate) enum Stream {
Microphone(Microphone),
#[cfg(target_os = "macos")]
System(screencapture::SystemAudio),
}
impl Stream {
pub(crate) fn device(&self) -> Option<&Device> {
match self {
Self::Microphone(mic) => Some(&mic.device),
#[cfg(target_os = "macos")]
Self::System(_) => None,
}
}
pub(crate) fn layout(&self) -> Layout {
match self {
Self::Microphone(mic) => mic.layout,
#[cfg(target_os = "macos")]
Self::System(system) => system.layout(),
}
}
pub(crate) async fn read(&mut self) -> Result<Option<Samples>, Failure> {
match self {
Self::Microphone(mic) => mic.read().await,
#[cfg(target_os = "macos")]
Self::System(system) => Ok(system.read().await),
}
}
}
pub(crate) async fn format(config: &Config) -> Result<Layout, Failure> {
match &config.source {
Source::Microphone(device) => {
let (device, config) = (device.clone(), config.clone());
tokio::task::spawn_blocking(move || {
let (_, _, _, stream_config) = resolve(device.as_deref(), &config)?;
Ok(Layout {
sample_rate: stream_config.sample_rate,
channels: u32::from(stream_config.channels),
})
})
.await
.map_err(|err| Failure::fatal(Error::Capture(format!("audio host thread failed: {err}"))))?
}
#[cfg(target_os = "macos")]
Source::System => Ok(screencapture::SystemAudio::format(config.sample_rate, config.channels)),
#[cfg(not(target_os = "macos"))]
Source::System => Err(Failure::fatal(Error::Unsupported(
"system audio capture is only supported on macOS".into(),
))),
}
}
pub(crate) async fn open(config: &Config) -> Result<Stream, Failure> {
match &config.source {
Source::Microphone(device) => Ok(Stream::Microphone(Microphone::open(device.as_deref(), config).await?)),
#[cfg(target_os = "macos")]
Source::System => Ok(Stream::System(
screencapture::SystemAudio::open(config.sample_rate, config.channels)
.await
.map_err(Failure::fatal)?,
)),
#[cfg(not(target_os = "macos"))]
Source::System => Err(Failure::fatal(Error::Unsupported(
"system audio capture is only supported on macOS".into(),
))),
}
}
pub(crate) struct Microphone {
_stream: cpal::Stream,
reader: MicrophoneReader,
pending: Option<Samples>,
layout: Layout,
device: Device,
}
struct MicrophoneReader {
rx: buffer::Reader,
errors: kio::Consumer<Option<cpal::Error>>,
}
impl MicrophoneReader {
async fn pending(&mut self, samples: Samples) -> Result<Option<Samples>, Failure> {
tokio::select! {
biased;
Some(err) = failure(&self.errors) => Err(err),
_ = std::future::ready(()) => Ok(Some(samples)),
}
}
async fn read(&mut self) -> Result<Option<Samples>, Failure> {
let data = tokio::select! {
biased;
Some(err) = failure(&self.errors) => return Err(err),
data = self.rx.recv() => data,
};
Ok(data)
}
}
async fn failure(errors: &kio::Consumer<Option<cpal::Error>>) -> Option<Failure> {
errors
.wait(|error| match error.as_ref() {
Some(error) => Poll::Ready(error.clone()),
None => Poll::Pending,
})
.await
.ok()
.map(Failure::cpal)
}
impl Microphone {
async fn open(selector: Option<&str>, config: &Config) -> Result<Self, Failure> {
permission::ensure_microphone_access().await.map_err(Failure::fatal)?;
let (device, current, sample_format, stream_config) = resolve(selector, config)?;
let sample_rate = stream_config.sample_rate;
let channels = u32::from(stream_config.channels);
let layout = Layout { sample_rate, channels };
let opening = |err| Failure::opening(err, &device.to_string(), layout);
#[cfg(feature = "aec")]
let aec = config
.aec
.as_ref()
.map(|control| control.attach(sample_rate, channels))
.transpose()
.map_err(Failure::fatal)?;
let (mut writer, rx) = buffer::channel(
channels as usize,
#[cfg(feature = "aec")]
aec,
);
let error_tx = kio::Producer::new(None);
let errors = error_tx.consume();
let mut reader = MicrophoneReader { rx, errors };
let stream = match sample_format {
cpal::SampleFormat::F32 => {
let errors = error_tx.clone();
device.build_input_stream(
stream_config,
move |data: &[f32], _: &_| writer.write_f32(data),
move |err| stream_err(&errors, err),
None,
)
}
cpal::SampleFormat::I16 => {
let errors = error_tx.clone();
device.build_input_stream(
stream_config,
move |data: &[i16], _: &_| writer.write_i16(data),
move |err| stream_err(&errors, err),
None,
)
}
cpal::SampleFormat::U16 => {
let errors = error_tx.clone();
device.build_input_stream(
stream_config,
move |data: &[u16], _: &_| writer.write_u16(data),
move |err| stream_err(&errors, err),
None,
)
}
other => {
return Err(Failure::fatal(Error::Unsupported(format!(
"microphone {device} captures {other}, which is not a supported sample format"
))));
}
}
.map_err(opening)?;
stream.play().map_err(opening)?;
let pending = match tokio::time::timeout(FIRST_BUFFER_TIMEOUT, reader.read()).await {
Ok(Ok(Some(samples))) => samples,
Ok(Ok(None)) => {
return Err(Failure::retry(Error::Capture(format!(
"microphone {device} stopped before any samples"
))));
}
Ok(Err(err)) => return Err(err),
Err(_) => {
return Err(Failure::fatal(Error::Capture(format!(
"no samples from microphone {device} within {FIRST_BUFFER_TIMEOUT:?} (permission denied?)"
))));
}
};
tracing::info!(device = %device, sample_rate, channels, "opened microphone");
Ok(Self {
_stream: stream,
reader,
pending: Some(pending),
layout,
device: current,
})
}
async fn read(&mut self) -> Result<Option<Samples>, Failure> {
if let Some(samples) = self.pending.take() {
return self.reader.pending(samples).await;
}
self.reader.read().await
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device {
pub id: String,
pub name: String,
pub default: bool,
pub host: String,
}
impl Device {
pub fn source(&self) -> Source {
Source::Microphone(Some(self.id.clone()))
}
}
pub async fn devices() -> Result<Vec<Device>, Error> {
blocking(list).await
}
fn list() -> Result<Vec<Device>, Error> {
let preferred = cpal::default_host().id();
let mut devices = Vec::new();
let mut seen = std::collections::HashSet::new();
for id in cpal::available_hosts() {
let host = match cpal::host_from_id(id) {
Ok(host) => host,
Err(err) => {
tracing::debug!(host = id.name(), error = %err, "skipping an audio host that would not open");
continue;
}
};
let default = host.default_input_device().and_then(|device| device.id().ok());
let inputs = match host.input_devices() {
Ok(inputs) => inputs,
Err(err) => {
tracing::debug!(host = id.name(), error = %err, "skipping a host that would not list its inputs");
continue;
}
};
for device in inputs {
let device_id = match device.id() {
Ok(device_id) => device_id,
Err(err) => {
tracing::debug!(host = id.name(), error = %err, "skipping an input device with no id");
continue;
}
};
if !seen.insert(device_id.to_string()) {
continue;
}
let is_default = id == preferred && Some(&device_id) == default.as_ref();
match describe(&device, &device_id, is_default) {
Ok(device) => devices.push(device),
Err(err) => {
tracing::debug!(error = %err, "skipping an input device that could not be described");
}
}
}
}
Ok(devices)
}
async fn blocking<T, F>(f: F) -> Result<T, Error>
where
F: FnOnce() -> Result<T, Error> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|err| Error::Capture(format!("audio host thread failed: {err}")))?
}
fn resolve(
selector: Option<&str>,
config: &Config,
) -> Result<(cpal::Device, Device, cpal::SampleFormat, cpal::StreamConfig), Failure> {
let host = cpal::default_host();
let default = host.default_input_device().and_then(|device| device.id().ok());
let (device, id) = match selector {
Some(selector) => {
let wanted: cpal::DeviceId = selector.parse().map_err(|err| {
Failure::fatal(Error::Device(format!(
"{selector:?} is not an input device id; run `devices` to list them: {err}"
)))
})?;
let host = cpal::host_from_id(wanted.host())
.map_err(|err| Failure::fatal(Error::Device(format!("{selector:?}: {err}"))))?;
let device = host
.input_devices()
.map_err(Failure::cpal)?
.find(|device| device.id().ok().as_ref() == Some(&wanted))
.ok_or_else(|| Failure::retry(Error::Device(format!("input device {selector:?} not found"))))?;
(device, wanted)
}
None => {
let device = host
.default_input_device()
.ok_or_else(|| Failure::retry(Error::Device("no default input device".into())))?;
let id = device.id().map_err(Failure::cpal)?;
(device, id)
}
};
let current = describe(&device, &id, Some(&id) == default.as_ref()).map_err(Failure::cpal)?;
let supported = device.default_input_config().map_err(Failure::cpal)?;
let supported = if config.sample_rate.is_none() && config.channels.is_none() {
supported
} else {
let ranges = device.supported_input_configs().map_err(Failure::cpal)?;
negotiate(&device.to_string(), supported, ranges, config).map_err(Failure::fatal)?
};
Ok((device, current, supported.sample_format(), supported.config()))
}
fn negotiate(
device: &str,
default: cpal::SupportedStreamConfig,
ranges: impl IntoIterator<Item = cpal::SupportedStreamConfigRange>,
config: &Config,
) -> Result<cpal::SupportedStreamConfig, Error> {
let sample_rate = config.sample_rate.unwrap_or(default.sample_rate());
let channels = config.channels.unwrap_or(u32::from(default.channels()));
if sample_rate == default.sample_rate() && channels == u32::from(default.channels()) {
return Ok(default);
}
let mut usable: Vec<_> = ranges
.into_iter()
.filter(|range| writable(range.sample_format()))
.collect();
let preferred = |range: &cpal::SupportedStreamConfigRange| range.sample_format() == default.sample_format();
let best = usable
.iter()
.filter(|range| u32::from(range.channels()) == channels && range.contains_rate(sample_rate))
.max_by(|a, b| {
preferred(a)
.cmp(&preferred(b))
.then_with(|| a.cmp_default_heuristics(b))
});
if let Some(best) = best {
return Ok(best.with_sample_rate(sample_rate));
}
usable.sort_by_key(|range| (range.channels(), range.min_sample_rate(), range.max_sample_rate()));
let supported: Vec<_> = usable
.iter()
.map(|range| {
format!(
"{} channels at {}-{} Hz ({})",
range.channels(),
range.min_sample_rate(),
range.max_sample_rate(),
range.sample_format()
)
})
.collect();
let supported = if supported.is_empty() {
"no usable format".to_string()
} else {
supported.join(", ")
};
Err(Error::Unsupported(format!(
"microphone {device} cannot capture {sample_rate} Hz with {channels} channels; it supports {supported}"
)))
}
fn writable(format: cpal::SampleFormat) -> bool {
matches!(
format,
cpal::SampleFormat::F32 | cpal::SampleFormat::I16 | cpal::SampleFormat::U16
)
}
fn describe(device: &cpal::Device, id: &cpal::DeviceId, default: bool) -> Result<Device, cpal::Error> {
Ok(Device {
default,
name: device.description()?.name().into(),
host: id.host().name().to_string(),
id: id.to_string(),
})
}
fn stream_err(errors: &kio::Producer<Option<cpal::Error>>, err: cpal::Error) {
if survivable(err.kind()) {
tracing::warn!(error = %err, "microphone stream error does not require a restart");
return;
}
tracing::error!(error = %err, "microphone stream error");
let Ok(mut failure) = errors.write() else { return };
if failure.is_some() {
return;
}
*failure = Some(err);
failure.close();
}
fn survivable(kind: cpal::ErrorKind) -> bool {
matches!(
kind,
cpal::ErrorKind::DeviceChanged | cpal::ErrorKind::RealtimeDenied | cpal::ErrorKind::Xrun
)
}
fn retryable(kind: cpal::ErrorKind) -> bool {
matches!(
kind,
cpal::ErrorKind::DeviceBusy
| cpal::ErrorKind::DeviceNotAvailable
| cpal::ErrorKind::HostUnavailable
| cpal::ErrorKind::ResourceExhausted
| cpal::ErrorKind::StreamInvalidated
)
}
fn capture_err(err: impl std::fmt::Display) -> Error {
Error::Capture(err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn reader() -> (buffer::Writer, kio::Producer<Option<cpal::Error>>, MicrophoneReader) {
let (tx, rx) = buffer::channel(
1,
#[cfg(feature = "aec")]
None,
);
let failures = kio::Producer::new(None);
let errors = failures.consume();
(tx, failures, MicrophoneReader { rx, errors })
}
fn fail(errors: &kio::Producer<Option<cpal::Error>>, message: &'static str) {
stream_err(
errors,
cpal::Error::with_message(cpal::ErrorKind::DeviceNotAvailable, message),
);
}
#[tokio::test]
async fn stream_error_wakes_a_reader_without_samples() {
let (_samples, errors, mut reader) = reader();
fail(&errors, "device lost");
let err = match reader.read().await {
Err(err) => err.into_error(),
Ok(_) => panic!("the reader ignored its stream error"),
};
assert!(matches!(err, Error::Capture(message) if message == "device lost"));
}
#[tokio::test]
async fn replaced_stream_cannot_fail_its_replacement() {
let (_old_samples, old_errors, old_reader) = reader();
let (mut new_samples, _new_errors, mut new_reader) = reader();
drop(old_reader);
fail(&old_errors, "stale");
new_samples.write_f32(&[1.0]);
let samples = new_reader.read().await.unwrap().unwrap();
assert_eq!(samples.data, vec![1.0]);
}
#[tokio::test]
async fn stream_error_wins_over_the_buffer_saved_during_open() {
let (_samples, errors, mut reader) = reader();
fail(&errors, "device lost");
let result = reader.pending(Samples::plain(vec![1.0], false)).await;
let err = match result {
Err(err) => err.into_error(),
Ok(_) => panic!("the pending sample hid a stream error"),
};
assert!(matches!(err, Error::Capture(message) if message == "device lost"));
}
#[test]
fn survivable_errors_do_not_end_the_stream() {
let (_samples, errors, _reader) = reader();
stream_err(&errors, cpal::Error::new(cpal::ErrorKind::DeviceChanged));
assert!(errors.read().is_none());
}
#[test]
fn permission_errors_are_not_retryable() {
let failure = Failure::cpal(cpal::Error::new(cpal::ErrorKind::PermissionDenied));
assert!(!failure.is_retryable());
}
const MIC: &str = "Test Mic";
fn default_config() -> cpal::SupportedStreamConfig {
cpal::SupportedStreamConfig::new(2, 48_000, cpal::SupportedBufferSize::Unknown, cpal::SampleFormat::F32)
}
fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange {
cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Unknown, format)
}
fn ranges() -> Vec<cpal::SupportedStreamConfigRange> {
vec![
range(1, 8_000, 48_000, cpal::SampleFormat::I16),
range(2, 8_000, 48_000, cpal::SampleFormat::I16),
range(2, 44_100, 48_000, cpal::SampleFormat::F32),
range(1, 96_000, 96_000, cpal::SampleFormat::I32),
]
}
fn request(sample_rate: Option<u32>, channels: Option<u32>) -> Config {
Config {
sample_rate,
channels,
..Default::default()
}
}
fn unsupported(result: Result<cpal::SupportedStreamConfig, Error>) -> String {
match result {
Err(Error::Unsupported(message)) => message,
other => panic!("expected an unsupported format, got {other:?}"),
}
}
#[test]
fn requesting_the_default_keeps_it() {
let config = negotiate(MIC, default_config(), [], &request(Some(48_000), Some(2))).unwrap();
assert_eq!(config, default_config());
}
#[test]
fn an_override_keeps_the_default_sample_format_when_it_can() {
let config = negotiate(MIC, default_config(), ranges(), &request(Some(44_100), None)).unwrap();
assert_eq!(config.sample_rate(), 44_100);
assert_eq!(config.channels(), 2);
assert_eq!(config.sample_format(), cpal::SampleFormat::F32);
}
#[test]
fn an_override_takes_another_sample_format_from_the_device_ranges() {
let config = negotiate(MIC, default_config(), ranges(), &request(None, Some(1))).unwrap();
assert_eq!(config.sample_rate(), 48_000);
assert_eq!(config.channels(), 1);
assert_eq!(config.sample_format(), cpal::SampleFormat::I16);
let config = negotiate(MIC, default_config(), ranges(), &request(Some(16_000), None)).unwrap();
assert_eq!(config.sample_rate(), 16_000);
assert_eq!(config.channels(), 2);
assert_eq!(config.sample_format(), cpal::SampleFormat::I16);
}
#[test]
fn an_unsupported_sample_rate_is_refused_with_context() {
let message = unsupported(negotiate(MIC, default_config(), ranges(), &request(Some(96_000), None)));
assert!(message.contains(MIC), "{message}");
assert!(message.contains("96000 Hz with 2 channels"), "{message}");
assert!(message.contains("1 channels at 8000-48000 Hz (i16)"), "{message}");
assert!(!message.contains("i32"), "{message}");
}
#[test]
fn a_channel_count_beyond_u16_does_not_wrap() {
let message = unsupported(negotiate(MIC, default_config(), ranges(), &request(None, Some(65_537))));
assert!(message.contains("48000 Hz with 65537 channels"), "{message}");
}
#[test]
fn a_device_with_no_usable_ranges_is_refused() {
let ranges = [range(1, 8_000, 48_000, cpal::SampleFormat::I32)];
let message = unsupported(negotiate(MIC, default_config(), ranges, &request(None, Some(1))));
assert!(message.contains("no usable format"), "{message}");
}
#[test]
fn a_refused_open_names_the_device_and_format() {
let layout = Layout {
sample_rate: 44_100,
channels: 1,
};
let failure = Failure::opening(cpal::Error::new(cpal::ErrorKind::UnsupportedConfig), MIC, layout);
assert!(!failure.is_retryable());
match failure.into_error() {
Error::Unsupported(message) => {
assert!(message.contains("Test Mic at 44100 Hz with 1 channels"), "{message}")
}
other => panic!("expected an unsupported format, got {other:?}"),
}
let failure = Failure::opening(cpal::Error::new(cpal::ErrorKind::DeviceBusy), MIC, layout);
assert!(failure.is_retryable());
match failure.into_error() {
Error::Capture(message) => assert!(message.contains("Test Mic at 44100 Hz with 1 channels"), "{message}"),
other => panic!("expected a capture failure, got {other:?}"),
}
}
#[test]
fn opaque_backend_errors_are_not_retryable() {
let failure = Failure::cpal(cpal::Error::new(cpal::ErrorKind::BackendError));
assert!(!failure.is_retryable());
}
}