From aef88d3749a26923dc0fe63471fbaff8dad444a1 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 30 Aug 2026 23:44:34 +0200 Subject: [PATCH 1/5] feat(alsa): add duplex stream support Opens capture and playback from the same pcm_id and drives both from one worker thread. Links them via snd_pcm_link() where possible; virtual PCMs (default, pulse, asym) don't support that, so linking failure is tolerated and both handles are started and polled independently instead. --- CHANGELOG.md | 1 + src/host/alsa/enumerate.rs | 6 +- src/host/alsa/mod.rs | 1470 ++++++++++++++++++++++++++++-------- 3 files changed, 1173 insertions(+), 304 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a956ef..a08381d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `StreamTrait::stop` ends a stream gracefully, draining buffered audio before halting (blocking up to a caller-supplied timeout). Dropping a stream still halts immediately without draining. - `CallbackInfo::xrun()` reports buffer over/underruns via the data callback. - `DeviceTrait::build_duplex_stream()`, `build_duplex_stream_raw()`, `default_duplex_config()`, and `supports_duplex()` for capture and playback from one device-level callback. +- **ALSA**: Duplex streams are now supported. - **AudioWorklet**: Input and duplex streams are now supported. - **WebAudio**: Input and duplex streams are now supported. diff --git a/src/host/alsa/enumerate.rs b/src/host/alsa/enumerate.rs index 144a04b54..c682c63c3 100644 --- a/src/host/alsa/enumerate.rs +++ b/src/host/alsa/enumerate.rs @@ -35,8 +35,10 @@ impl Host { for hint in hints { if let Some(pcm_id) = hint.name { // Per ALSA docs (https://alsa-project.org/alsa-doc/alsa-lib/group___hint.html), - // NULL IOID means both Input/Output. Whether a stream can actually open in a - // given direction can only be determined by attempting to open it. + // NULL IOID means both Input/Output. This is common for hw:N devices that do + // both directions, and for virtual PCMs like asym or pipewire. Whether a + // stream can actually open in a given direction can only be determined by + // attempting to open it. let direction = hint.direction.map_or(DeviceDirection::Duplex, Into::into); seen_pcm_ids.insert(pcm_id.clone()); let device = Device { diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 076306d29..90cf2053c 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -23,9 +23,9 @@ use self::alsa::poll::Descriptors; pub use self::enumerate::Devices; use crate::{ BufferSize, COMMON_SAMPLE_RATES, CallbackInfo, ChannelCount, Data, DeviceDescription, - DeviceDescriptionBuilder, DeviceDirection, DeviceId, Error, ErrorKind, FrameCount, - SampleFormat, SampleRate, StreamConfig, StreamInstant, StreamTimestamp, SupportedBufferSize, - SupportedStreamConfig, SupportedStreamConfigRange, + DeviceDescriptionBuilder, DeviceDirection, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, + Error, ErrorKind, FrameCount, SampleFormat, SampleRate, StreamConfig, StreamInstant, + StreamTimestamp, SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, host::{ Notify, equilibrium::{DSD_EQUILIBRIUM_BYTE, U8_EQUILIBRIUM_BYTE, fill_equilibrium}, @@ -240,6 +240,10 @@ impl DeviceTrait for Device { ) } + fn supports_duplex(&self) -> bool { + self.direction == DeviceDirection::Duplex + } + fn supported_input_configs(&self) -> Result { Self::supported_input_configs(self) } @@ -307,6 +311,30 @@ impl DeviceTrait for Device { ); Ok(stream) } + + fn build_duplex_stream_raw( + &self, + config: DuplexStreamConfig, + input_sample_format: SampleFormat, + output_sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let stream_inner = + self.build_duplex_stream_inner(config, input_sample_format, output_sample_format)?; + let stream = Self::Stream::new_duplex( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } } #[derive(Debug)] @@ -407,7 +435,11 @@ impl Device { let handle = open_pcm(&self.pcm_id, stream_type)?; let hw_params = set_hw_params_from_format(&handle, conf, sample_format)?; - let (buffer_size, period_size) = set_sw_params_from_format(&handle, stream_type)?; + let start_threshold = match stream_type { + alsa::Direction::Playback => StartThreshold::Periods(DEFAULT_PERIODS as usize), + alsa::Direction::Capture => StartThreshold::Immediate, + }; + let (buffer_size, period_size) = set_sw_params_from_format(&handle, start_threshold)?; if buffer_size == 0 || period_size == 0 { return Err(ErrorKind::DeviceNotAvailable.into()); } @@ -421,23 +453,14 @@ impl Device { // A zero get_htstamp() at prepare time indicates the device does not support hardware timestamps (e.g. PulseAudio ALSA plugin). // Related: https://bugs.freedesktop.org/show_bug.cgi?id=88503 let creation_ts = handle.status()?.get_htstamp(); - let timestamp_mode = if creation_ts.tv_sec == 0 && creation_ts.tv_nsec == 0 { - TimestampMode::CreationInstant - } else if hw_params.supports_audio_ts_type(alsa::pcm::AudioTstampType::LinkSynchronized) { - TimestampMode::AudioLink - } else { - TimestampMode::SystemClock - }; + let timestamp_mode = timestamp_mode_for(&hw_params, creation_ts); drop(hw_params); let period_size = period_size as usize; let frame_size = sample_format.sample_size() * conf.channels as usize; let stream_inner = StreamInner { - dropping: AtomicBool::new(false), - draining: AtomicBool::new(false), - parked: AtomicBool::new(false), - park: Notify::default(), + control: WorkerControl::default(), direction: stream_type.into(), handle, sample_format, @@ -445,7 +468,8 @@ impl Device { frame_size, period_size, period_samples: period_size * conf.channels as usize, - equilibrium: EquilibriumFill::new(sample_format, period_size * frame_size), + equilibrium: (stream_type == alsa::Direction::Playback) + .then(|| EquilibriumFill::new(sample_format, period_size * frame_size)), timestamp_mode, creation_ts, creation_instant: std::time::Instant::now(), @@ -456,6 +480,109 @@ impl Device { Ok(stream_inner) } + // Opens capture and playback from the same pcm_id with matching period/rate and returns the + // paired inner state used to drive both from one worker thread. Linking happens later, in + // begin_duplex_playback(). + fn build_duplex_stream_inner( + &self, + config: DuplexStreamConfig, + input_sample_format: SampleFormat, + output_sample_format: SampleFormat, + ) -> Result { + let capture_config = StreamConfig { + channels: config.input_channels, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + let playback_config = StreamConfig { + channels: config.output_channels, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + crate::validate_stream_config(&capture_config)?; + crate::validate_stream_config(&playback_config)?; + + let capture_handle = open_pcm(&self.pcm_id, alsa::Direction::Capture)?; + let playback_handle = open_pcm(&self.pcm_id, alsa::Direction::Playback)?; + + let capture_hw_params = + set_hw_params_from_format(&capture_handle, capture_config, input_sample_format)?; + let playback_hw_params = + set_hw_params_from_format(&playback_handle, playback_config, output_sample_format)?; + + let (capture_buffer_size, capture_period_size) = + set_sw_params_from_format(&capture_handle, StartThreshold::Disabled)?; + let (playback_buffer_size, playback_period_size) = + set_sw_params_from_format(&playback_handle, StartThreshold::Disabled)?; + if capture_buffer_size == 0 + || capture_period_size == 0 + || playback_buffer_size == 0 + || playback_period_size == 0 + { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + // Duplex drives both directions from one worker cycle; period sizes must match. + if capture_period_size != playback_period_size { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "capture and playback negotiated different period sizes ({capture_period_size} vs {playback_period_size} frames)" + ), + )); + } + + capture_handle.prepare()?; + playback_handle.prepare()?; + + if capture_handle.count() == 0 || playback_handle.count() == 0 { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + + let capture_creation_ts = capture_handle.status()?.get_htstamp(); + let capture_timestamp_mode = timestamp_mode_for(&capture_hw_params, capture_creation_ts); + drop(capture_hw_params); + let playback_creation_ts = playback_handle.status()?.get_htstamp(); + let playback_timestamp_mode = timestamp_mode_for(&playback_hw_params, playback_creation_ts); + drop(playback_hw_params); + + let period_size = capture_period_size as usize; + let capture_frame_size = input_sample_format.sample_size() * config.input_channels as usize; + let playback_frame_size = + output_sample_format.sample_size() * config.output_channels as usize; + + let stream_inner = DuplexStreamInner { + control: WorkerControl::default(), + capture: DuplexCaptureState { + handle: capture_handle, + sample_format: input_sample_format, + frame_size: capture_frame_size, + period_samples: period_size * config.input_channels as usize, + timestamp_mode: capture_timestamp_mode, + creation_ts: capture_creation_ts, + }, + playback: DuplexPlaybackState { + handle: playback_handle, + sample_format: output_sample_format, + frame_size: playback_frame_size, + period_samples: period_size * config.output_channels as usize, + timestamp_mode: playback_timestamp_mode, + creation_ts: playback_creation_ts, + equilibrium: EquilibriumFill::new( + output_sample_format, + period_size * playback_frame_size, + ), + }, + sample_rate: config.sample_rate, + period_size, + linked: AtomicBool::new(false), + creation_instant: std::time::Instant::now(), + pending_xrun: AtomicBool::new(false), + _context: self._context.clone(), + }; + + Ok(stream_inner) + } + fn description(&self) -> Result { let name = self .desc @@ -708,6 +835,22 @@ impl EquilibriumFill { } } +// A zero get_htstamp() at prepare time indicates the device does not support hardware +// timestamps (e.g. PulseAudio ALSA plugin). Related: +// https://bugs.freedesktop.org/show_bug.cgi?id=88503 +fn timestamp_mode_for( + hw_params: &alsa::pcm::HwParams<'_>, + creation_ts: alsa::timespec, +) -> TimestampMode { + if creation_ts.tv_sec == 0 && creation_ts.tv_nsec == 0 { + TimestampMode::CreationInstant + } else if hw_params.supports_audio_ts_type(alsa::pcm::AudioTstampType::LinkSynchronized) { + TimestampMode::AudioLink + } else { + TimestampMode::SystemClock + } +} + // How callback timestamps are produced. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TimestampMode { @@ -726,10 +869,11 @@ enum TimestampMode { AudioLink, } -#[derive(Debug)] -struct StreamInner { - // Flag used to check when to stop polling, regardless of the state of the stream - // (e.g. broken due to a disconnected device). +// Park/drop plumbing shared by StreamInner and DuplexStreamInner, giving StreamTrait +// exclusive worker access for pause/stop/drain regardless of handle count. +#[derive(Debug, Default)] +struct WorkerControl { + // Set when the worker should stop polling, e.g. after a device disconnect. dropping: AtomicBool, // Whether the user callback is currently suppressed. @@ -738,6 +882,57 @@ struct StreamInner { // Set by stop() to request the worker pause for exclusive PCM access during drain. parked: AtomicBool, park: Notify, +} + +impl WorkerControl { + // Pauses the worker at its next loop iteration and waits for acknowledgment, or returns + // early if it already exited. Caller holds exclusive PCM access until unpark_worker(). + fn park_worker(&self) { + self.parked.store(true, Ordering::Relaxed); + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + // Exit if the worker acknowledged the park OR if the worker has exited (dropping=true). + while !*guard && !self.dropping.load(Ordering::Relaxed) { + guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); + } + } + + // Acknowledges a pending park, then sleeps until unpark_worker() is called. + fn acknowledge_park(&self) { + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + *guard = true; + cvar.notify_one(); + while self.parked.load(Ordering::Relaxed) { + guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); + } + *guard = false; + } + + // Marks the stream dead and wakes any thread blocked in park_worker(), so an exit + // other than a normal drop doesn't hang it. + fn signal_worker_exit(&self) { + self.dropping.store(true, Ordering::Relaxed); + let (lock, cvar) = &self.park; + let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + cvar.notify_one(); + } + + // Releases the park: clears parked and wakes the worker from acknowledge_park(). + fn unpark_worker(&self) { + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + *guard = false; + self.parked.store(false, Ordering::Relaxed); + drop(guard); + cvar.notify_one(); + } +} + +#[derive(Debug)] +struct StreamInner { + // Controls the worker thread's lifecycle and pause/drain state. + control: WorkerControl, // Stream direction. direction: DeviceDirection, @@ -755,7 +950,8 @@ struct StreamInner { frame_size: usize, period_size: usize, period_samples: usize, - equilibrium: EquilibriumFill, + // Only used for Output direction. + equilibrium: Option, // How callback timestamps are produced. timestamp_mode: TimestampMode, @@ -778,14 +974,64 @@ struct StreamInner { // Assume that the ALSA library is built with thread safe option. unsafe impl Sync for StreamInner {} +#[derive(Debug)] +struct DuplexCaptureState { + handle: alsa::pcm::PCM, + sample_format: SampleFormat, + frame_size: usize, + period_samples: usize, + timestamp_mode: TimestampMode, + creation_ts: alsa::timespec, +} + +#[derive(Debug)] +struct DuplexPlaybackState { + handle: alsa::pcm::PCM, + sample_format: SampleFormat, + frame_size: usize, + period_samples: usize, + timestamp_mode: TimestampMode, + creation_ts: alsa::timespec, + equilibrium: EquilibriumFill, +} + +#[derive(Debug)] +struct DuplexStreamInner { + control: WorkerControl, + + capture: DuplexCaptureState, + playback: DuplexPlaybackState, + + sample_rate: SampleRate, + period_size: usize, + + // Ties capture and playback via snd_pcm_link(). begin_duplex_playback() retries when false. + // Recovery and pause leave it alone; ALSA doesn't document those as severing a link. + linked: AtomicBool, + + creation_instant: Instant, + pending_xrun: AtomicBool, + _context: Arc, +} + +// Assume that the ALSA library is built with thread safe option. +unsafe impl Sync for DuplexStreamInner {} + +impl DuplexStreamInner { + #[cfg(feature = "realtime")] + fn is_rt_eligible(&self) -> bool { + pcm_is_rt_eligible(&self.capture.handle) && pcm_is_rt_eligible(&self.playback.handle) + } +} + #[derive(Debug)] pub struct Stream { /// The high-priority audio processing thread calling callbacks. /// Option used for moving out in destructor. thread: Option>, - /// Handle to the underlying stream for playback controls. - inner: Arc, + /// Single-direction or duplex. + kind: StreamKind, /// Used to signal to stop processing. trigger: TriggerSender, @@ -798,126 +1044,109 @@ pub struct Stream { latch: Latch, } +#[derive(Debug)] +enum StreamKind { + Single(Arc), + Duplex(Arc), +} + impl StreamInner { #[inline] fn callback_instant(&self, status: &alsa::pcm::Status) -> StreamInstant { - // For playback the PCM starts in PREPARED state while the output buffer fills; - // snd_pcm_start() fires automatically at start_threshold, moving it to RUNNING. - // Therefore, callbacks arrive before RUNNING state. Using creation_ts as the - // anchor for all modes means timestamps advance monotonically through both the - // initial buffer fill and any later xrun recovery. - match self.timestamp_mode { - TimestampMode::CreationInstant => { - let d = std::time::Instant::now().duration_since(self.creation_instant); - StreamInstant::new(d.as_secs(), d.subsec_nanos()) - } - TimestampMode::SystemClock => { - // htstamp is the time of the most recent DMA interrupt on the configured - // monotonic clock. Subtracting creation_ts (same clock, prepare() time) - // gives elapsed time since stream creation in any PCM state. - htstamp_elapsed(status, self.creation_ts) - } - TimestampMode::AudioLink => { - // audio_htstamp measures elapsed time since snd_pcm_start() via hardware - // sample counter and TSC cross-timestamp, so it is only valid in RUNNING state. - if status.get_state() != alsa::pcm::State::Running { - // After xrun recovery, snd_pcm_prepare() does not reset trigger_htstamp - // (only snd_pcm_start() does), so it keeps its pre-xrun value while the - // hardware counter has not yet restarted. - htstamp_elapsed(status, self.creation_ts) + callback_instant_for( + self.timestamp_mode, + self.creation_ts, + self.creation_instant, + status, + ) + } +} + +#[inline] +fn callback_instant_for( + timestamp_mode: TimestampMode, + creation_ts: alsa::timespec, + creation_instant: std::time::Instant, + status: &alsa::pcm::Status, +) -> StreamInstant { + // For playback the PCM starts in PREPARED state while the output buffer fills; + // snd_pcm_start() fires automatically at start_threshold, moving it to RUNNING. + // Therefore, callbacks arrive before RUNNING state. Using creation_ts as the + // anchor for all modes means timestamps advance monotonically through both the + // initial buffer fill and any later xrun recovery. + match timestamp_mode { + TimestampMode::CreationInstant => { + let d = std::time::Instant::now().duration_since(creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) + } + TimestampMode::SystemClock => { + // htstamp is the time of the most recent DMA interrupt on the configured + // monotonic clock. Subtracting creation_ts (same clock, prepare() time) + // gives elapsed time since stream creation in any PCM state. + htstamp_elapsed(status, creation_ts) + } + TimestampMode::AudioLink => { + // audio_htstamp measures elapsed time since snd_pcm_start() via hardware + // sample counter and TSC cross-timestamp, so it is only valid in RUNNING state. + if status.get_state() != alsa::pcm::State::Running { + // After xrun recovery, snd_pcm_prepare() does not reset trigger_htstamp + // (only snd_pcm_start() does), so it keeps its pre-xrun value while the + // hardware counter has not yet restarted. + htstamp_elapsed(status, creation_ts) + } else { + // When running, add (trigger_ts − creation_ts) to express elapsed time + // since stream creation rather than since the last snd_pcm_start(). + let trigger_ts = status.get_trigger_htstamp(); + let trigger_offset = timespec_diff_nanos(trigger_ts, creation_ts); + if trigger_offset < 0 { + // trigger_ts predates creation_ts (driver bug); fall back to + // htstamp − creation_ts to preserve a monotone result. + htstamp_elapsed(status, creation_ts) } else { - // When running, add (trigger_ts − creation_ts) to express elapsed time - // since stream creation rather than since the last snd_pcm_start(). - let trigger_ts = status.get_trigger_htstamp(); - let trigger_offset = timespec_diff_nanos(trigger_ts, self.creation_ts); - if trigger_offset < 0 { - // trigger_ts predates creation_ts (driver bug); fall back to - // htstamp − creation_ts to preserve a monotone result. - htstamp_elapsed(status, self.creation_ts) - } else { - let audio_ts = status.get_audio_htstamp(); - let nanos = timespec_to_nanos(audio_ts) + trigger_offset; - StreamInstant::from_nanos(nanos as u64) - } + let audio_ts = status.get_audio_htstamp(); + let nanos = timespec_to_nanos(audio_ts) + trigger_offset; + StreamInstant::from_nanos(nanos as u64) } } } } +} +impl StreamInner { #[cfg(feature = "realtime")] fn is_rt_eligible(&self) -> bool { - use alsa_sys::*; - // SAFETY: `alsa::pcm::PCM` is `pub struct PCM(*mut snd_pcm_t, Cell)`. The crate - // does not expose a public `as_ptr()`, but we can cast and read from it. - // TODO: replace with `self.handle.as_ptr()` once alsa-rs exposes it publicly. - let raw = unsafe { - (&self.handle as *const alsa::pcm::PCM) - .cast::<*mut snd_pcm_t>() - .read() - }; - let pcm_type = unsafe { snd_pcm_type(raw) }; - - // Only attempt RT promotion for types known not to spin and not to chain to a - // server-backed backend. Therefore, we exclude: - // - NULL: always-ready poll() spins and exhausts RLIMIT_RTTIME, causing SIGXCPU. - // - IOPLUG/EXTPLUG: may route to PulseAudio, causing priority inversion and SIGXCPU. - // - HOOKS, SOFTVOL, PLUG, RATE, ROUTE, COPY: that can chain to either of the above. - matches!( - pcm_type, - SND_PCM_TYPE_HW - | SND_PCM_TYPE_LINEAR - | SND_PCM_TYPE_ALAW - | SND_PCM_TYPE_MULAW - | SND_PCM_TYPE_ADPCM - | SND_PCM_TYPE_LINEAR_FLOAT - | SND_PCM_TYPE_IEC958 - ) - } - - // Signals the worker to pause at the top of its next loop iteration and waits for it to - // acknowledge. Returns early if the worker has already exited. The caller holds exclusive - // PCM access until unpark_worker() is called. - fn park_worker(&self) { - self.parked.store(true, Ordering::Relaxed); - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - // Exit if the worker acknowledged the park OR if the worker has exited (dropping=true). - while !*guard && !self.dropping.load(Ordering::Relaxed) { - guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); - } - } - - // Called by the worker when it sees parked=true: acknowledges the park, then sleeps - // until the caller calls unpark_worker(). - fn acknowledge_park(&self) { - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - *guard = true; - cvar.notify_one(); - while self.parked.load(Ordering::Relaxed) { - guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); - } - *guard = false; - } - - // Called by the worker on any exit that is not a normal drop: marks the stream dead and - // wakes any thread blocked in park_worker() so it doesn't hang indefinitely. - fn signal_worker_exit(&self) { - self.dropping.store(true, Ordering::Relaxed); - let (lock, cvar) = &self.park; - let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - cvar.notify_one(); + pcm_is_rt_eligible(&self.handle) } +} - // Releases the park: clears parked and wakes the worker from acknowledge_park(). - fn unpark_worker(&self) { - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - *guard = false; - self.parked.store(false, Ordering::Relaxed); - drop(guard); - cvar.notify_one(); - } +#[cfg(feature = "realtime")] +fn pcm_is_rt_eligible(handle: &alsa::pcm::PCM) -> bool { + use alsa_sys::*; + // SAFETY: `alsa::pcm::PCM` is `pub struct PCM(*mut snd_pcm_t, Cell)`. The crate + // does not expose a public `as_ptr()`, but we can cast and read from it. + // TODO: replace with `handle.as_ptr()` once alsa-rs exposes it publicly. + let raw = unsafe { + (handle as *const alsa::pcm::PCM) + .cast::<*mut snd_pcm_t>() + .read() + }; + let pcm_type = unsafe { snd_pcm_type(raw) }; + + // Only attempt RT promotion for types known not to spin and not to chain to a + // server-backed backend. Therefore, we exclude: + // - NULL: always-ready poll() spins and exhausts RLIMIT_RTTIME, causing SIGXCPU. + // - IOPLUG/EXTPLUG: may route to PulseAudio, causing priority inversion and SIGXCPU. + // - HOOKS, SOFTVOL, PLUG, RATE, ROUTE, COPY: that can chain to either of the above. + matches!( + pcm_type, + SND_PCM_TYPE_HW + | SND_PCM_TYPE_LINEAR + | SND_PCM_TYPE_ALAW + | SND_PCM_TYPE_MULAW + | SND_PCM_TYPE_ADPCM + | SND_PCM_TYPE_LINEAR_FLOAT + | SND_PCM_TYPE_IEC958 + ) } struct StreamWorkerContext { @@ -996,11 +1225,11 @@ fn input_stream_worker( let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); loop { - if stream.dropping.load(Ordering::Relaxed) { + if stream.control.dropping.load(Ordering::Relaxed) { return; } - if stream.parked.load(Ordering::Relaxed) { - stream.acknowledge_park(); + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); } let result = match poll_for_period(&rx, stream, &mut ctxt) { Ok(Poll::Pending) => continue, @@ -1021,7 +1250,7 @@ fn input_stream_worker( match err.kind() { ErrorKind::DeviceNotAvailable => { error_callback(err); - stream.signal_worker_exit(); + stream.control.signal_worker_exit(); return; } _ => error_callback(err), @@ -1051,11 +1280,11 @@ fn output_stream_worker( let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); loop { - if stream.dropping.load(Ordering::Relaxed) { + if stream.control.dropping.load(Ordering::Relaxed) { return; } - if stream.parked.load(Ordering::Relaxed) { - stream.acknowledge_park(); + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); } let result = match poll_for_period(&rx, stream, &mut ctxt) { Ok(Poll::Pending) => continue, @@ -1076,7 +1305,7 @@ fn output_stream_worker( match err.kind() { ErrorKind::DeviceNotAvailable => { error_callback(err); - stream.signal_worker_exit(); + stream.control.signal_worker_exit(); return; } _ => error_callback(err), @@ -1135,7 +1364,6 @@ enum Poll { Recover, } -// This block is shared between both input and output stream worker functions. fn poll_for_period( rx: &TriggerReceiver, stream: &StreamInner, @@ -1217,17 +1445,9 @@ fn poll_for_period( return Ok(Poll::Pending); } - let audio_ts_type = match stream.timestamp_mode { - TimestampMode::AudioLink => alsa::pcm::AudioTstampType::LinkSynchronized, - TimestampMode::SystemClock | TimestampMode::CreationInstant => { - alsa::pcm::AudioTstampType::Compat - } - }; // From the guard above we know that this poll is not a spurious wakeup, // so we also know we can query the device in a stable state. - let status = alsa::pcm::StatusBuilder::new() - .audio_htstamp_config(audio_ts_type, false) - .build(&stream.handle)?; + let status = status_with_timestamp(&stream.handle, stream.timestamp_mode)?; Ok(Poll::Ready { status, @@ -1235,6 +1455,22 @@ fn poll_for_period( }) } +fn status_with_timestamp( + handle: &alsa::pcm::PCM, + mode: TimestampMode, +) -> Result { + let audio_ts_type = match mode { + TimestampMode::AudioLink => alsa::pcm::AudioTstampType::LinkSynchronized, + TimestampMode::SystemClock | TimestampMode::CreationInstant => { + alsa::pcm::AudioTstampType::Compat + } + }; + alsa::pcm::StatusBuilder::new() + .audio_htstamp_config(audio_ts_type, false) + .build(handle) + .map_err(Into::into) +} + // Full input underrun recovery: mark the xrun, then prepare + start the stream. fn recover_input(stream: &StreamInner) -> Result<(), Error> { stream.pending_xrun.store(true, Ordering::Relaxed); @@ -1278,7 +1514,7 @@ fn process_input( Err(err) => return Err(err.into()), } } - if !stream.draining.load(Ordering::Relaxed) { + if !stream.control.draining.load(Ordering::Relaxed) { let data = buffer.as_mut_ptr() as *mut (); let data = unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; let callback_instant = stream.callback_instant(&status); @@ -1315,9 +1551,13 @@ fn process_output( data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), ) -> Result<(), Error> { // Pre-fill buffer with equilibrium; user callback overwrites what it wants. - stream.equilibrium.fill(buffer); + stream + .equilibrium + .as_ref() + .expect("process_output only runs for Output-direction streams") + .fill(buffer); - if !stream.draining.load(Ordering::Relaxed) { + if !stream.control.draining.load(Ordering::Relaxed) { let data = buffer.as_mut_ptr() as *mut (); let mut data = unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; @@ -1362,37 +1602,485 @@ fn process_output( Ok(()) } -// Adapted from `timestamp2ns` here: -// https://fossies.org/linux/alsa-lib/test/audio_time.c -#[inline] -#[expect(clippy::unnecessary_cast)] -fn timespec_to_nanos(ts: alsa::timespec) -> i64 { - ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64 -} +// Prefills playback with silence, links the pair if not already linked, and starts capture +// (which starts playback too via kernel link-group propagation) or starts both explicitly if +// unlinked. Call only when both PCMs are Prepared. +// +// snd_pcm_link() only synchronizes PCMs sharing one card's hardware trigger, so it can fail +// (e.g. an `asym` PCM spanning two cards) while both PCMs still open and run fine independently. +// cpal can't verify hardware clock sharing either way, so a failed link doesn't refuse the +// stream: it proceeds unlinked instead of gating on a signal it can't fully trust. +fn begin_duplex_playback(stream: &DuplexStreamInner) -> Result<(), Error> { + let mut silence = vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); + stream.playback.equilibrium.fill(&mut silence); + for _ in 0..DEFAULT_PERIODS { + let mut frames_written = 0; + while frames_written < stream.period_size { + let n = stream + .playback + .handle + .io_bytes() + .writei(&silence[frames_written * stream.playback.frame_size..])?; + frames_written += n; + } + } -// Adapted from `timediff` here: -// https://fossies.org/linux/alsa-lib/test/audio_time.c -#[inline] -fn timespec_diff_nanos(a: alsa::timespec, b: alsa::timespec) -> i64 { - timespec_to_nanos(a) - timespec_to_nanos(b) + if !stream.linked.load(Ordering::Relaxed) + && stream.capture.handle.link(&stream.playback.handle).is_ok() + { + stream.linked.store(true, Ordering::Relaxed); + } + + stream.capture.handle.start()?; + if !stream.linked.load(Ordering::Relaxed) { + stream.playback.handle.start()?; + } + Ok(()) } -// StreamInstant representing how long htstamp is ahead of origin, clamped to zero. -// Used as the creation-relative timestamp source for SystemClock and AudioLink fallback paths. -#[inline] -fn htstamp_elapsed(status: &alsa::pcm::Status, origin: alsa::timespec) -> StreamInstant { - let nanos = timespec_diff_nanos(status.get_htstamp(), origin); - StreamInstant::from_nanos(nanos.max(0) as u64) +fn start_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { + match stream.capture.handle.state() { + alsa::pcm::State::Paused => { + let resumed = stream + .capture + .handle + .pause(false) + .and_then(|_| stream.playback.handle.pause(false)); + // Mirrors pause_duplex's fallback: resuming a linked pair via PAUSE_RELEASE can be + // as unreliable as pausing it was, on the same drivers. + if resumed.is_err() { + stream.capture.handle.drop().ok(); + stream.playback.handle.drop().ok(); + stream.capture.handle.prepare()?; + stream.playback.handle.prepare()?; + begin_duplex_playback(stream)?; + } + } + // Guard against Setup in case prepare() in stop() failed silently. + alsa::pcm::State::Prepared | alsa::pcm::State::Setup => { + if stream.capture.handle.state() == alsa::pcm::State::Setup { + stream.capture.handle.prepare()?; + } + if stream.playback.handle.state() == alsa::pcm::State::Setup { + stream.playback.handle.prepare()?; + } + begin_duplex_playback(stream)?; + } + _ => {} + } + Ok(()) } -impl Stream { - /// Parks the worker and gets exclusive access to the PCM handle. - fn park_worker(&self) { - self.latch.release(); - // Must be true before the trigger fires, so the worker sees it on the next loop iteration. - self.inner.parked.store(true, Ordering::Relaxed); - self.trigger.wakeup(); - self.inner.park_worker(); +// On xrun, drop() and prepare() both PCMs. When linked this is one call each: DROP and PREPARE +// propagate through the kernel link group the same way START does (snd_pcm_action_group() +// applies to every substream in the group, not just the one the ioctl was issued on). +fn recover_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { + stream.pending_xrun.store(true, Ordering::Relaxed); + if stream.linked.load(Ordering::Relaxed) { + stream.capture.handle.drop()?; + stream.capture.handle.prepare()?; + } else { + stream.capture.handle.drop().ok(); + stream.playback.handle.drop().ok(); + stream.capture.handle.prepare()?; + stream.playback.handle.prepare()?; + } + begin_duplex_playback(stream) +} + +struct DuplexStreamWorkerContext { + descriptors: Box<[libc::pollfd]>, + capture_range: std::ops::Range, + playback_range: std::ops::Range, + capture_buffer: Box<[u8]>, + playback_buffer: Box<[u8]>, + poll_timeout: i32, +} + +impl DuplexStreamWorkerContext { + fn new( + poll_timeout: &Option, + stream: &DuplexStreamInner, + rx: &TriggerReceiver, + ) -> Self { + let poll_timeout: i32 = if let Some(d) = poll_timeout { + d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 + } else { + POLL_INFINITE + }; + + let capture_buffer = + vec![0u8; stream.period_size * stream.capture.frame_size].into_boxed_slice(); + let playback_buffer = + vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); + + let capture_count = stream.capture.handle.count(); + let playback_count = stream.playback.handle.count(); + let mut descriptors = vec![ + libc::pollfd { + fd: 0, + events: 0, + revents: 0 + }; + 1 + capture_count + playback_count + ] + .into_boxed_slice(); + + descriptors[0] = libc::pollfd { + fd: rx.0, + events: libc::POLLIN, + revents: 0, + }; + + let capture_range = 1..(1 + capture_count); + let playback_range = capture_range.end..(capture_range.end + playback_count); + + let filled = stream + .capture + .handle + .fill(&mut descriptors[capture_range.clone()]) + .expect("Failed to fill ALSA capture descriptors"); + debug_assert_eq!(filled, capture_count); + let filled = stream + .playback + .handle + .fill(&mut descriptors[playback_range.clone()]) + .expect("Failed to fill ALSA playback descriptors"); + debug_assert_eq!(filled, playback_count); + + Self { + descriptors, + capture_range, + playback_range, + capture_buffer, + playback_buffer, + poll_timeout, + } + } +} + +fn duplex_stream_worker( + rx: Arc, + stream: &DuplexStreamInner, + data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(Error) + Send + 'static), + timeout: Option, +) { + #[cfg(feature = "realtime")] + if stream.is_rt_eligible() { + let period_frames = u32::try_from(stream.period_size).unwrap_or(0); + if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( + period_frames, + stream.sample_rate, + ) { + error_callback(err.into()); + } + } + + let mut ctxt = DuplexStreamWorkerContext::new(&timeout, stream, &rx); + loop { + if stream.control.dropping.load(Ordering::Relaxed) { + return; + } + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); + } + let result = match poll_for_duplex_period(&rx, stream, &mut ctxt) { + Ok(DuplexPoll::Pending) => continue, + Ok(DuplexPoll::Recover) => recover_duplex(stream), + Ok(DuplexPoll::Ready { + capture_status, + playback_status, + capture_delay, + playback_delay, + }) => process_duplex( + stream, + &mut ctxt.capture_buffer, + &mut ctxt.playback_buffer, + capture_status, + playback_status, + capture_delay, + playback_delay, + data_callback, + ), + Err(err) => Err(err), + }; + if let Err(err) = result { + match err.kind() { + ErrorKind::DeviceNotAvailable => { + error_callback(err); + stream.control.signal_worker_exit(); + return; + } + _ => error_callback(err), + } + } + } +} + +#[expect(clippy::large_enum_variant)] +enum DuplexPoll { + Pending, + Ready { + capture_status: alsa::pcm::Status, + playback_status: alsa::pcm::Status, + capture_delay: usize, + playback_delay: usize, + }, + Recover, +} + +// Neither direction is processed until both have a full period ready, keeping capture and +// playback in lockstep when snd_pcm_link() couldn't tie them together (virtual PCMs like +// default or pulse have no shared substream to link, so this is common on non-hw devices). +// Suspend goes straight to full recovery instead of a soft hardware resume, since duplex +// would need to keep that resume path in sync across two handles. +fn poll_for_duplex_period( + rx: &TriggerReceiver, + stream: &DuplexStreamInner, + ctxt: &mut DuplexStreamWorkerContext, +) -> Result { + let res = alsa::poll::poll(&mut ctxt.descriptors, ctxt.poll_timeout)?; + if res == 0 { + for handle in [&stream.capture.handle, &stream.playback.handle] { + match handle.state() { + alsa::pcm::State::Disconnected => { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + alsa::pcm::State::XRun | alsa::pcm::State::Suspended => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + _ => {} + } + } + return Ok(DuplexPoll::Pending); + } + + if ctxt.descriptors[0].revents != 0 { + rx.clear_pipe(); + return Ok(DuplexPoll::Pending); + } + + let capture_revents = stream + .capture + .handle + .revents(&ctxt.descriptors[ctxt.capture_range.clone()])?; + let playback_revents = stream + .playback + .handle + .revents(&ctxt.descriptors[ctxt.playback_range.clone()])?; + if capture_revents.is_empty() && playback_revents.is_empty() { + return Ok(DuplexPoll::Pending); + } + if capture_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) + || playback_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) + { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + + let (capture_avail, capture_delay) = match stream.capture.handle.avail_delay() { + Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + Err(err) if err.errno() == libc::EPIPE => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + res => res, + }?; + let (playback_avail, playback_delay) = match stream.playback.handle.avail_delay() { + Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + Err(err) if err.errno() == libc::EPIPE => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + res => res, + }?; + if capture_avail < stream.period_size as alsa::pcm::Frames + || playback_avail < stream.period_size as alsa::pcm::Frames + { + return Ok(DuplexPoll::Pending); + } + + let capture_status = + status_with_timestamp(&stream.capture.handle, stream.capture.timestamp_mode)?; + let playback_status = + status_with_timestamp(&stream.playback.handle, stream.playback.timestamp_mode)?; + + Ok(DuplexPoll::Ready { + capture_status, + playback_status, + capture_delay: capture_delay.max(0) as usize, + playback_delay: playback_delay.max(0) as usize, + }) +} + +#[expect(clippy::too_many_arguments)] +fn process_duplex( + stream: &DuplexStreamInner, + capture_buffer: &mut [u8], + playback_buffer: &mut [u8], + capture_status: alsa::pcm::Status, + playback_status: alsa::pcm::Status, + capture_delay: usize, + playback_delay: usize, + data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), +) -> Result<(), Error> { + let mut frames_read = 0; + while frames_read < stream.period_size { + match stream + .capture + .handle + .io_bytes() + .readi(&mut capture_buffer[frames_read * stream.capture.frame_size..]) + { + Ok(n) => frames_read += n, + Err(err) if err.errno() == libc::EAGAIN && frames_read == 0 => return Ok(()), + Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { + return recover_duplex(stream); + } + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_duplex(stream); + } + Err(err) => return Err(err.into()), + } + } + + stream.playback.equilibrium.fill(playback_buffer); + + if !stream.control.draining.load(Ordering::Relaxed) { + let input_ptr = capture_buffer.as_ptr() as *mut (); + let input_data = unsafe { + Data::from_parts( + input_ptr, + stream.capture.period_samples, + stream.capture.sample_format, + ) + }; + let output_ptr = playback_buffer.as_mut_ptr() as *mut (); + let mut output_data = unsafe { + Data::from_parts( + output_ptr, + stream.playback.period_samples, + stream.playback.sample_format, + ) + }; + + let capture_instant = callback_instant_for( + stream.capture.timestamp_mode, + stream.capture.creation_ts, + stream.creation_instant, + &capture_status, + ); + let capture_delay_duration = + frames_to_duration(capture_delay as FrameCount, stream.sample_rate); + let capture_device = capture_instant + .checked_sub(capture_delay_duration) + .unwrap_or(StreamInstant::ZERO); + + let playback_instant = callback_instant_for( + stream.playback.timestamp_mode, + stream.playback.creation_ts, + stream.creation_instant, + &playback_status, + ); + let playback_delay_duration = + frames_to_duration(playback_delay as FrameCount, stream.sample_rate); + let playback_device = playback_instant + playback_delay_duration; + + let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); + let info = DuplexCallbackInfo::new( + CallbackInfo { + timestamp: StreamTimestamp { + callback: capture_instant, + device: capture_device, + }, + xrun, + }, + CallbackInfo { + timestamp: StreamTimestamp { + callback: playback_instant, + device: playback_device, + }, + xrun, + }, + ); + data_callback(&input_data, &mut output_data, &info); + } + + let mut frames_written = 0; + while frames_written < stream.period_size { + match stream + .playback + .handle + .io_bytes() + .writei(&playback_buffer[frames_written * stream.playback.frame_size..]) + { + Ok(n) => frames_written += n, + Err(err) if err.errno() == libc::EAGAIN && frames_written == 0 => return Ok(()), + Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { + return recover_duplex(stream); + } + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_duplex(stream); + } + Err(err) => return Err(err.into()), + } + } + Ok(()) +} + +// Adapted from `timestamp2ns` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +#[inline] +#[expect(clippy::unnecessary_cast)] +fn timespec_to_nanos(ts: alsa::timespec) -> i64 { + ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64 +} + +// Adapted from `timediff` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +#[inline] +fn timespec_diff_nanos(a: alsa::timespec, b: alsa::timespec) -> i64 { + timespec_to_nanos(a) - timespec_to_nanos(b) +} + +// StreamInstant representing how long htstamp is ahead of origin, clamped to zero. +// Used as the creation-relative timestamp source for SystemClock and AudioLink fallback paths. +#[inline] +fn htstamp_elapsed(status: &alsa::pcm::Status, origin: alsa::timespec) -> StreamInstant { + let nanos = timespec_diff_nanos(status.get_htstamp(), origin); + StreamInstant::from_nanos(nanos.max(0) as u64) +} + +impl Stream { + /// Parks the worker and gets exclusive access to the PCM handle(s). + fn park_worker(&self) { + self.latch.release(); + // Must be true before the trigger fires, so the worker sees it on the next loop iteration. + match &self.kind { + StreamKind::Single(inner) => { + inner.control.parked.store(true, Ordering::Relaxed); + self.trigger.wakeup(); + inner.control.park_worker(); + } + StreamKind::Duplex(inner) => { + inner.control.parked.store(true, Ordering::Relaxed); + self.trigger.wakeup(); + inner.control.park_worker(); + } + } } fn new_input( @@ -1431,7 +2119,7 @@ impl Stream { Self { thread: Some(thread), - inner, + kind: StreamKind::Single(inner), trigger: tx, _rx: rx, latch, @@ -1474,119 +2162,238 @@ impl Stream { Self { thread: Some(thread), - inner, + kind: StreamKind::Single(inner), + trigger: tx, + _rx: rx, + latch, + } + } + + fn new_duplex( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let (tx, rx) = trigger(); + let rx_thread = rx.clone(); + let stream = inner.clone(); + + // The latch is released by play(); the worker blocks here until then, keeping both + // PCMs in PREPARED state with no DMA activity. + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let thread = thread::Builder::new() + .name("cpal_alsa_duplex".to_owned()) + .spawn(move || { + waiter.wait(); + duplex_stream_worker( + rx_thread, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + latch.add_thread(thread.thread().clone()); + + Self { + thread: Some(thread), + kind: StreamKind::Duplex(inner), trigger: tx, _rx: rx, latch, } } - fn suspend_pcm(&self) -> Result<(), Error> { - let hw_params = self.inner.handle.hw_params_current()?; + fn suspend_pcm(&self, inner: &StreamInner) -> Result<(), Error> { + let hw_params = inner.handle.hw_params_current()?; if hw_params.can_pause() { - if self.inner.handle.state() != alsa::pcm::State::Paused { - self.inner.handle.pause(true)?; + if inner.handle.state() != alsa::pcm::State::Paused { + inner.handle.pause(true)?; } } else { self.park_worker(); - let result = if self.inner.handle.state() == alsa::pcm::State::Running { - self.inner + let result = if inner.handle.state() == alsa::pcm::State::Running { + inner .handle .drop() - .and_then(|_| self.inner.handle.prepare()) + .and_then(|_| inner.handle.prepare()) .map_err(Error::from) } else { Ok(()) }; - self.inner.unpark_worker(); + inner.control.unpark_worker(); return result; } Ok(()) } // Drops buffered PCM data so a resumed stream doesn't deliver stale audio. - fn discard_pcm(&self) -> Result<(), Error> { + fn discard_pcm(&self, inner: &StreamInner) -> Result<(), Error> { self.park_worker(); - let result = if self.inner.handle.state() != alsa::pcm::State::Setup { - self.inner + let result = if inner.handle.state() != alsa::pcm::State::Setup { + inner .handle .drop() - .and_then(|_| self.inner.handle.prepare()) + .and_then(|_| inner.handle.prepare()) .map_err(Error::from) } else { Ok(()) }; - self.inner.unpark_worker(); + inner.control.unpark_worker(); result } - // Drains a parked output PCM: caller holds exclusive access via park_worker()/unpark_worker(). - fn drain_output(&self, timeout: Option) -> Result<(), Error> { - if timeout == Some(Duration::ZERO) { - self.inner.handle.drop().ok(); - return self.inner.handle.prepare().map_err(Into::into); - } - - // Non-blocking drain: the PCM is opened non-blocking, so snd_pcm_drain returns EAGAIN - // immediately. Poll the ALSA fds until drain completes or the deadline expires. - let deadline = timeout.and_then(|t| Instant::now().checked_add(t)); - let mut fds = self.inner.handle.get()?; - let mut result: Result<(), Error> = Ok(()); - - 'drain: loop { - match self.inner.handle.drain() { - Ok(()) => break, - Err(e) if e.errno() == libc::EAGAIN => { - let timeout_ms = match deadline { - None => POLL_INFINITE, - Some(deadline) => { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - self.inner.handle.drop().ok(); - break 'drain; - } - remaining.as_millis().min(i32::MAX as u128) as i32 - } - }; - match alsa::poll::poll(&mut fds, timeout_ms) { - Ok(0) => { - self.inner.handle.drop().ok(); - break 'drain; - } - Ok(_) => continue, - Err(e) => { - result = Err(e.into()); - break; - } - } + // PAUSE propagates through the link group like START does, so one call on capture pauses + // both when linked; the explicit playback call only does work when unlinked. + fn pause_duplex(&self, inner: &DuplexStreamInner) -> Result<(), Error> { + let can_pause = inner.capture.handle.hw_params_current()?.can_pause() + && inner.playback.handle.hw_params_current()?.can_pause(); + if can_pause { + let result: Result<(), alsa::Error> = (|| { + if inner.capture.handle.state() != alsa::pcm::State::Paused { + inner.capture.handle.pause(true)?; } - Err(e) => { - result = Err(e.into()); - break; + if inner.playback.handle.state() != alsa::pcm::State::Paused { + inner.playback.handle.pause(true)?; } + Ok(()) + })(); + // Some drivers advertise per-direction pause support that fails once the pair is + // linked; fall through to the discard path below instead of surfacing that error. + if result.is_ok() { + return Ok(()); } } - // Leave PCM in PREPARED so the worker can resume normally. - match self.inner.handle.state() { - alsa::pcm::State::Setup => { - // Drain completed or drop-on-timeout succeeded. - if let Err(e) = self.inner.handle.prepare() { - result = result.and(Err(e.into())); + self.park_worker(); + let capture_result = if inner.capture.handle.state() == alsa::pcm::State::Running { + inner + .capture + .handle + .drop() + .and_then(|_| inner.capture.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + let playback_result = if inner.playback.handle.state() == alsa::pcm::State::Running { + inner + .playback + .handle + .drop() + .and_then(|_| inner.playback.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + inner.control.unpark_worker(); + capture_result.and(playback_result) + } + + // Discards capture and drains playback, per StreamTrait::stop's per-direction contract. Left + // linked, capture.handle.start() in the next begin_duplex_playback() fails even after + // preparing capture: a linked start() needs the whole group ready, and playback sits in + // Setup until its own prepare() runs. Unlink first so each handle can be prepared and + // started independently. + fn stop_duplex( + &self, + inner: &DuplexStreamInner, + timeout: Option, + ) -> Result<(), Error> { + self.park_worker(); + if inner.linked.swap(false, Ordering::Relaxed) { + inner.capture.handle.unlink().ok(); // best-effort + } + let capture_result = if inner.capture.handle.state() != alsa::pcm::State::Setup { + inner + .capture + .handle + .drop() + .and_then(|_| inner.capture.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + let playback_result = drain_pcm(&inner.playback.handle, timeout); + inner.control.unpark_worker(); + capture_result.and(playback_result) + } +} + +// Drains a parked output PCM: caller holds exclusive access via park_worker()/unpark_worker(). +fn drain_pcm(handle: &alsa::pcm::PCM, timeout: Option) -> Result<(), Error> { + if timeout == Some(Duration::ZERO) { + handle.drop().ok(); + return handle.prepare().map_err(Into::into); + } + + // Non-blocking drain: the PCM is opened non-blocking, so snd_pcm_drain returns EAGAIN + // immediately. Poll the ALSA fds until drain completes or the deadline expires. + let deadline = timeout.and_then(|t| Instant::now().checked_add(t)); + let mut fds = handle.get()?; + let mut result: Result<(), Error> = Ok(()); + + 'drain: loop { + match handle.drain() { + Ok(()) => break, + Err(e) if e.errno() == libc::EAGAIN => { + let timeout_ms = match deadline { + None => POLL_INFINITE, + Some(deadline) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + handle.drop().ok(); + break 'drain; + } + remaining.as_millis().min(i32::MAX as u128) as i32 + } + }; + match alsa::poll::poll(&mut fds, timeout_ms) { + Ok(0) => { + handle.drop().ok(); + break 'drain; + } + Ok(_) => continue, + Err(e) => { + result = Err(e.into()); + break; + } } } - alsa::pcm::State::Draining => { - // A poll error interrupted an in-progress drain; abort it. - self.inner.handle.drop().ok(); - if let Err(e) = self.inner.handle.prepare() { - result = result.and(Err(e.into())); - } + Err(e) => { + result = Err(e.into()); + break; } - _ => {} // XRun, Running, Disconnected: worker's own recovery handles it } + } - result + // Leave PCM in PREPARED so the worker can resume normally. + match handle.state() { + alsa::pcm::State::Setup => { + // Drain completed or drop-on-timeout succeeded. + if let Err(e) = handle.prepare() { + result = result.and(Err(e.into())); + } + } + alsa::pcm::State::Draining => { + // A poll error interrupted an in-progress drain; abort it. + handle.drop().ok(); + if let Err(e) = handle.prepare() { + result = result.and(Err(e.into())); + } + } + _ => {} // XRun, Running, Disconnected: worker's own recovery handles it } + + result } impl Stream { @@ -1594,8 +2401,16 @@ impl Stream { // if parked, and wakes it from poll_for_period(). dropping must be set first so the // worker exits on re-entry rather than polling again. fn shutdown_worker(&self) { - self.inner.dropping.store(true, Ordering::Relaxed); - self.inner.unpark_worker(); + match &self.kind { + StreamKind::Single(inner) => { + inner.control.dropping.store(true, Ordering::Relaxed); + inner.control.unpark_worker(); + } + StreamKind::Duplex(inner) => { + inner.control.dropping.store(true, Ordering::Relaxed); + inner.control.unpark_worker(); + } + } self.trigger.wakeup(); } } @@ -1614,68 +2429,111 @@ impl Drop for Stream { impl StreamTrait for Stream { fn start(&self) -> Result<(), Error> { - self.inner.draining.store(false, Ordering::Relaxed); - self.latch.release(); // idempotent: no-op after first call - self.inner.unpark_worker(); // resume if stop() left it parked; no-op otherwise - match self.inner.handle.state() { - // Calling start() on an empty output buffer would trigger an immediate XRUN. - alsa::pcm::State::Prepared if self.inner.direction == DeviceDirection::Input => { - self.inner.handle.start()?; - } - alsa::pcm::State::Paused => { - self.inner.handle.pause(false)?; - } - // Guard against Setup in case prepare() in stop() failed silently. - alsa::pcm::State::Setup => { - self.inner.handle.prepare()?; - if self.inner.direction == DeviceDirection::Input { - self.inner.handle.start()?; + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(false, Ordering::Relaxed); + self.latch.release(); // idempotent: no-op after first call + inner.control.unpark_worker(); // resume if stop() left it parked; no-op otherwise + match inner.handle.state() { + // Calling start() on an empty output buffer would trigger an immediate XRUN. + alsa::pcm::State::Prepared if inner.direction == DeviceDirection::Input => { + inner.handle.start()?; + } + alsa::pcm::State::Paused => { + inner.handle.pause(false)?; + } + // Guard against Setup in case prepare() in stop() failed silently. + alsa::pcm::State::Setup => { + inner.handle.prepare()?; + if inner.direction == DeviceDirection::Input { + inner.handle.start()?; + } + } + _ => {} } + Ok(()) + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(false, Ordering::Relaxed); + self.latch.release(); + inner.control.unpark_worker(); + start_duplex(inner) } - _ => {} } - Ok(()) } fn pause(&self) -> Result<(), Error> { - self.inner.draining.store(true, Ordering::Relaxed); - self.suspend_pcm() + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.suspend_pcm(inner) + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.pause_duplex(inner) + } + } } fn stop(&self, timeout: Option) -> Result<(), Error> { - self.inner.draining.store(true, Ordering::Relaxed); + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); - if self.inner.direction != DeviceDirection::Output { - // Unlike pause(), stop() discards rather than preserves buffered samples. - return self.discard_pcm(); - } + if inner.direction != DeviceDirection::Output { + // Unlike pause(), stop() discards rather than preserves buffered samples. + return self.discard_pcm(inner); + } - self.park_worker(); - let result = self.drain_output(timeout); - self.inner.unpark_worker(); - result + self.park_worker(); + let result = drain_pcm(&inner.handle, timeout); + inner.control.unpark_worker(); + result + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.stop_duplex(inner, timeout) + } + } } fn now(&self) -> StreamInstant { - if self.inner.timestamp_mode != TimestampMode::CreationInstant { - let audio_ts_type = match self.inner.timestamp_mode { - TimestampMode::AudioLink => alsa::pcm::AudioTstampType::LinkSynchronized, - _ => alsa::pcm::AudioTstampType::Compat, - }; - if let Ok(status) = alsa::pcm::StatusBuilder::new() - .audio_htstamp_config(audio_ts_type, false) - .build(&self.inner.handle) - { - return self.inner.callback_instant(&status); + match &self.kind { + StreamKind::Single(inner) => { + if inner.timestamp_mode != TimestampMode::CreationInstant { + if let Ok(status) = status_with_timestamp(&inner.handle, inner.timestamp_mode) { + return inner.callback_instant(&status); + } + } + let d = std::time::Instant::now().duration_since(inner.creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) + } + StreamKind::Duplex(inner) => { + // Capture is the canonical clock, matching how process_duplex derives + // DuplexCallbackInfo's capture-side timestamp. + if inner.capture.timestamp_mode != TimestampMode::CreationInstant { + if let Ok(status) = + status_with_timestamp(&inner.capture.handle, inner.capture.timestamp_mode) + { + return callback_instant_for( + inner.capture.timestamp_mode, + inner.capture.creation_ts, + inner.creation_instant, + &status, + ); + } + } + let d = std::time::Instant::now().duration_since(inner.creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) } } - - let d = std::time::Instant::now().duration_since(self.inner.creation_instant); - StreamInstant::new(d.as_secs(), d.subsec_nanos()) } fn buffer_size(&self) -> Result { - Ok(self.inner.period_size as FrameCount) + match &self.kind { + StreamKind::Single(inner) => Ok(inner.period_size as FrameCount), + StreamKind::Duplex(inner) => Ok(inner.period_size as FrameCount), + } } } @@ -1889,24 +2747,32 @@ fn set_hw_params_from_format( pcm_handle.hw_params_current().map_err(Into::into) } +// What triggers ALSA's automatic Prepared -> Running transition. +enum StartThreshold { + // Capture: any read request satisfies this trivially - effectively immediate. + Immediate, + // Playback: starts once this many periods are queued, regardless of total buffer depth. + Periods(usize), + // Never automatically; the caller starts the PCM explicitly. + Disabled, +} + fn set_sw_params_from_format( pcm_handle: &alsa::pcm::PCM, - stream_type: alsa::Direction, + start_threshold: StartThreshold, ) -> Result<(alsa::pcm::Frames, alsa::pcm::Frames), Error> { let sw_params = pcm_handle.sw_params_current()?; let (buffer_size, period_size) = pcm_handle .get_params() .map(|(b, p)| (b as alsa::pcm::Frames, p as alsa::pcm::Frames))?; - let start_threshold = match stream_type { - alsa::Direction::Playback => { - // Start playback when 2 periods are filled. This ensures consistent low-latency - // startup regardless of total buffer size (whether 2 or more periods). - DEFAULT_PERIODS * period_size - } - alsa::Direction::Capture => 1, + let threshold = match start_threshold { + StartThreshold::Immediate => 1, + StartThreshold::Periods(periods) => periods as alsa::pcm::Frames * period_size, + // boundary is unreachable, so auto-start never fires. + StartThreshold::Disabled => sw_params.get_boundary()?, }; - sw_params.set_start_threshold(start_threshold)?; + sw_params.set_start_threshold(threshold)?; sw_params.set_avail_min(period_size)?; sw_params.set_tstamp_mode(true)?; From 82da885875f7e5c78a793aefc471cf844935e3a5 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 Aug 2026 21:29:27 +0200 Subject: [PATCH 2/5] refactor(alsa): split into submodules --- src/host/alsa/device.rs | 541 +++++++ src/host/alsa/duplex.rs | 455 ++++++ src/host/alsa/hw_params.rs | 257 ++++ src/host/alsa/mod.rs | 2762 ++---------------------------------- src/host/alsa/stream.rs | 797 +++++++++++ src/host/alsa/timestamp.rs | 91 ++ src/host/alsa/trigger.rs | 81 ++ src/host/alsa/worker.rs | 450 ++++++ 8 files changed, 2758 insertions(+), 2676 deletions(-) create mode 100644 src/host/alsa/device.rs create mode 100644 src/host/alsa/duplex.rs create mode 100644 src/host/alsa/hw_params.rs create mode 100644 src/host/alsa/stream.rs create mode 100644 src/host/alsa/timestamp.rs create mode 100644 src/host/alsa/trigger.rs create mode 100644 src/host/alsa/worker.rs diff --git a/src/host/alsa/device.rs b/src/host/alsa/device.rs new file mode 100644 index 000000000..8122be595 --- /dev/null +++ b/src/host/alsa/device.rs @@ -0,0 +1,541 @@ +use std::{ + collections::HashMap, + fmt, + sync::{Arc, atomic::AtomicBool}, + time::Duration, + vec::IntoIter as VecIntoIter, +}; + +use super::{ + AlsaContext, DEFAULT_PERIODS, Stream, alsa, + alsa::poll::Descriptors, + hw_params::{ + StartThreshold, set_hw_params_from_format, set_sw_params_from_format, + supported_period_size_range, + }, + open_pcm, + stream::{ + DuplexCaptureState, DuplexPlaybackState, DuplexStreamInner, EquilibriumFill, StreamInner, + WorkerControl, timestamp_mode_for, + }, +}; +use crate::{ + COMMON_SAMPLE_RATES, CallbackInfo, ChannelCount, Data, DeviceDescription, + DeviceDescriptionBuilder, DeviceDirection, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, + Error, ErrorKind, SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, + SupportedStreamConfig, SupportedStreamConfigRange, + iter::{SupportedInputConfigs, SupportedOutputConfigs}, + traits::DeviceTrait, +}; + +#[derive(Clone, Debug)] +pub struct Device { + pub(super) pcm_id: String, + pub(super) desc: Option, + pub(super) direction: DeviceDirection, + pub(super) _context: Arc, +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn description(&self) -> Result { + Self::description(self) + } + + fn id(&self) -> Result { + Self::id(self) + } + + // Override trait defaults to avoid opening devices during enumeration. + // + // ALSA does not guarantee transactional cleanup on failed snd_pcm_open(). Opening plugins like + // alsaequal that fail with EPERM can leak FDs, poisoning the ALSA backend for the process + // lifetime (subsequent device opens fail with EBUSY until process exit). + fn supports_input(&self) -> bool { + matches!( + self.direction, + DeviceDirection::Input | DeviceDirection::Duplex + ) + } + + fn supports_output(&self) -> bool { + matches!( + self.direction, + DeviceDirection::Output | DeviceDirection::Duplex + ) + } + + fn supports_duplex(&self) -> bool { + self.direction == DeviceDirection::Duplex + } + + fn supported_input_configs(&self) -> Result { + Self::supported_input_configs(self) + } + + fn supported_output_configs(&self) -> Result { + Self::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Self::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Self::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + conf: StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &CallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + // Keep `capture` monotonic: avail_delay() varies between cycles, and a capture overrun + // can make it jump up enough to pull `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); + let stream_inner = + self.build_stream_inner(conf, sample_format, alsa::Direction::Capture)?; + let stream = Self::Stream::new_input( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } + + fn build_output_stream_raw( + &self, + conf: StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &CallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + // Keep `playback` monotonic: avail_delay() varies between cycles, and a playback + // underrun can drain the buffer enough to pull `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); + let stream_inner = + self.build_stream_inner(conf, sample_format, alsa::Direction::Playback)?; + let stream = Self::Stream::new_output( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } + + fn build_duplex_stream_raw( + &self, + config: DuplexStreamConfig, + input_sample_format: SampleFormat, + output_sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let stream_inner = + self.build_duplex_stream_inner(config, input_sample_format, output_sample_format)?; + let stream = Self::Stream::new_duplex( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } +} + +impl Device { + fn build_stream_inner( + &self, + conf: StreamConfig, + sample_format: SampleFormat, + stream_type: alsa::Direction, + ) -> Result { + crate::validate_stream_config(&conf)?; + + let handle = open_pcm(&self.pcm_id, stream_type)?; + + let hw_params = set_hw_params_from_format(&handle, conf, sample_format)?; + let start_threshold = match stream_type { + alsa::Direction::Playback => StartThreshold::Periods(DEFAULT_PERIODS as usize), + alsa::Direction::Capture => StartThreshold::Immediate, + }; + let (buffer_size, period_size) = set_sw_params_from_format(&handle, start_threshold)?; + if buffer_size == 0 || period_size == 0 { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + + handle.prepare()?; + + if handle.count() == 0 { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + + // A zero get_htstamp() at prepare time indicates the device does not support hardware timestamps (e.g. PulseAudio ALSA plugin). + // Related: https://bugs.freedesktop.org/show_bug.cgi?id=88503 + let creation_ts = handle.status()?.get_htstamp(); + let timestamp_mode = timestamp_mode_for(&hw_params, creation_ts); + drop(hw_params); + + let period_size = period_size as usize; + let frame_size = sample_format.sample_size() * conf.channels as usize; + + let stream_inner = StreamInner { + control: WorkerControl::default(), + direction: stream_type.into(), + handle, + sample_format, + sample_rate: conf.sample_rate, + frame_size, + period_size, + period_samples: period_size * conf.channels as usize, + equilibrium: (stream_type == alsa::Direction::Playback) + .then(|| EquilibriumFill::new(sample_format, period_size * frame_size)), + timestamp_mode, + creation_ts, + creation_instant: std::time::Instant::now(), + pending_xrun: AtomicBool::new(false), + _context: self._context.clone(), + }; + + Ok(stream_inner) + } + + // Opens capture and playback from the same pcm_id with matching period/rate and returns the + // paired inner state used to drive both from one worker thread. Linking happens later, in + // begin_duplex_playback(). + fn build_duplex_stream_inner( + &self, + config: DuplexStreamConfig, + input_sample_format: SampleFormat, + output_sample_format: SampleFormat, + ) -> Result { + let capture_config = StreamConfig { + channels: config.input_channels, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + let playback_config = StreamConfig { + channels: config.output_channels, + sample_rate: config.sample_rate, + buffer_size: config.buffer_size, + }; + crate::validate_stream_config(&capture_config)?; + crate::validate_stream_config(&playback_config)?; + + let capture_handle = open_pcm(&self.pcm_id, alsa::Direction::Capture)?; + let playback_handle = open_pcm(&self.pcm_id, alsa::Direction::Playback)?; + + let capture_hw_params = + set_hw_params_from_format(&capture_handle, capture_config, input_sample_format)?; + let playback_hw_params = + set_hw_params_from_format(&playback_handle, playback_config, output_sample_format)?; + + let (capture_buffer_size, capture_period_size) = + set_sw_params_from_format(&capture_handle, StartThreshold::Disabled)?; + let (playback_buffer_size, playback_period_size) = + set_sw_params_from_format(&playback_handle, StartThreshold::Disabled)?; + if capture_buffer_size == 0 + || capture_period_size == 0 + || playback_buffer_size == 0 + || playback_period_size == 0 + { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + // Duplex drives both directions from one worker cycle; period sizes must match. + if capture_period_size != playback_period_size { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "capture and playback negotiated different period sizes ({capture_period_size} vs {playback_period_size} frames)" + ), + )); + } + + capture_handle.prepare()?; + playback_handle.prepare()?; + + if capture_handle.count() == 0 || playback_handle.count() == 0 { + return Err(ErrorKind::DeviceNotAvailable.into()); + } + + let capture_creation_ts = capture_handle.status()?.get_htstamp(); + let capture_timestamp_mode = timestamp_mode_for(&capture_hw_params, capture_creation_ts); + drop(capture_hw_params); + let playback_creation_ts = playback_handle.status()?.get_htstamp(); + let playback_timestamp_mode = timestamp_mode_for(&playback_hw_params, playback_creation_ts); + drop(playback_hw_params); + + let period_size = capture_period_size as usize; + let capture_frame_size = input_sample_format.sample_size() * config.input_channels as usize; + let playback_frame_size = + output_sample_format.sample_size() * config.output_channels as usize; + + let stream_inner = DuplexStreamInner { + control: WorkerControl::default(), + capture: DuplexCaptureState { + handle: capture_handle, + sample_format: input_sample_format, + frame_size: capture_frame_size, + period_samples: period_size * config.input_channels as usize, + timestamp_mode: capture_timestamp_mode, + creation_ts: capture_creation_ts, + }, + playback: DuplexPlaybackState { + handle: playback_handle, + sample_format: output_sample_format, + frame_size: playback_frame_size, + period_samples: period_size * config.output_channels as usize, + timestamp_mode: playback_timestamp_mode, + creation_ts: playback_creation_ts, + equilibrium: EquilibriumFill::new( + output_sample_format, + period_size * playback_frame_size, + ), + }, + sample_rate: config.sample_rate, + period_size, + linked: AtomicBool::new(false), + creation_instant: std::time::Instant::now(), + pending_xrun: AtomicBool::new(false), + _context: self._context.clone(), + }; + + Ok(stream_inner) + } + + fn description(&self) -> Result { + let name = self + .desc + .as_ref() + .and_then(|desc| desc.lines().next()) + .unwrap_or(self.pcm_id.as_str()); + + let mut builder = DeviceDescriptionBuilder::new(name) + .driver(self.pcm_id.as_str()) + .direction(self.direction); + + if let Some(ref desc) = self.desc { + builder = builder.extended(desc.lines().map(|l| l.trim()).filter(|l| !l.is_empty())); + } + + Ok(builder.build()) + } + + fn id(&self) -> Result { + Ok(DeviceId::new(crate::platform::HostId::Alsa, &self.pcm_id)) + } + + fn supported_configs( + &self, + stream_t: alsa::Direction, + ) -> Result, Error> { + let pcm = open_pcm(&self.pcm_id, stream_t)?; + + let hw_params = alsa::pcm::HwParams::any(&pcm)?; + + // Test both LE and BE formats to detect what the hardware actually supports. + // LE is listed first as it's the common case for most audio hardware. + // Hardware reports its supported formats regardless of CPU endianness. + const FORMATS: [(SampleFormat, alsa::pcm::Format); 23] = [ + (SampleFormat::I8, alsa::pcm::Format::S8), + (SampleFormat::U8, alsa::pcm::Format::U8), + (SampleFormat::I16, alsa::pcm::Format::S16LE), + (SampleFormat::I16, alsa::pcm::Format::S16BE), + (SampleFormat::U16, alsa::pcm::Format::U16LE), + (SampleFormat::U16, alsa::pcm::Format::U16BE), + (SampleFormat::I24, alsa::pcm::Format::S24LE), + (SampleFormat::I24, alsa::pcm::Format::S24BE), + (SampleFormat::U24, alsa::pcm::Format::U24LE), + (SampleFormat::U24, alsa::pcm::Format::U24BE), + (SampleFormat::I32, alsa::pcm::Format::S32LE), + (SampleFormat::I32, alsa::pcm::Format::S32BE), + (SampleFormat::U32, alsa::pcm::Format::U32LE), + (SampleFormat::U32, alsa::pcm::Format::U32BE), + (SampleFormat::F32, alsa::pcm::Format::FloatLE), + (SampleFormat::F32, alsa::pcm::Format::FloatBE), + (SampleFormat::F64, alsa::pcm::Format::Float64LE), + (SampleFormat::F64, alsa::pcm::Format::Float64BE), + (SampleFormat::DsdU8, alsa::pcm::Format::DSDU8), + (SampleFormat::DsdU16, alsa::pcm::Format::DSDU16LE), + (SampleFormat::DsdU16, alsa::pcm::Format::DSDU16BE), + (SampleFormat::DsdU32, alsa::pcm::Format::DSDU32LE), + (SampleFormat::DsdU32, alsa::pcm::Format::DSDU32BE), + //SND_PCM_FORMAT_IEC958_SUBFRAME_LE, + //SND_PCM_FORMAT_IEC958_SUBFRAME_BE, + //SND_PCM_FORMAT_MU_LAW, + //SND_PCM_FORMAT_A_LAW, + //SND_PCM_FORMAT_IMA_ADPCM, + //SND_PCM_FORMAT_MPEG, + //SND_PCM_FORMAT_GSM, + //SND_PCM_FORMAT_SPECIAL, + //SND_PCM_FORMAT_S24_3LE, + //SND_PCM_FORMAT_S24_3BE, + //SND_PCM_FORMAT_U24_3LE, + //SND_PCM_FORMAT_U24_3BE, + //SND_PCM_FORMAT_S20_3LE, + //SND_PCM_FORMAT_S20_3BE, + //SND_PCM_FORMAT_U20_3LE, + //SND_PCM_FORMAT_U20_3BE, + //SND_PCM_FORMAT_S18_3LE, + //SND_PCM_FORMAT_S18_3BE, + //SND_PCM_FORMAT_U18_3LE, + //SND_PCM_FORMAT_U18_3BE, + ]; + + let min_rate = hw_params.get_rate_min()?; + let max_rate = hw_params.get_rate_max()?; + + let sample_rates = if min_rate == max_rate || hw_params.test_rate(min_rate + 1).is_ok() { + // Fixed rate or continuous range. + vec![(min_rate, max_rate)] + } else { + // Discrete rates: probe the standard list plus the hardware's own min and max so + // that rates outside `COMMON_SAMPLE_RATES` are not missed. + let mut probe: Vec = COMMON_SAMPLE_RATES.to_vec(); + probe.push(min_rate); + probe.push(max_rate); + probe.sort_unstable(); + probe.dedup(); + probe + .into_iter() + .filter(|&r| (min_rate..=max_rate).contains(&r) && hw_params.test_rate(r).is_ok()) + .map(|r| (r, r)) + .collect() + }; + + let min_channels = hw_params.get_channels_min()?; + // 64 = AES10 (MADI) maximum; also prevents spinning on plugins like plughw that report u32::MAX. + const CHANNEL_ENUM_CAP: u32 = 64; + let max_channels = hw_params + .get_channels_max()? + .min(CHANNEL_ENUM_CAP) + .min(ChannelCount::MAX as u32); + + let supported_channels: Vec = + if min_channels == max_channels || hw_params.test_channels(min_channels + 1).is_ok() { + (min_channels..=max_channels) + .map(|c| c as ChannelCount) + .collect() + } else { + (min_channels..=max_channels) + .filter(|&c| hw_params.test_channels(c).is_ok()) + .map(|c| c as ChannelCount) + .collect() + }; + + let mut output = + Vec::with_capacity(FORMATS.len() * supported_channels.len() * sample_rates.len()); + let mut seen_formats: Vec = Vec::with_capacity(FORMATS.len()); + + // Key: (channels, physical width in bits) with 4 physical widths (8/16/32/64 bits) + let mut buffer_size_cache: HashMap<(ChannelCount, u32), SupportedBufferSize> = + HashMap::with_capacity(supported_channels.len() * 4); + + for &(sample_format, alsa_format) in FORMATS.iter() { + if seen_formats.contains(&sample_format) || hw_params.test_format(alsa_format).is_err() + { + continue; + } + seen_formats.push(sample_format); + let width = alsa_format.physical_width().unwrap_or(0) as u32; + + for &channels in &supported_channels { + let buffer_size = + *buffer_size_cache + .entry((channels, width)) + .or_insert_with(|| { + supported_period_size_range(&hw_params, alsa_format, channels) + }); + + for &(min_rate, max_rate) in sample_rates.iter() { + output.push(SupportedStreamConfigRange { + channels, + min_sample_rate: min_rate, + max_sample_rate: max_rate, + buffer_size, + sample_format, + }); + } + } + } + + Ok(output.into_iter()) + } + + fn supported_input_configs(&self) -> Result { + self.supported_configs(alsa::Direction::Capture) + } + + fn supported_output_configs(&self) -> Result { + self.supported_configs(alsa::Direction::Playback) + } + + // ALSA does not offer default stream formats, so instead we compare all supported formats by + // the `SupportedStreamConfigRange::cmp_default_heuristics` order and select the greatest. + fn default_config(&self, stream_t: alsa::Direction) -> Result { + let mut formats: Vec<_> = self.supported_configs(stream_t)?.collect(); + + formats.sort_by(|a, b| a.cmp_default_heuristics(b)); + + match formats.into_iter().next_back() { + Some(f) => Ok(f + .try_with_standard_sample_rate() + .unwrap_or_else(|| f.with_max_sample_rate())), + None => Err(Error::with_message( + ErrorKind::UnsupportedConfig, + "No supported configuration", + )), + } + } + + fn default_input_config(&self) -> Result { + self.default_config(alsa::Direction::Capture) + } + + fn default_output_config(&self) -> Result { + self.default_config(alsa::Direction::Playback) + } +} + +impl PartialEq for Device { + fn eq(&self, other: &Self) -> bool { + self.pcm_id == other.pcm_id + } +} + +impl Eq for Device {} + +impl fmt::Display for Device { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let desc = self.description().map_err(|_| fmt::Error)?; + f.write_str(desc.name()) + } +} + +impl std::hash::Hash for Device { + fn hash(&self, state: &mut H) { + self.pcm_id.hash(state); + } +} diff --git a/src/host/alsa/duplex.rs b/src/host/alsa/duplex.rs new file mode 100644 index 000000000..2e80d5abb --- /dev/null +++ b/src/host/alsa/duplex.rs @@ -0,0 +1,455 @@ +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use super::{ + DEFAULT_PERIODS, POLL_INFINITE, alsa, + alsa::poll::Descriptors, + stream::DuplexStreamInner, + timestamp::{callback_instant_for, status_with_timestamp}, + trigger::TriggerReceiver, +}; +use crate::{ + CallbackInfo, Data, DuplexCallbackInfo, Error, ErrorKind, FrameCount, StreamInstant, + StreamTimestamp, host::frames_to_duration, +}; + +pub(super) fn start_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { + match stream.capture.handle.state() { + alsa::pcm::State::Paused => { + let resumed = stream + .capture + .handle + .pause(false) + .and_then(|_| stream.playback.handle.pause(false)); + // Mirrors pause_duplex's fallback: resuming a linked pair via PAUSE_RELEASE can be + // as unreliable as pausing it was, on the same drivers. + if resumed.is_err() { + stream.capture.handle.drop().ok(); + stream.playback.handle.drop().ok(); + stream.capture.handle.prepare()?; + stream.playback.handle.prepare()?; + begin_duplex_playback(stream)?; + } + } + // Guard against Setup in case prepare() in stop() failed silently. + alsa::pcm::State::Prepared | alsa::pcm::State::Setup => { + if stream.capture.handle.state() == alsa::pcm::State::Setup { + stream.capture.handle.prepare()?; + } + if stream.playback.handle.state() == alsa::pcm::State::Setup { + stream.playback.handle.prepare()?; + } + begin_duplex_playback(stream)?; + } + _ => {} + } + Ok(()) +} + +pub(super) fn duplex_stream_worker( + rx: Arc, + stream: &DuplexStreamInner, + data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(Error) + Send + 'static), + timeout: Option, +) { + #[cfg(feature = "realtime")] + if stream.is_rt_eligible() { + let period_frames = u32::try_from(stream.period_size).unwrap_or(0); + if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( + period_frames, + stream.sample_rate, + ) { + error_callback(err.into()); + } + } + + let mut ctxt = DuplexStreamWorkerContext::new(&timeout, stream, &rx); + loop { + if stream.control.dropping.load(Ordering::Relaxed) { + return; + } + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); + } + let result = match poll_for_duplex_period(&rx, stream, &mut ctxt) { + Ok(DuplexPoll::Pending) => continue, + Ok(DuplexPoll::Recover) => recover_duplex(stream), + Ok(DuplexPoll::Ready { + capture_status, + playback_status, + capture_delay, + playback_delay, + }) => process_duplex( + stream, + &mut ctxt.capture_buffer, + &mut ctxt.playback_buffer, + capture_status, + playback_status, + capture_delay, + playback_delay, + data_callback, + ), + Err(err) => Err(err), + }; + if let Err(err) = result { + match err.kind() { + ErrorKind::DeviceNotAvailable => { + error_callback(err); + stream.control.signal_worker_exit(); + return; + } + _ => error_callback(err), + } + } + } +} + +// Prefills playback with silence, links the pair if not already linked, and starts capture +// (which starts playback too via kernel link-group propagation) or starts both explicitly if +// unlinked. Call only when both PCMs are Prepared. +// +// snd_pcm_link() only synchronizes PCMs sharing one card's hardware trigger, so it can fail +// (e.g. an `asym` PCM spanning two cards) while both PCMs still open and run fine independently. +// cpal can't verify hardware clock sharing either way, so a failed link doesn't refuse the +// stream: it proceeds unlinked instead of gating on a signal it can't fully trust. +fn begin_duplex_playback(stream: &DuplexStreamInner) -> Result<(), Error> { + let mut silence = vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); + stream.playback.equilibrium.fill(&mut silence); + for _ in 0..DEFAULT_PERIODS { + let mut frames_written = 0; + while frames_written < stream.period_size { + let n = stream + .playback + .handle + .io_bytes() + .writei(&silence[frames_written * stream.playback.frame_size..])?; + frames_written += n; + } + } + + if !stream.linked.load(Ordering::Relaxed) + && stream.capture.handle.link(&stream.playback.handle).is_ok() + { + stream.linked.store(true, Ordering::Relaxed); + } + + stream.capture.handle.start()?; + if !stream.linked.load(Ordering::Relaxed) { + stream.playback.handle.start()?; + } + Ok(()) +} + +// On xrun, drop() and prepare() both PCMs. When linked this is one call each: DROP and PREPARE +// propagate through the kernel link group the same way START does (snd_pcm_action_group() +// applies to every substream in the group, not just the one the ioctl was issued on). +fn recover_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { + stream.pending_xrun.store(true, Ordering::Relaxed); + if stream.linked.load(Ordering::Relaxed) { + stream.capture.handle.drop()?; + stream.capture.handle.prepare()?; + } else { + stream.capture.handle.drop().ok(); + stream.playback.handle.drop().ok(); + stream.capture.handle.prepare()?; + stream.playback.handle.prepare()?; + } + begin_duplex_playback(stream) +} + +struct DuplexStreamWorkerContext { + descriptors: Box<[libc::pollfd]>, + capture_range: std::ops::Range, + playback_range: std::ops::Range, + capture_buffer: Box<[u8]>, + playback_buffer: Box<[u8]>, + poll_timeout: i32, +} + +impl DuplexStreamWorkerContext { + fn new( + poll_timeout: &Option, + stream: &DuplexStreamInner, + rx: &TriggerReceiver, + ) -> Self { + let poll_timeout: i32 = if let Some(d) = poll_timeout { + d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 + } else { + POLL_INFINITE + }; + + let capture_buffer = + vec![0u8; stream.period_size * stream.capture.frame_size].into_boxed_slice(); + let playback_buffer = + vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); + + let capture_count = stream.capture.handle.count(); + let playback_count = stream.playback.handle.count(); + let mut descriptors = vec![ + libc::pollfd { + fd: 0, + events: 0, + revents: 0 + }; + 1 + capture_count + playback_count + ] + .into_boxed_slice(); + + descriptors[0] = libc::pollfd { + fd: rx.0, + events: libc::POLLIN, + revents: 0, + }; + + let capture_range = 1..(1 + capture_count); + let playback_range = capture_range.end..(capture_range.end + playback_count); + + let filled = stream + .capture + .handle + .fill(&mut descriptors[capture_range.clone()]) + .expect("Failed to fill ALSA capture descriptors"); + debug_assert_eq!(filled, capture_count); + let filled = stream + .playback + .handle + .fill(&mut descriptors[playback_range.clone()]) + .expect("Failed to fill ALSA playback descriptors"); + debug_assert_eq!(filled, playback_count); + + Self { + descriptors, + capture_range, + playback_range, + capture_buffer, + playback_buffer, + poll_timeout, + } + } +} + +#[expect(clippy::large_enum_variant)] +enum DuplexPoll { + Pending, + Ready { + capture_status: alsa::pcm::Status, + playback_status: alsa::pcm::Status, + capture_delay: usize, + playback_delay: usize, + }, + Recover, +} + +// Neither direction is processed until both have a full period ready, keeping capture and +// playback in lockstep when snd_pcm_link() couldn't tie them together (virtual PCMs like +// default or pulse have no shared substream to link, so this is common on non-hw devices). +// Suspend goes straight to full recovery instead of a soft hardware resume, since duplex +// would need to keep that resume path in sync across two handles. +fn poll_for_duplex_period( + rx: &TriggerReceiver, + stream: &DuplexStreamInner, + ctxt: &mut DuplexStreamWorkerContext, +) -> Result { + let res = alsa::poll::poll(&mut ctxt.descriptors, ctxt.poll_timeout)?; + if res == 0 { + for handle in [&stream.capture.handle, &stream.playback.handle] { + match handle.state() { + alsa::pcm::State::Disconnected => { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + alsa::pcm::State::XRun | alsa::pcm::State::Suspended => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + _ => {} + } + } + return Ok(DuplexPoll::Pending); + } + + if ctxt.descriptors[0].revents != 0 { + rx.clear_pipe(); + return Ok(DuplexPoll::Pending); + } + + let capture_revents = stream + .capture + .handle + .revents(&ctxt.descriptors[ctxt.capture_range.clone()])?; + let playback_revents = stream + .playback + .handle + .revents(&ctxt.descriptors[ctxt.playback_range.clone()])?; + if capture_revents.is_empty() && playback_revents.is_empty() { + return Ok(DuplexPoll::Pending); + } + if capture_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) + || playback_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) + { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + + let (capture_avail, capture_delay) = match stream.capture.handle.avail_delay() { + Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + Err(err) if err.errno() == libc::EPIPE => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + res => res, + }?; + let (playback_avail, playback_delay) = match stream.playback.handle.avail_delay() { + Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + Err(err) if err.errno() == libc::EPIPE => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(DuplexPoll::Recover); + } + res => res, + }?; + if capture_avail < stream.period_size as alsa::pcm::Frames + || playback_avail < stream.period_size as alsa::pcm::Frames + { + return Ok(DuplexPoll::Pending); + } + + let capture_status = + status_with_timestamp(&stream.capture.handle, stream.capture.timestamp_mode)?; + let playback_status = + status_with_timestamp(&stream.playback.handle, stream.playback.timestamp_mode)?; + + Ok(DuplexPoll::Ready { + capture_status, + playback_status, + capture_delay: capture_delay.max(0) as usize, + playback_delay: playback_delay.max(0) as usize, + }) +} + +#[expect(clippy::too_many_arguments)] +fn process_duplex( + stream: &DuplexStreamInner, + capture_buffer: &mut [u8], + playback_buffer: &mut [u8], + capture_status: alsa::pcm::Status, + playback_status: alsa::pcm::Status, + capture_delay: usize, + playback_delay: usize, + data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), +) -> Result<(), Error> { + let mut frames_read = 0; + while frames_read < stream.period_size { + match stream + .capture + .handle + .io_bytes() + .readi(&mut capture_buffer[frames_read * stream.capture.frame_size..]) + { + Ok(n) => frames_read += n, + Err(err) if err.errno() == libc::EAGAIN && frames_read == 0 => return Ok(()), + Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { + return recover_duplex(stream); + } + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_duplex(stream); + } + Err(err) => return Err(err.into()), + } + } + + stream.playback.equilibrium.fill(playback_buffer); + + if !stream.control.draining.load(Ordering::Relaxed) { + let input_ptr = capture_buffer.as_ptr() as *mut (); + let input_data = unsafe { + Data::from_parts( + input_ptr, + stream.capture.period_samples, + stream.capture.sample_format, + ) + }; + let output_ptr = playback_buffer.as_mut_ptr() as *mut (); + let mut output_data = unsafe { + Data::from_parts( + output_ptr, + stream.playback.period_samples, + stream.playback.sample_format, + ) + }; + + let capture_instant = callback_instant_for( + stream.capture.timestamp_mode, + stream.capture.creation_ts, + stream.creation_instant, + &capture_status, + ); + let capture_delay_duration = + frames_to_duration(capture_delay as FrameCount, stream.sample_rate); + let capture_device = capture_instant + .checked_sub(capture_delay_duration) + .unwrap_or(StreamInstant::ZERO); + + let playback_instant = callback_instant_for( + stream.playback.timestamp_mode, + stream.playback.creation_ts, + stream.creation_instant, + &playback_status, + ); + let playback_delay_duration = + frames_to_duration(playback_delay as FrameCount, stream.sample_rate); + let playback_device = playback_instant + playback_delay_duration; + + let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); + let info = DuplexCallbackInfo::new( + CallbackInfo { + timestamp: StreamTimestamp { + callback: capture_instant, + device: capture_device, + }, + xrun, + }, + CallbackInfo { + timestamp: StreamTimestamp { + callback: playback_instant, + device: playback_device, + }, + xrun, + }, + ); + data_callback(&input_data, &mut output_data, &info); + } + + let mut frames_written = 0; + while frames_written < stream.period_size { + match stream + .playback + .handle + .io_bytes() + .writei(&playback_buffer[frames_written * stream.playback.frame_size..]) + { + Ok(n) => frames_written += n, + Err(err) if err.errno() == libc::EAGAIN && frames_written == 0 => return Ok(()), + Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { + return recover_duplex(stream); + } + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_duplex(stream); + } + Err(err) => return Err(err.into()), + } + } + Ok(()) +} diff --git a/src/host/alsa/hw_params.rs b/src/host/alsa/hw_params.rs new file mode 100644 index 000000000..22a13be17 --- /dev/null +++ b/src/host/alsa/hw_params.rs @@ -0,0 +1,257 @@ +use super::{DEFAULT_PERIODS, alsa}; +use crate::{ + BufferSize, ChannelCount, Error, ErrorKind, FrameCount, SampleFormat, StreamConfig, + SupportedBufferSize, +}; + +pub(super) fn supported_period_size_range( + hw_params: &alsa::pcm::HwParams<'_>, + alsa_format: alsa::pcm::Format, + channels: ChannelCount, +) -> SupportedBufferSize { + let p = hw_params.clone(); + if p.set_access(alsa::pcm::Access::RWInterleaved).is_err() + || p.set_channels(channels as u32).is_err() + || p.set_format(alsa_format).is_err() + { + return SupportedBufferSize::Unknown; + } + let Some((min, max)) = hw_params_period_size_min_max(&p) else { + return SupportedBufferSize::Unknown; + }; + let min_frames = min.max(1); + // cpal double-buffers (ring = DEFAULT_PERIODS * period), so the achievable + // period maximum is also bounded by max_buffer / DEFAULT_PERIODS. + let effective_max = match p.get_buffer_size_max() { + Ok(max_buf) if max_buf > 0 => max.min(max_buf / DEFAULT_PERIODS), + _ => max, + }; + if effective_max >= min_frames { + let Ok(min) = min_frames.try_into() else { + return SupportedBufferSize::Unknown; + }; + SupportedBufferSize::Range { + min, + max: effective_max.try_into().unwrap_or(FrameCount::MAX), + } + } else { + SupportedBufferSize::Unknown + } +} + +pub(super) fn set_hw_params_from_format( + pcm_handle: &alsa::pcm::PCM, + config: StreamConfig, + sample_format: SampleFormat, +) -> Result, Error> { + let hw_params = init_hw_params(pcm_handle, config, sample_format)?; + + // When BufferSize::Fixed(x) is specified, we configure double-buffering with + // buffer_size = 2x and period_size = x. This provides consistent low-latency + // behavior across different ALSA implementations and hardware. + if let BufferSize::Fixed(period_size) = config.buffer_size { + let period_size = period_size as alsa::pcm::Frames; + + // Validate the requested size against the device's supported ranges using the same PCM + // handle we'll use for streaming. This avoids a second PCM open (which can disturb + // hardware clock state on some drivers) while still catching wildly out-of-range + // requests before set_period_size_near silently rounds them. + if let Some((min_period, max_period)) = hw_params_period_size_min_max(&hw_params) { + if !(min_period..=max_period).contains(&period_size) { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {period_size} is not in the supported range {min_period}..={max_period}" + ), + )); + } + } + + let buffer_size = DEFAULT_PERIODS * period_size; + if let Ok(max_buffer) = hw_params.get_buffer_size_max() { + if max_buffer > 0 && buffer_size > max_buffer { + let effective_max = max_buffer / DEFAULT_PERIODS; + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!( + "Buffer size {period_size} exceeds the maximum supported value of {effective_max}" + ), + )); + } + } + + hw_params.set_buffer_size_near(buffer_size)?; + hw_params.set_period_size_near(period_size, alsa::ValueOr::Nearest)?; + } + + // Apply hardware parameters + pcm_handle.hw_params(&hw_params)?; + + // For BufferSize::Default, constrain to device's configured period with 2-period buffering. + // PipeWire-ALSA picks a good period size but pairs it with many periods (huge buffer). + // We need to re-initialize hw_params and set BOTH period and buffer to constrain properly. + if config.buffer_size == BufferSize::Default { + if let Ok(period_size) = hw_params.get_period_size() { + // Re-initialize hw_params to clear previous constraints + let hw_params = init_hw_params(pcm_handle, config, sample_format)?; + + // Set both period (to device's chosen value) and buffer (to 2 periods) + hw_params.set_period_size_near(period_size, alsa::ValueOr::Nearest)?; + hw_params.set_buffer_size_near(DEFAULT_PERIODS * period_size)?; + + // Re-apply with new constraints + pcm_handle.hw_params(&hw_params)?; + } + } + + pcm_handle.hw_params_current().map_err(Into::into) +} + +// What triggers ALSA's automatic Prepared -> Running transition. +pub(super) enum StartThreshold { + // Capture: any read request satisfies this trivially - effectively immediate. + Immediate, + // Playback: starts once this many periods are queued, regardless of total buffer depth. + Periods(usize), + // Never automatically; the caller starts the PCM explicitly. + Disabled, +} + +pub(super) fn set_sw_params_from_format( + pcm_handle: &alsa::pcm::PCM, + start_threshold: StartThreshold, +) -> Result<(alsa::pcm::Frames, alsa::pcm::Frames), Error> { + let sw_params = pcm_handle.sw_params_current()?; + let (buffer_size, period_size) = pcm_handle + .get_params() + .map(|(b, p)| (b as alsa::pcm::Frames, p as alsa::pcm::Frames))?; + + let threshold = match start_threshold { + StartThreshold::Immediate => 1, + StartThreshold::Periods(periods) => periods as alsa::pcm::Frames * period_size, + // boundary is unreachable, so auto-start never fires. + StartThreshold::Disabled => sw_params.get_boundary()?, + }; + sw_params.set_start_threshold(threshold)?; + sw_params.set_avail_min(period_size)?; + + sw_params.set_tstamp_mode(true)?; + sw_params.set_tstamp_type(alsa::pcm::TstampType::MonotonicRaw)?; + + // tstamp_type param cannot be changed after the device is opened. + // The default tstamp_type value on most Linux systems is "monotonic", + // let's try to use it if setting the tstamp_type fails. + if pcm_handle.sw_params(&sw_params).is_err() { + sw_params.set_tstamp_type(alsa::pcm::TstampType::Monotonic)?; + pcm_handle.sw_params(&sw_params)?; + } + + Ok((buffer_size, period_size)) +} + +fn hw_params_period_size_min_max( + hw_params: &alsa::pcm::HwParams, +) -> Option<(alsa::pcm::Frames, alsa::pcm::Frames)> { + let min = hw_params.get_period_size_min().ok()?; + let max = hw_params.get_period_size_max().ok()?; + // min=0 means no hardware lower bound (PipeWire reports this on unconstrained params); + // it is handled in the caller by clamping to 1. max <= 0 is degenerate (or ULONG_MAX + // wrapping negative), so we return None in that case rather than a misleading range. + (max > 0 && max >= min).then_some((min, max)) +} + +fn init_hw_params<'a>( + pcm_handle: &'a alsa::pcm::PCM, + config: StreamConfig, + sample_format: SampleFormat, +) -> Result, Error> { + let hw_params = alsa::pcm::HwParams::any(pcm_handle)?; + hw_params.set_access(alsa::pcm::Access::RWInterleaved)?; + + // Determine which endianness the hardware actually supports for this format. + // We prefer native endian (no conversion needed) but fall back to the opposite + // endian if that's all the hardware supports (e.g., LE USB DAC on BE system). + let alsa_format = sample_format_to_alsa_format(&hw_params, sample_format)?; + hw_params.set_format(alsa_format)?; + + hw_params.set_rate(config.sample_rate, alsa::ValueOr::Nearest)?; + hw_params.set_channels(config.channels as u32)?; + Ok(hw_params) +} + +/// Convert SampleFormat to the appropriate alsa::pcm::Format based on what the hardware supports. +/// Prefers native endian, falls back to non-native if that's all the hardware supports. +fn sample_format_to_alsa_format( + hw_params: &alsa::pcm::HwParams, + sample_format: SampleFormat, +) -> Result { + use alsa::pcm::Format; + + // For each sample format, define (native_endian_format, opposite_endian_format) pairs + let (native, opposite) = match sample_format { + SampleFormat::I8 => return Ok(Format::S8), // No endianness + SampleFormat::U8 => return Ok(Format::U8), // No endianness + #[cfg(target_endian = "little")] + SampleFormat::I16 => (Format::S16LE, Format::S16BE), + #[cfg(target_endian = "big")] + SampleFormat::I16 => (Format::S16BE, Format::S16LE), + #[cfg(target_endian = "little")] + SampleFormat::U16 => (Format::U16LE, Format::U16BE), + #[cfg(target_endian = "big")] + SampleFormat::U16 => (Format::U16BE, Format::U16LE), + #[cfg(target_endian = "little")] + SampleFormat::I24 => (Format::S24LE, Format::S24BE), + #[cfg(target_endian = "big")] + SampleFormat::I24 => (Format::S24BE, Format::S24LE), + #[cfg(target_endian = "little")] + SampleFormat::U24 => (Format::U24LE, Format::U24BE), + #[cfg(target_endian = "big")] + SampleFormat::U24 => (Format::U24BE, Format::U24LE), + #[cfg(target_endian = "little")] + SampleFormat::I32 => (Format::S32LE, Format::S32BE), + #[cfg(target_endian = "big")] + SampleFormat::I32 => (Format::S32BE, Format::S32LE), + #[cfg(target_endian = "little")] + SampleFormat::U32 => (Format::U32LE, Format::U32BE), + #[cfg(target_endian = "big")] + SampleFormat::U32 => (Format::U32BE, Format::U32LE), + #[cfg(target_endian = "little")] + SampleFormat::F32 => (Format::FloatLE, Format::FloatBE), + #[cfg(target_endian = "big")] + SampleFormat::F32 => (Format::FloatBE, Format::FloatLE), + #[cfg(target_endian = "little")] + SampleFormat::F64 => (Format::Float64LE, Format::Float64BE), + #[cfg(target_endian = "big")] + SampleFormat::F64 => (Format::Float64BE, Format::Float64LE), + SampleFormat::DsdU8 => return Ok(Format::DSDU8), + #[cfg(target_endian = "little")] + SampleFormat::DsdU16 => (Format::DSDU16LE, Format::DSDU16BE), + #[cfg(target_endian = "big")] + SampleFormat::DsdU16 => (Format::DSDU16BE, Format::DSDU16LE), + #[cfg(target_endian = "little")] + SampleFormat::DsdU32 => (Format::DSDU32LE, Format::DSDU32BE), + #[cfg(target_endian = "big")] + SampleFormat::DsdU32 => (Format::DSDU32BE, Format::DSDU32LE), + _ => { + return Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!("Sample format {sample_format} is not supported"), + )); + } + }; + + // Try native endian first (optimal - no conversion needed) + if hw_params.test_format(native).is_ok() { + return Ok(native); + } + + // Fall back to opposite endian if hardware only supports that + if hw_params.test_format(opposite).is_ok() { + return Ok(opposite); + } + + Err(Error::with_message( + ErrorKind::UnsupportedConfig, + format!("Sample format {sample_format} is not supported in any byte order"), + )) +} diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 90cf2053c..9297bfee7 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -8,117 +8,26 @@ extern crate alsa_sys; extern crate libc; use std::{ - collections::HashMap, - fmt, mem, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - thread::{self, JoinHandle}, - time::{Duration, Instant}, - vec::IntoIter as VecIntoIter, + mem, + sync::{Arc, Mutex}, }; -use self::alsa::poll::Descriptors; +pub use self::device::Device; pub use self::enumerate::Devices; +pub use self::stream::Stream; use crate::{ - BufferSize, COMMON_SAMPLE_RATES, CallbackInfo, ChannelCount, Data, DeviceDescription, - DeviceDescriptionBuilder, DeviceDirection, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, - Error, ErrorKind, FrameCount, SampleFormat, SampleRate, StreamConfig, StreamInstant, - StreamTimestamp, SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, - host::{ - Notify, - equilibrium::{DSD_EQUILIBRIUM_BYTE, U8_EQUILIBRIUM_BYTE, fill_equilibrium}, - frames_to_duration, - latch::Latch, - }, - iter::{SupportedInputConfigs, SupportedOutputConfigs}, - traits::{DeviceTrait, HostTrait, StreamTrait}, + DeviceDirection, DeviceId, Error, ErrorKind, + traits::{DeviceTrait, HostTrait}, }; +mod device; +mod duplex; mod enumerate; - -// ALSA Buffer Size Behavior -// ========================= -// -// ## ALSA Latency Model -// -// **Hardware vs Software Buffer**: ALSA maintains a software buffer in memory that feeds -// a hardware buffer in the audio device. Audio latency is determined by how much data -// sits in the software buffer before being transferred to hardware. -// -// **Period-Based Transfer**: ALSA transfers data in chunks called "periods". When one -// period worth of data has been consumed by hardware, ALSA triggers a callback to refill -// that period in the software buffer. -// -// ## BufferSize::Fixed Behavior -// -// When `BufferSize::Fixed(x)` is specified, cpal attempts to configure the period size -// to approximately `x` frames to achieve the requested callback size. However, the -// actual callback size may differ from the request: -// -// - ALSA may round the period size to hardware-supported values -// - Different devices have different period size constraints -// - The callback size is not guaranteed to exactly match the request -// - If the requested size cannot be accommodated, ALSA will choose the nearest -// supported configuration -// -// This mirrors the behavior documented in the cpal API where `BufferSize::Fixed(x)` -// requests but does not guarantee a specific callback size. -// -// ## BufferSize::Default Behavior -// -// When `BufferSize::Default` is specified, cpal does NOT set explicit period size or -// period count constraints, allowing the device/driver to choose sensible defaults. -// -// **Why not set defaults?** Different audio systems have different behaviors: -// -// - **Native ALSA hardware**: Typically chooses reasonable defaults (e.g., 512-2048 -// frame periods with 2-4 periods) -// -// - **PipeWire-ALSA plugin**: Allocates a large ring buffer (~1M frames at 48kHz) but -// uses small periods (512-1024 frames). Critically, if you request `set_periods(2)` -// without specifying period size, PipeWire calculates period = buffer/2, resulting -// in pathologically large periods (~524K frames = 10 seconds). See issues #1029 and -// #1036. -// -// By not constraining period configuration, PipeWire-ALSA can use its optimized defaults -// (small periods with many-period buffer), while native ALSA hardware uses its own defaults. -// -// **Startup latency**: Regardless of buffer size, cpal uses double-buffering for startup -// (start_threshold = 2 periods), ensuring low latency even with large multi-period ring -// buffers. - -const DEFAULT_DEVICE: &str = "default"; -const DEFAULT_PERIODS: alsa::pcm::Frames = 2; - -const POLL_INFINITE: i32 = -1; // "block until an event arrives" -const TRIGGER_PAYLOAD_SIZE: libc::ssize_t = mem::size_of::() as libc::ssize_t; - -// Some ALSA plugins (e.g. alsaequal, certain USB drivers) are not reentrant. -static ALSA_OPEN_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn open_pcm(pcm_id: &str, direction: alsa::Direction) -> Result { - let _guard = ALSA_OPEN_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); - alsa::pcm::PCM::new(pcm_id, direction, true).map_err(|e| { - let e = Error::from(e); - if e.kind() == ErrorKind::UnsupportedConfig { - let dir = match direction { - alsa::Direction::Capture => "input", - alsa::Direction::Playback => "output", - }; - Error::with_message( - ErrorKind::UnsupportedOperation, - format!("Device does not support {dir}"), - ) - } else { - e - } - }) -} - -// TODO: Not yet defined in rust-lang/libc crate -const LIBC_ENOTSUPP: libc::c_int = 524; +mod hw_params; +mod stream; +mod timestamp; +mod trigger; +mod worker; /// The default Linux and BSD host type. #[derive(Debug, Clone)] @@ -208,2586 +117,87 @@ impl Drop for AlsaContext { } } -impl DeviceTrait for Device { - type SupportedInputConfigs = SupportedInputConfigs; - type SupportedOutputConfigs = SupportedOutputConfigs; - type Stream = Stream; - - fn description(&self) -> Result { - Self::description(self) - } - - fn id(&self) -> Result { - Self::id(self) - } - - // Override trait defaults to avoid opening devices during enumeration. - // - // ALSA does not guarantee transactional cleanup on failed snd_pcm_open(). Opening plugins like - // alsaequal that fail with EPERM can leak FDs, poisoning the ALSA backend for the process - // lifetime (subsequent device opens fail with EBUSY until process exit). - fn supports_input(&self) -> bool { - matches!( - self.direction, - DeviceDirection::Input | DeviceDirection::Duplex - ) - } - - fn supports_output(&self) -> bool { - matches!( - self.direction, - DeviceDirection::Output | DeviceDirection::Duplex - ) - } - - fn supports_duplex(&self) -> bool { - self.direction == DeviceDirection::Duplex - } - - fn supported_input_configs(&self) -> Result { - Self::supported_input_configs(self) - } - - fn supported_output_configs(&self) -> Result { - Self::supported_output_configs(self) - } - - fn default_input_config(&self) -> Result { - Self::default_input_config(self) - } - - fn default_output_config(&self) -> Result { - Self::default_output_config(self) - } - - fn build_input_stream_raw( - &self, - conf: StreamConfig, - sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, - ) -> Result - where - D: FnMut(&Data, &CallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - // Keep `capture` monotonic: avail_delay() varies between cycles, and a capture overrun - // can make it jump up enough to pull `capture` backward. - let data_callback = crate::host::monotonic_input_callback(data_callback); - let stream_inner = - self.build_stream_inner(conf, sample_format, alsa::Direction::Capture)?; - let stream = Self::Stream::new_input( - Arc::new(stream_inner), - data_callback, - error_callback, - timeout, - ); - Ok(stream) - } - - fn build_output_stream_raw( - &self, - conf: StreamConfig, - sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, - ) -> Result - where - D: FnMut(&mut Data, &CallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - // Keep `playback` monotonic: avail_delay() varies between cycles, and a playback - // underrun can drain the buffer enough to pull `playback` backward. - let data_callback = crate::host::monotonic_output_callback(data_callback); - let stream_inner = - self.build_stream_inner(conf, sample_format, alsa::Direction::Playback)?; - let stream = Self::Stream::new_output( - Arc::new(stream_inner), - data_callback, - error_callback, - timeout, - ); - Ok(stream) - } - - fn build_duplex_stream_raw( - &self, - config: DuplexStreamConfig, - input_sample_format: SampleFormat, - output_sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, - ) -> Result - where - D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - let stream_inner = - self.build_duplex_stream_inner(config, input_sample_format, output_sample_format)?; - let stream = Self::Stream::new_duplex( - Arc::new(stream_inner), - data_callback, - error_callback, - timeout, - ); - Ok(stream) - } -} +// ALSA Buffer Size Behavior +// ========================= +// +// ## ALSA Latency Model +// +// **Hardware vs Software Buffer**: ALSA maintains a software buffer in memory that feeds +// a hardware buffer in the audio device. Audio latency is determined by how much data +// sits in the software buffer before being transferred to hardware. +// +// **Period-Based Transfer**: ALSA transfers data in chunks called "periods". When one +// period worth of data has been consumed by hardware, ALSA triggers a callback to refill +// that period in the software buffer. +// +// ## BufferSize::Fixed Behavior +// +// When `BufferSize::Fixed(x)` is specified, cpal attempts to configure the period size +// to approximately `x` frames to achieve the requested callback size. However, the +// actual callback size may differ from the request: +// +// - ALSA may round the period size to hardware-supported values +// - Different devices have different period size constraints +// - The callback size is not guaranteed to exactly match the request +// - If the requested size cannot be accommodated, ALSA will choose the nearest +// supported configuration +// +// This mirrors the behavior documented in the cpal API where `BufferSize::Fixed(x)` +// requests but does not guarantee a specific callback size. +// +// ## BufferSize::Default Behavior +// +// When `BufferSize::Default` is specified, cpal does NOT set explicit period size or +// period count constraints, allowing the device/driver to choose sensible defaults. +// +// **Why not set defaults?** Different audio systems have different behaviors: +// +// - **Native ALSA hardware**: Typically chooses reasonable defaults (e.g., 512-2048 +// frame periods with 2-4 periods) +// +// - **PipeWire-ALSA plugin**: Allocates a large ring buffer (~1M frames at 48kHz) but +// uses small periods (512-1024 frames). Critically, if you request `set_periods(2)` +// without specifying period size, PipeWire calculates period = buffer/2, resulting +// in pathologically large periods (~524K frames = 10 seconds). See issues #1029 and +// #1036. +// +// By not constraining period configuration, PipeWire-ALSA can use its optimized defaults +// (small periods with many-period buffer), while native ALSA hardware uses its own defaults. +// +// **Startup latency**: Regardless of buffer size, cpal uses double-buffering for startup +// (start_threshold = 2 periods), ensuring low latency even with large multi-period ring +// buffers. -#[derive(Debug)] -struct TriggerSender(libc::c_int); +const DEFAULT_DEVICE: &str = "default"; +const DEFAULT_PERIODS: alsa::pcm::Frames = 2; -#[derive(Debug)] -struct TriggerReceiver(libc::c_int); +const POLL_INFINITE: i32 = -1; // "block until an event arrives" +const TRIGGER_PAYLOAD_SIZE: libc::ssize_t = mem::size_of::() as libc::ssize_t; -impl TriggerSender { - fn wakeup(&self) { - let buf = !0u64; // any non-zero value wakes poll() - loop { - let ret = unsafe { - libc::write( - self.0, - &buf as *const u64 as *const _, - TRIGGER_PAYLOAD_SIZE as _, - ) - }; - if ret == TRIGGER_PAYLOAD_SIZE { - return; - } - // write() can be interrupted by a signal before writing any bytes; retry. - assert_eq!(ret, -1, "wakeup: unexpected return value {ret}"); - let err = std::io::Error::last_os_error(); - if err.kind() != std::io::ErrorKind::Interrupted { - panic!("wakeup: {err}"); - } - } - } -} +// Some ALSA plugins (e.g. alsaequal, certain USB drivers) are not reentrant. +static ALSA_OPEN_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); -impl TriggerReceiver { - fn clear_pipe(&self) { - let mut out = 0u64; - loop { - let ret = unsafe { - libc::read( - self.0, - &mut out as *mut u64 as *mut _, - TRIGGER_PAYLOAD_SIZE as _, - ) +fn open_pcm(pcm_id: &str, direction: alsa::Direction) -> Result { + let _guard = ALSA_OPEN_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + alsa::pcm::PCM::new(pcm_id, direction, true).map_err(|e| { + let e = Error::from(e); + if e.kind() == ErrorKind::UnsupportedConfig { + let dir = match direction { + alsa::Direction::Capture => "input", + alsa::Direction::Playback => "output", }; - if ret == TRIGGER_PAYLOAD_SIZE { - return; - } - // read() can be interrupted by a signal before reading any bytes; retry. - assert_eq!(ret, -1, "clear_pipe: unexpected return value {ret}"); - let err = std::io::Error::last_os_error(); - if err.kind() != std::io::ErrorKind::Interrupted { - panic!("clear_pipe: {err}"); - } - } - } -} - -fn trigger() -> (TriggerSender, Arc) { - let mut fds = [0, 0]; - match unsafe { libc::pipe(fds.as_mut_ptr()) } { - 0 => (TriggerSender(fds[1]), Arc::new(TriggerReceiver(fds[0]))), - _ => panic!("Could not create pipe"), - } -} - -impl Drop for TriggerSender { - fn drop(&mut self) { - unsafe { - libc::close(self.0); - } - } -} - -impl Drop for TriggerReceiver { - fn drop(&mut self) { - unsafe { - libc::close(self.0); + Error::with_message( + ErrorKind::UnsupportedOperation, + format!("Device does not support {dir}"), + ) + } else { + e } - } -} - -#[derive(Clone, Debug)] -pub struct Device { - pcm_id: String, - desc: Option, - direction: DeviceDirection, - _context: Arc, + }) } -impl Device { - fn build_stream_inner( - &self, - conf: StreamConfig, - sample_format: SampleFormat, - stream_type: alsa::Direction, - ) -> Result { - crate::validate_stream_config(&conf)?; - - let handle = open_pcm(&self.pcm_id, stream_type)?; - - let hw_params = set_hw_params_from_format(&handle, conf, sample_format)?; - let start_threshold = match stream_type { - alsa::Direction::Playback => StartThreshold::Periods(DEFAULT_PERIODS as usize), - alsa::Direction::Capture => StartThreshold::Immediate, - }; - let (buffer_size, period_size) = set_sw_params_from_format(&handle, start_threshold)?; - if buffer_size == 0 || period_size == 0 { - return Err(ErrorKind::DeviceNotAvailable.into()); - } - - handle.prepare()?; - - if handle.count() == 0 { - return Err(ErrorKind::DeviceNotAvailable.into()); - } - - // A zero get_htstamp() at prepare time indicates the device does not support hardware timestamps (e.g. PulseAudio ALSA plugin). - // Related: https://bugs.freedesktop.org/show_bug.cgi?id=88503 - let creation_ts = handle.status()?.get_htstamp(); - let timestamp_mode = timestamp_mode_for(&hw_params, creation_ts); - drop(hw_params); - - let period_size = period_size as usize; - let frame_size = sample_format.sample_size() * conf.channels as usize; - - let stream_inner = StreamInner { - control: WorkerControl::default(), - direction: stream_type.into(), - handle, - sample_format, - sample_rate: conf.sample_rate, - frame_size, - period_size, - period_samples: period_size * conf.channels as usize, - equilibrium: (stream_type == alsa::Direction::Playback) - .then(|| EquilibriumFill::new(sample_format, period_size * frame_size)), - timestamp_mode, - creation_ts, - creation_instant: std::time::Instant::now(), - pending_xrun: AtomicBool::new(false), - _context: self._context.clone(), - }; - - Ok(stream_inner) - } - - // Opens capture and playback from the same pcm_id with matching period/rate and returns the - // paired inner state used to drive both from one worker thread. Linking happens later, in - // begin_duplex_playback(). - fn build_duplex_stream_inner( - &self, - config: DuplexStreamConfig, - input_sample_format: SampleFormat, - output_sample_format: SampleFormat, - ) -> Result { - let capture_config = StreamConfig { - channels: config.input_channels, - sample_rate: config.sample_rate, - buffer_size: config.buffer_size, - }; - let playback_config = StreamConfig { - channels: config.output_channels, - sample_rate: config.sample_rate, - buffer_size: config.buffer_size, - }; - crate::validate_stream_config(&capture_config)?; - crate::validate_stream_config(&playback_config)?; - - let capture_handle = open_pcm(&self.pcm_id, alsa::Direction::Capture)?; - let playback_handle = open_pcm(&self.pcm_id, alsa::Direction::Playback)?; - - let capture_hw_params = - set_hw_params_from_format(&capture_handle, capture_config, input_sample_format)?; - let playback_hw_params = - set_hw_params_from_format(&playback_handle, playback_config, output_sample_format)?; - - let (capture_buffer_size, capture_period_size) = - set_sw_params_from_format(&capture_handle, StartThreshold::Disabled)?; - let (playback_buffer_size, playback_period_size) = - set_sw_params_from_format(&playback_handle, StartThreshold::Disabled)?; - if capture_buffer_size == 0 - || capture_period_size == 0 - || playback_buffer_size == 0 - || playback_period_size == 0 - { - return Err(ErrorKind::DeviceNotAvailable.into()); - } - // Duplex drives both directions from one worker cycle; period sizes must match. - if capture_period_size != playback_period_size { - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!( - "capture and playback negotiated different period sizes ({capture_period_size} vs {playback_period_size} frames)" - ), - )); - } - - capture_handle.prepare()?; - playback_handle.prepare()?; - - if capture_handle.count() == 0 || playback_handle.count() == 0 { - return Err(ErrorKind::DeviceNotAvailable.into()); - } - - let capture_creation_ts = capture_handle.status()?.get_htstamp(); - let capture_timestamp_mode = timestamp_mode_for(&capture_hw_params, capture_creation_ts); - drop(capture_hw_params); - let playback_creation_ts = playback_handle.status()?.get_htstamp(); - let playback_timestamp_mode = timestamp_mode_for(&playback_hw_params, playback_creation_ts); - drop(playback_hw_params); - - let period_size = capture_period_size as usize; - let capture_frame_size = input_sample_format.sample_size() * config.input_channels as usize; - let playback_frame_size = - output_sample_format.sample_size() * config.output_channels as usize; - - let stream_inner = DuplexStreamInner { - control: WorkerControl::default(), - capture: DuplexCaptureState { - handle: capture_handle, - sample_format: input_sample_format, - frame_size: capture_frame_size, - period_samples: period_size * config.input_channels as usize, - timestamp_mode: capture_timestamp_mode, - creation_ts: capture_creation_ts, - }, - playback: DuplexPlaybackState { - handle: playback_handle, - sample_format: output_sample_format, - frame_size: playback_frame_size, - period_samples: period_size * config.output_channels as usize, - timestamp_mode: playback_timestamp_mode, - creation_ts: playback_creation_ts, - equilibrium: EquilibriumFill::new( - output_sample_format, - period_size * playback_frame_size, - ), - }, - sample_rate: config.sample_rate, - period_size, - linked: AtomicBool::new(false), - creation_instant: std::time::Instant::now(), - pending_xrun: AtomicBool::new(false), - _context: self._context.clone(), - }; - - Ok(stream_inner) - } - - fn description(&self) -> Result { - let name = self - .desc - .as_ref() - .and_then(|desc| desc.lines().next()) - .unwrap_or(self.pcm_id.as_str()); - - let mut builder = DeviceDescriptionBuilder::new(name) - .driver(self.pcm_id.as_str()) - .direction(self.direction); - - if let Some(ref desc) = self.desc { - builder = builder.extended(desc.lines().map(|l| l.trim()).filter(|l| !l.is_empty())); - } - - Ok(builder.build()) - } - - fn id(&self) -> Result { - Ok(DeviceId::new(crate::platform::HostId::Alsa, &self.pcm_id)) - } - - fn supported_configs( - &self, - stream_t: alsa::Direction, - ) -> Result, Error> { - let pcm = open_pcm(&self.pcm_id, stream_t)?; - - let hw_params = alsa::pcm::HwParams::any(&pcm)?; - - // Test both LE and BE formats to detect what the hardware actually supports. - // LE is listed first as it's the common case for most audio hardware. - // Hardware reports its supported formats regardless of CPU endianness. - const FORMATS: [(SampleFormat, alsa::pcm::Format); 23] = [ - (SampleFormat::I8, alsa::pcm::Format::S8), - (SampleFormat::U8, alsa::pcm::Format::U8), - (SampleFormat::I16, alsa::pcm::Format::S16LE), - (SampleFormat::I16, alsa::pcm::Format::S16BE), - (SampleFormat::U16, alsa::pcm::Format::U16LE), - (SampleFormat::U16, alsa::pcm::Format::U16BE), - (SampleFormat::I24, alsa::pcm::Format::S24LE), - (SampleFormat::I24, alsa::pcm::Format::S24BE), - (SampleFormat::U24, alsa::pcm::Format::U24LE), - (SampleFormat::U24, alsa::pcm::Format::U24BE), - (SampleFormat::I32, alsa::pcm::Format::S32LE), - (SampleFormat::I32, alsa::pcm::Format::S32BE), - (SampleFormat::U32, alsa::pcm::Format::U32LE), - (SampleFormat::U32, alsa::pcm::Format::U32BE), - (SampleFormat::F32, alsa::pcm::Format::FloatLE), - (SampleFormat::F32, alsa::pcm::Format::FloatBE), - (SampleFormat::F64, alsa::pcm::Format::Float64LE), - (SampleFormat::F64, alsa::pcm::Format::Float64BE), - (SampleFormat::DsdU8, alsa::pcm::Format::DSDU8), - (SampleFormat::DsdU16, alsa::pcm::Format::DSDU16LE), - (SampleFormat::DsdU16, alsa::pcm::Format::DSDU16BE), - (SampleFormat::DsdU32, alsa::pcm::Format::DSDU32LE), - (SampleFormat::DsdU32, alsa::pcm::Format::DSDU32BE), - //SND_PCM_FORMAT_IEC958_SUBFRAME_LE, - //SND_PCM_FORMAT_IEC958_SUBFRAME_BE, - //SND_PCM_FORMAT_MU_LAW, - //SND_PCM_FORMAT_A_LAW, - //SND_PCM_FORMAT_IMA_ADPCM, - //SND_PCM_FORMAT_MPEG, - //SND_PCM_FORMAT_GSM, - //SND_PCM_FORMAT_SPECIAL, - //SND_PCM_FORMAT_S24_3LE, - //SND_PCM_FORMAT_S24_3BE, - //SND_PCM_FORMAT_U24_3LE, - //SND_PCM_FORMAT_U24_3BE, - //SND_PCM_FORMAT_S20_3LE, - //SND_PCM_FORMAT_S20_3BE, - //SND_PCM_FORMAT_U20_3LE, - //SND_PCM_FORMAT_U20_3BE, - //SND_PCM_FORMAT_S18_3LE, - //SND_PCM_FORMAT_S18_3BE, - //SND_PCM_FORMAT_U18_3LE, - //SND_PCM_FORMAT_U18_3BE, - ]; - - let min_rate = hw_params.get_rate_min()?; - let max_rate = hw_params.get_rate_max()?; - - let sample_rates = if min_rate == max_rate || hw_params.test_rate(min_rate + 1).is_ok() { - // Fixed rate or continuous range. - vec![(min_rate, max_rate)] - } else { - // Discrete rates: probe the standard list plus the hardware's own min and max so - // that rates outside `COMMON_SAMPLE_RATES` are not missed. - let mut probe: Vec = COMMON_SAMPLE_RATES.to_vec(); - probe.push(min_rate); - probe.push(max_rate); - probe.sort_unstable(); - probe.dedup(); - probe - .into_iter() - .filter(|&r| (min_rate..=max_rate).contains(&r) && hw_params.test_rate(r).is_ok()) - .map(|r| (r, r)) - .collect() - }; - - let min_channels = hw_params.get_channels_min()?; - // 64 = AES10 (MADI) maximum; also prevents spinning on plugins like plughw that report u32::MAX. - const CHANNEL_ENUM_CAP: u32 = 64; - let max_channels = hw_params - .get_channels_max()? - .min(CHANNEL_ENUM_CAP) - .min(ChannelCount::MAX as u32); - - let supported_channels: Vec = - if min_channels == max_channels || hw_params.test_channels(min_channels + 1).is_ok() { - (min_channels..=max_channels) - .map(|c| c as ChannelCount) - .collect() - } else { - (min_channels..=max_channels) - .filter(|&c| hw_params.test_channels(c).is_ok()) - .map(|c| c as ChannelCount) - .collect() - }; - - let mut output = - Vec::with_capacity(FORMATS.len() * supported_channels.len() * sample_rates.len()); - let mut seen_formats: Vec = Vec::with_capacity(FORMATS.len()); - - // Key: (channels, physical width in bits) with 4 physical widths (8/16/32/64 bits) - let mut buffer_size_cache: HashMap<(ChannelCount, u32), SupportedBufferSize> = - HashMap::with_capacity(supported_channels.len() * 4); - - for &(sample_format, alsa_format) in FORMATS.iter() { - if seen_formats.contains(&sample_format) || hw_params.test_format(alsa_format).is_err() - { - continue; - } - seen_formats.push(sample_format); - let width = alsa_format.physical_width().unwrap_or(0) as u32; - - for &channels in &supported_channels { - let buffer_size = - *buffer_size_cache - .entry((channels, width)) - .or_insert_with(|| { - supported_period_size_range(&hw_params, alsa_format, channels) - }); - - for &(min_rate, max_rate) in sample_rates.iter() { - output.push(SupportedStreamConfigRange { - channels, - min_sample_rate: min_rate, - max_sample_rate: max_rate, - buffer_size, - sample_format, - }); - } - } - } - - Ok(output.into_iter()) - } - - fn supported_input_configs(&self) -> Result { - self.supported_configs(alsa::Direction::Capture) - } - - fn supported_output_configs(&self) -> Result { - self.supported_configs(alsa::Direction::Playback) - } - - // ALSA does not offer default stream formats, so instead we compare all supported formats by - // the `SupportedStreamConfigRange::cmp_default_heuristics` order and select the greatest. - fn default_config(&self, stream_t: alsa::Direction) -> Result { - let mut formats: Vec<_> = self.supported_configs(stream_t)?.collect(); - - formats.sort_by(|a, b| a.cmp_default_heuristics(b)); - - match formats.into_iter().next_back() { - Some(f) => Ok(f - .try_with_standard_sample_rate() - .unwrap_or_else(|| f.with_max_sample_rate())), - None => Err(Error::with_message( - ErrorKind::UnsupportedConfig, - "No supported configuration", - )), - } - } - - fn default_input_config(&self) -> Result { - self.default_config(alsa::Direction::Capture) - } - - fn default_output_config(&self) -> Result { - self.default_config(alsa::Direction::Playback) - } -} - -impl PartialEq for Device { - fn eq(&self, other: &Self) -> bool { - self.pcm_id == other.pcm_id - } -} - -impl Eq for Device {} - -impl fmt::Display for Device { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let desc = self.description().map_err(|_| fmt::Error)?; - f.write_str(desc.name()) - } -} - -impl std::hash::Hash for Device { - fn hash(&self, state: &mut H) { - self.pcm_id.hash(state); - } -} - -/// Strategy for pre-filling an output buffer with the equilibrium value. -#[derive(Debug)] -enum EquilibriumFill { - /// Equilibrium is represented as a single repeating byte value. - Byte(u8), - /// A period-sized buffer pre-filled with the equilibrium value. - Template(Box<[u8]>), -} - -impl EquilibriumFill { - /// Compute the equilibrium-fill strategy for the given sample format at stream creation. - fn new(sample_format: SampleFormat, period_bytes: usize) -> Self { - if sample_format.is_int() || sample_format.is_float() { - Self::Byte(0) - } else if sample_format == SampleFormat::U8 { - Self::Byte(U8_EQUILIBRIUM_BYTE) - } else if sample_format.is_dsd() { - Self::Byte(DSD_EQUILIBRIUM_BYTE) - } else { - // Multi-byte unsigned integer formats require a fill equal to the midpoint of their - // range. - debug_assert!(sample_format.is_uint()); - let mut template = vec![0u8; period_bytes].into_boxed_slice(); - fill_equilibrium(&mut template, sample_format); - Self::Template(template) - } - } - - #[inline] - fn fill(&self, buffer: &mut [u8]) { - match self { - Self::Byte(b) => buffer.fill(*b), - Self::Template(t) => buffer.copy_from_slice(t), - } - } -} - -// A zero get_htstamp() at prepare time indicates the device does not support hardware -// timestamps (e.g. PulseAudio ALSA plugin). Related: -// https://bugs.freedesktop.org/show_bug.cgi?id=88503 -fn timestamp_mode_for( - hw_params: &alsa::pcm::HwParams<'_>, - creation_ts: alsa::timespec, -) -> TimestampMode { - if creation_ts.tv_sec == 0 && creation_ts.tv_nsec == 0 { - TimestampMode::CreationInstant - } else if hw_params.supports_audio_ts_type(alsa::pcm::AudioTstampType::LinkSynchronized) { - TimestampMode::AudioLink - } else { - TimestampMode::SystemClock - } -} - -// How callback timestamps are produced. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TimestampMode { - // Hardware timestamps are unavailable (e.g. PulseAudio ALSA plugin returns zero htstamp). - // Timestamps are monotonic elapsed time since stream creation, sourced from Instant::now(). - CreationInstant, - - // The kernel records the monotonic clock at each DMA interrupt in htstamp. - // Subtracting creation_ts (same clock, captured at prepare time) gives elapsed time - // since stream creation. Uses CLOCK_MONOTONIC_RAW when available, CLOCK_MONOTONIC otherwise. - SystemClock, - - // The hardware maps the audio sample counter to CLOCK_MONOTONIC_RAW via TSC - // cross-timestamps (LinkSynchronized), giving a timestamp that tracks the actual audio - // clock rather than DMA interrupt delivery time. Higher fidelity than SystemClock. - AudioLink, -} - -// Park/drop plumbing shared by StreamInner and DuplexStreamInner, giving StreamTrait -// exclusive worker access for pause/stop/drain regardless of handle count. -#[derive(Debug, Default)] -struct WorkerControl { - // Set when the worker should stop polling, e.g. after a device disconnect. - dropping: AtomicBool, - - // Whether the user callback is currently suppressed. - draining: AtomicBool, - - // Set by stop() to request the worker pause for exclusive PCM access during drain. - parked: AtomicBool, - park: Notify, -} - -impl WorkerControl { - // Pauses the worker at its next loop iteration and waits for acknowledgment, or returns - // early if it already exited. Caller holds exclusive PCM access until unpark_worker(). - fn park_worker(&self) { - self.parked.store(true, Ordering::Relaxed); - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - // Exit if the worker acknowledged the park OR if the worker has exited (dropping=true). - while !*guard && !self.dropping.load(Ordering::Relaxed) { - guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); - } - } - - // Acknowledges a pending park, then sleeps until unpark_worker() is called. - fn acknowledge_park(&self) { - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - *guard = true; - cvar.notify_one(); - while self.parked.load(Ordering::Relaxed) { - guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); - } - *guard = false; - } - - // Marks the stream dead and wakes any thread blocked in park_worker(), so an exit - // other than a normal drop doesn't hang it. - fn signal_worker_exit(&self) { - self.dropping.store(true, Ordering::Relaxed); - let (lock, cvar) = &self.park; - let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - cvar.notify_one(); - } - - // Releases the park: clears parked and wakes the worker from acknowledge_park(). - fn unpark_worker(&self) { - let (lock, cvar) = &self.park; - let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - *guard = false; - self.parked.store(false, Ordering::Relaxed); - drop(guard); - cvar.notify_one(); - } -} - -#[derive(Debug)] -struct StreamInner { - // Controls the worker thread's lifecycle and pause/drain state. - control: WorkerControl, - - // Stream direction. - direction: DeviceDirection, - - // The ALSA handle. - handle: alsa::pcm::PCM, - - // Format of the samples. - sample_format: SampleFormat, - - // Sample rate of the stream. - sample_rate: SampleRate, - - // Cached values for performance in audio callback hot path. - frame_size: usize, - period_size: usize, - period_samples: usize, - // Only used for Output direction. - equilibrium: Option, - - // How callback timestamps are produced. - timestamp_mode: TimestampMode, - - // htstamp value from the status query at prepare() time. - // Used as the creation-time anchor for SystemClock and AudioLink calculations. - creation_ts: alsa::timespec, - - // Monotonic instant captured at stream creation. Timestamp origin for CreationInstant - // mode and last-resort fallback if the status query in now() fails. - creation_instant: std::time::Instant, - - // Xrun pending delivery to the data callback. - pending_xrun: AtomicBool, - - // Keep ALSA context alive to prevent premature ALSA config cleanup. - _context: Arc, -} - -// Assume that the ALSA library is built with thread safe option. -unsafe impl Sync for StreamInner {} - -#[derive(Debug)] -struct DuplexCaptureState { - handle: alsa::pcm::PCM, - sample_format: SampleFormat, - frame_size: usize, - period_samples: usize, - timestamp_mode: TimestampMode, - creation_ts: alsa::timespec, -} - -#[derive(Debug)] -struct DuplexPlaybackState { - handle: alsa::pcm::PCM, - sample_format: SampleFormat, - frame_size: usize, - period_samples: usize, - timestamp_mode: TimestampMode, - creation_ts: alsa::timespec, - equilibrium: EquilibriumFill, -} - -#[derive(Debug)] -struct DuplexStreamInner { - control: WorkerControl, - - capture: DuplexCaptureState, - playback: DuplexPlaybackState, - - sample_rate: SampleRate, - period_size: usize, - - // Ties capture and playback via snd_pcm_link(). begin_duplex_playback() retries when false. - // Recovery and pause leave it alone; ALSA doesn't document those as severing a link. - linked: AtomicBool, - - creation_instant: Instant, - pending_xrun: AtomicBool, - _context: Arc, -} - -// Assume that the ALSA library is built with thread safe option. -unsafe impl Sync for DuplexStreamInner {} - -impl DuplexStreamInner { - #[cfg(feature = "realtime")] - fn is_rt_eligible(&self) -> bool { - pcm_is_rt_eligible(&self.capture.handle) && pcm_is_rt_eligible(&self.playback.handle) - } -} - -#[derive(Debug)] -pub struct Stream { - /// The high-priority audio processing thread calling callbacks. - /// Option used for moving out in destructor. - thread: Option>, - - /// Single-direction or duplex. - kind: StreamKind, - - /// Used to signal to stop processing. - trigger: TriggerSender, - - /// Keeps the read end of the self-pipe alive for the lifetime of the Stream, so that - /// `trigger.wakeup()` never writes to a closed pipe, even if the worker exited early. - _rx: Arc, - - /// Latch that blocks the worker thread until `play()` is called for the first time. - latch: Latch, -} - -#[derive(Debug)] -enum StreamKind { - Single(Arc), - Duplex(Arc), -} - -impl StreamInner { - #[inline] - fn callback_instant(&self, status: &alsa::pcm::Status) -> StreamInstant { - callback_instant_for( - self.timestamp_mode, - self.creation_ts, - self.creation_instant, - status, - ) - } -} - -#[inline] -fn callback_instant_for( - timestamp_mode: TimestampMode, - creation_ts: alsa::timespec, - creation_instant: std::time::Instant, - status: &alsa::pcm::Status, -) -> StreamInstant { - // For playback the PCM starts in PREPARED state while the output buffer fills; - // snd_pcm_start() fires automatically at start_threshold, moving it to RUNNING. - // Therefore, callbacks arrive before RUNNING state. Using creation_ts as the - // anchor for all modes means timestamps advance monotonically through both the - // initial buffer fill and any later xrun recovery. - match timestamp_mode { - TimestampMode::CreationInstant => { - let d = std::time::Instant::now().duration_since(creation_instant); - StreamInstant::new(d.as_secs(), d.subsec_nanos()) - } - TimestampMode::SystemClock => { - // htstamp is the time of the most recent DMA interrupt on the configured - // monotonic clock. Subtracting creation_ts (same clock, prepare() time) - // gives elapsed time since stream creation in any PCM state. - htstamp_elapsed(status, creation_ts) - } - TimestampMode::AudioLink => { - // audio_htstamp measures elapsed time since snd_pcm_start() via hardware - // sample counter and TSC cross-timestamp, so it is only valid in RUNNING state. - if status.get_state() != alsa::pcm::State::Running { - // After xrun recovery, snd_pcm_prepare() does not reset trigger_htstamp - // (only snd_pcm_start() does), so it keeps its pre-xrun value while the - // hardware counter has not yet restarted. - htstamp_elapsed(status, creation_ts) - } else { - // When running, add (trigger_ts − creation_ts) to express elapsed time - // since stream creation rather than since the last snd_pcm_start(). - let trigger_ts = status.get_trigger_htstamp(); - let trigger_offset = timespec_diff_nanos(trigger_ts, creation_ts); - if trigger_offset < 0 { - // trigger_ts predates creation_ts (driver bug); fall back to - // htstamp − creation_ts to preserve a monotone result. - htstamp_elapsed(status, creation_ts) - } else { - let audio_ts = status.get_audio_htstamp(); - let nanos = timespec_to_nanos(audio_ts) + trigger_offset; - StreamInstant::from_nanos(nanos as u64) - } - } - } - } -} - -impl StreamInner { - #[cfg(feature = "realtime")] - fn is_rt_eligible(&self) -> bool { - pcm_is_rt_eligible(&self.handle) - } -} - -#[cfg(feature = "realtime")] -fn pcm_is_rt_eligible(handle: &alsa::pcm::PCM) -> bool { - use alsa_sys::*; - // SAFETY: `alsa::pcm::PCM` is `pub struct PCM(*mut snd_pcm_t, Cell)`. The crate - // does not expose a public `as_ptr()`, but we can cast and read from it. - // TODO: replace with `handle.as_ptr()` once alsa-rs exposes it publicly. - let raw = unsafe { - (handle as *const alsa::pcm::PCM) - .cast::<*mut snd_pcm_t>() - .read() - }; - let pcm_type = unsafe { snd_pcm_type(raw) }; - - // Only attempt RT promotion for types known not to spin and not to chain to a - // server-backed backend. Therefore, we exclude: - // - NULL: always-ready poll() spins and exhausts RLIMIT_RTTIME, causing SIGXCPU. - // - IOPLUG/EXTPLUG: may route to PulseAudio, causing priority inversion and SIGXCPU. - // - HOOKS, SOFTVOL, PLUG, RATE, ROUTE, COPY: that can chain to either of the above. - matches!( - pcm_type, - SND_PCM_TYPE_HW - | SND_PCM_TYPE_LINEAR - | SND_PCM_TYPE_ALAW - | SND_PCM_TYPE_MULAW - | SND_PCM_TYPE_ADPCM - | SND_PCM_TYPE_LINEAR_FLOAT - | SND_PCM_TYPE_IEC958 - ) -} - -struct StreamWorkerContext { - descriptors: Box<[libc::pollfd]>, - transfer_buffer: Box<[u8]>, - poll_timeout: i32, -} - -impl StreamWorkerContext { - fn new(poll_timeout: &Option, stream: &StreamInner, rx: &TriggerReceiver) -> Self { - let poll_timeout: i32 = if let Some(d) = poll_timeout { - // Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll), - // but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested. - d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 - } else { - POLL_INFINITE - }; - - // Pre-allocate a period-sized working buffer. Contents are overwritten each callback. - let transfer_buffer = vec![0u8; stream.period_size * stream.frame_size].into_boxed_slice(); - - // Pre-allocate and initialize descriptors vector: 1 for self-pipe + ALSA descriptors. - // The descriptor count is constant for the lifetime of stream parameters, and - // poll() overwrites revents on each call, so we only need to set up fd and events once. - let num_descriptors = stream.handle.count(); - let total_descriptors = 1 + num_descriptors; - let mut descriptors = vec![ - libc::pollfd { - fd: 0, - events: 0, - revents: 0 - }; - total_descriptors - ] - .into_boxed_slice(); - - // Set up self-pipe descriptor at index 0 - descriptors[0] = libc::pollfd { - fd: rx.0, - events: libc::POLLIN, - revents: 0, - }; - - // Set up ALSA descriptors starting at index 1 - let filled = stream - .handle - .fill(&mut descriptors[1..]) - .expect("Failed to fill ALSA descriptors"); - debug_assert_eq!(filled, num_descriptors); - - Self { - descriptors, - transfer_buffer, - poll_timeout, - } - } -} - -fn input_stream_worker( - rx: Arc, - stream: &StreamInner, - data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), - timeout: Option, -) { - #[cfg(feature = "realtime")] - if stream.is_rt_eligible() { - let period_frames = u32::try_from(stream.period_size).unwrap_or(0); - if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( - period_frames, - stream.sample_rate, - ) { - error_callback(err.into()); - } - } - - let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); - loop { - if stream.control.dropping.load(Ordering::Relaxed) { - return; - } - if stream.control.parked.load(Ordering::Relaxed) { - stream.control.acknowledge_park(); - } - let result = match poll_for_period(&rx, stream, &mut ctxt) { - Ok(Poll::Pending) => continue, - Ok(Poll::Recover) => recover_input(stream), - Ok(Poll::Ready { - status, - delay_frames, - }) => process_input( - stream, - &mut ctxt.transfer_buffer, - status, - delay_frames, - data_callback, - ), - Err(err) => Err(err), - }; - if let Err(err) = result { - match err.kind() { - ErrorKind::DeviceNotAvailable => { - error_callback(err); - stream.control.signal_worker_exit(); - return; - } - _ => error_callback(err), - } - } - } -} - -fn output_stream_worker( - rx: Arc, - stream: &StreamInner, - data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), - timeout: Option, -) { - #[cfg(feature = "realtime")] - if stream.is_rt_eligible() { - let period_frames = u32::try_from(stream.period_size).unwrap_or(0); - if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( - period_frames, - stream.sample_rate, - ) { - error_callback(err.into()); - } - } - - let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); - - loop { - if stream.control.dropping.load(Ordering::Relaxed) { - return; - } - if stream.control.parked.load(Ordering::Relaxed) { - stream.control.acknowledge_park(); - } - let result = match poll_for_period(&rx, stream, &mut ctxt) { - Ok(Poll::Pending) => continue, - Ok(Poll::Recover) => recover_output(stream), - Ok(Poll::Ready { - status, - delay_frames, - }) => process_output( - stream, - &mut ctxt.transfer_buffer, - status, - delay_frames, - data_callback, - ), - Err(err) => Err(err), - }; - if let Err(err) = result { - match err.kind() { - ErrorKind::DeviceNotAvailable => { - error_callback(err); - stream.control.signal_worker_exit(); - return; - } - _ => error_callback(err), - } - } - } -} - -/// Attempt hardware resume from a suspend event (`ESTRPIPE`). -fn try_resume(stream: &StreamInner) -> Result { - let handle = &stream.handle; - - let hw_params = handle.hw_params_current()?; - if !hw_params.can_resume() { - // Hardware doesn't support suspend/resume: fall back to full recovery. - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(Poll::Recover); - } - - match handle.resume() { - Ok(()) => { - if handle - .info() - .map(|i| i.get_stream() == alsa::Direction::Capture) - .unwrap_or(false) - { - // A successful `resume()` may leave the device `PREPARED` rather than `RUNNING`. - // `start()` to ensure the capture actually resumes. - if let Err(e) = handle.start() { - // `EBUSY` is ignored because it means the device is already running. - if e.errno() != libc::EBUSY { - return Err(e.into()); - } - } - } - Ok(Poll::Pending) - } - // device is still resuming; poll again until it is ready. - Err(e) if e.errno() == libc::EAGAIN => Ok(Poll::Pending), - // hardware does not support soft resume: fall back to full recovery. - Err(e) if e.errno() == libc::ENOSYS => { - stream.pending_xrun.store(true, Ordering::Relaxed); - Ok(Poll::Recover) - } - Err(e) => Err(e.into()), - } -} - -enum Poll { - Pending, - Ready { - status: alsa::pcm::Status, - delay_frames: usize, - }, - // An xrun was detected; the worker should call prepare() (+ start() for input) and loop. - Recover, -} - -fn poll_for_period( - rx: &TriggerReceiver, - stream: &StreamInner, - ctxt: &mut StreamWorkerContext, -) -> Result { - let StreamWorkerContext { - ref mut descriptors, - ref poll_timeout, - .. - } = *ctxt; - - let res = alsa::poll::poll(descriptors, *poll_timeout)?; - if res == 0 { - // Timeout expired with no events. Query PCM state to handle cases where - // POLLERR/POLLHUP was not delivered before the timeout fired (e.g. some - // power-management suspend paths or VM/container ALSA shims). - match stream.handle.state() { - alsa::pcm::State::Disconnected => { - return Err(Error::with_message( - ErrorKind::DeviceNotAvailable, - "Device disconnected", - )); - } - // Xrun with POLLERR missed: recover the same way the POLLERR path does. - alsa::pcm::State::XRun => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(Poll::Recover); - } - // Suspend with POLLHUP/POLLERR missed: attempt hardware resume. - alsa::pcm::State::Suspended => return try_resume(stream), - // No events and no error state: spurious wakeup, poll again. - _ => {} - } - return Ok(Poll::Pending); - } - - if descriptors[0].revents != 0 { - // Self-pipe fired: the stream is being dropped. Clear the pipe and let the - // worker loop detect the dropping flag on the next iteration. - rx.clear_pipe(); - return Ok(Poll::Pending); - } - - let revents = stream.handle.revents(&descriptors[1..])?; - // No events: spurious wakeup, poll again. - if revents.is_empty() { - return Ok(Poll::Pending); - } - // POLLHUP/POLLNVAL: the device has been disconnected. - if revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) { - return Err(Error::with_message( - ErrorKind::DeviceNotAvailable, - "Device disconnected", - )); - } - // POLLERR signals an xrun or suspend; avail_delay() below returns an error accordingly. - // POLLIN/POLLOUT: data is ready, fall through to process it. - let (avail_frames, delay_frames) = match stream.handle.avail_delay() { - // Suspend: try hardware resume first; fall back to prepare() if unsupported. - // BSD compat: check via PCM state rather than the Linux-specific ESTRPIPE errno. - Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { - return try_resume(stream); - } - // Xrun: recover via prepare() (+ start() for capture, handled by the worker). - Err(err) if err.errno() == libc::EPIPE => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(Poll::Recover); - } - res => res, - }?; - // ALSA can have spurious wakeups where poll returns but avail < avail_min. - // This is documented to occur with dmix (timer-driven) and other plugins. - // Verify we have room for at least one full period before processing. - // See: https://bugzilla.kernel.org/show_bug.cgi?id=202499 - // - // Compare in Frames (i64) so that a negative avail_frames from a buggy driver - // naturally fails the guard rather than wrapping to a huge usize that passes it. - if avail_frames < stream.period_size as alsa::pcm::Frames { - return Ok(Poll::Pending); - } - - // From the guard above we know that this poll is not a spurious wakeup, - // so we also know we can query the device in a stable state. - let status = status_with_timestamp(&stream.handle, stream.timestamp_mode)?; - - Ok(Poll::Ready { - status, - delay_frames: delay_frames.max(0) as usize, - }) -} - -fn status_with_timestamp( - handle: &alsa::pcm::PCM, - mode: TimestampMode, -) -> Result { - let audio_ts_type = match mode { - TimestampMode::AudioLink => alsa::pcm::AudioTstampType::LinkSynchronized, - TimestampMode::SystemClock | TimestampMode::CreationInstant => { - alsa::pcm::AudioTstampType::Compat - } - }; - alsa::pcm::StatusBuilder::new() - .audio_htstamp_config(audio_ts_type, false) - .build(handle) - .map_err(Into::into) -} - -// Full input underrun recovery: mark the xrun, then prepare + start the stream. -fn recover_input(stream: &StreamInner) -> Result<(), Error> { - stream.pending_xrun.store(true, Ordering::Relaxed); - stream.handle.prepare()?; - stream.handle.start()?; - Ok(()) -} - -// Read input data from ALSA and deliver it to the user. -fn process_input( - stream: &StreamInner, - buffer: &mut [u8], - status: alsa::pcm::Status, - delay_frames: usize, - data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), -) -> Result<(), Error> { - let mut frames_read = 0; - while frames_read < stream.period_size { - match stream - .handle - .io_bytes() - .readi(&mut buffer[frames_read * stream.frame_size..]) - { - Ok(n) => frames_read += n, - // EAGAIN = no frames available: skip this cycle if no progress was made, - // otherwise treat as an underrun (partial period cannot be delivered safely). - Err(err) if err.errno() == libc::EAGAIN && frames_read == 0 => return Ok(()), - // Suspend: try soft resume first, falling back to underrun recovery if the - // hardware doesn't support it. BSD compat: check via PCM state rather than the - // Linux-specific ESTRPIPE errno. - Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { - return match try_resume(stream)? { - Poll::Recover => recover_input(stream), - _ => Ok(()), - }; - } - // EAGAIN with partial progress, or EPIPE: full underrun recovery required. - Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { - return recover_input(stream); - } - Err(err) => return Err(err.into()), - } - } - if !stream.control.draining.load(Ordering::Relaxed) { - let data = buffer.as_mut_ptr() as *mut (); - let data = unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; - let callback_instant = stream.callback_instant(&status); - let delay_duration = frames_to_duration(delay_frames as FrameCount, stream.sample_rate); - let capture = callback_instant - .checked_sub(delay_duration) - .unwrap_or(StreamInstant::ZERO); - let timestamp = StreamTimestamp { - callback: callback_instant, - device: capture, - }; - let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); - data_callback(&data, &CallbackInfo { timestamp, xrun }); - } - - Ok(()) -} - -// Request data from the user's function and write it via ALSA. -// Full output underrun recovery: mark the xrun, then prepare the stream. No need to call -// start(): ALSA automatically restarts output streams once the buffer is refilled and -// triggered again. -fn recover_output(stream: &StreamInner) -> Result<(), Error> { - stream.pending_xrun.store(true, Ordering::Relaxed); - stream.handle.prepare()?; - Ok(()) -} - -fn process_output( - stream: &StreamInner, - buffer: &mut [u8], - status: alsa::pcm::Status, - delay_frames: usize, - data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), -) -> Result<(), Error> { - // Pre-fill buffer with equilibrium; user callback overwrites what it wants. - stream - .equilibrium - .as_ref() - .expect("process_output only runs for Output-direction streams") - .fill(buffer); - - if !stream.control.draining.load(Ordering::Relaxed) { - let data = buffer.as_mut_ptr() as *mut (); - let mut data = - unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; - let callback_instant = stream.callback_instant(&status); - let delay_duration = frames_to_duration(delay_frames as FrameCount, stream.sample_rate); - let playback = callback_instant + delay_duration; - let timestamp = StreamTimestamp { - callback: callback_instant, - device: playback, - }; - let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); - data_callback(&mut data, &CallbackInfo { timestamp, xrun }); - } - - let mut frames_written = 0; - while frames_written < stream.period_size { - match stream - .handle - .io_bytes() - .writei(&buffer[frames_written * stream.frame_size..]) - { - Ok(n) => frames_written += n, - // EAGAIN = device cannot currently accept more frames: skip this cycle if no - // progress was made, otherwise treat as an underrun (partial period cannot be - // completed safely). - Err(err) if err.errno() == libc::EAGAIN && frames_written == 0 => return Ok(()), - // Suspend: try soft resume first, falling back to underrun recovery if the - // hardware doesn't support it. BSD compat: check via PCM state rather than the Linux-specific ESTRPIPE errno. - Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { - return match try_resume(stream)? { - Poll::Recover => recover_output(stream), - _ => Ok(()), - }; - } - // EAGAIN with partial progress, or EPIPE: full underrun recovery required. - Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { - return recover_output(stream); - } - Err(err) => return Err(err.into()), - } - } - Ok(()) -} - -// Prefills playback with silence, links the pair if not already linked, and starts capture -// (which starts playback too via kernel link-group propagation) or starts both explicitly if -// unlinked. Call only when both PCMs are Prepared. -// -// snd_pcm_link() only synchronizes PCMs sharing one card's hardware trigger, so it can fail -// (e.g. an `asym` PCM spanning two cards) while both PCMs still open and run fine independently. -// cpal can't verify hardware clock sharing either way, so a failed link doesn't refuse the -// stream: it proceeds unlinked instead of gating on a signal it can't fully trust. -fn begin_duplex_playback(stream: &DuplexStreamInner) -> Result<(), Error> { - let mut silence = vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); - stream.playback.equilibrium.fill(&mut silence); - for _ in 0..DEFAULT_PERIODS { - let mut frames_written = 0; - while frames_written < stream.period_size { - let n = stream - .playback - .handle - .io_bytes() - .writei(&silence[frames_written * stream.playback.frame_size..])?; - frames_written += n; - } - } - - if !stream.linked.load(Ordering::Relaxed) - && stream.capture.handle.link(&stream.playback.handle).is_ok() - { - stream.linked.store(true, Ordering::Relaxed); - } - - stream.capture.handle.start()?; - if !stream.linked.load(Ordering::Relaxed) { - stream.playback.handle.start()?; - } - Ok(()) -} - -fn start_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { - match stream.capture.handle.state() { - alsa::pcm::State::Paused => { - let resumed = stream - .capture - .handle - .pause(false) - .and_then(|_| stream.playback.handle.pause(false)); - // Mirrors pause_duplex's fallback: resuming a linked pair via PAUSE_RELEASE can be - // as unreliable as pausing it was, on the same drivers. - if resumed.is_err() { - stream.capture.handle.drop().ok(); - stream.playback.handle.drop().ok(); - stream.capture.handle.prepare()?; - stream.playback.handle.prepare()?; - begin_duplex_playback(stream)?; - } - } - // Guard against Setup in case prepare() in stop() failed silently. - alsa::pcm::State::Prepared | alsa::pcm::State::Setup => { - if stream.capture.handle.state() == alsa::pcm::State::Setup { - stream.capture.handle.prepare()?; - } - if stream.playback.handle.state() == alsa::pcm::State::Setup { - stream.playback.handle.prepare()?; - } - begin_duplex_playback(stream)?; - } - _ => {} - } - Ok(()) -} - -// On xrun, drop() and prepare() both PCMs. When linked this is one call each: DROP and PREPARE -// propagate through the kernel link group the same way START does (snd_pcm_action_group() -// applies to every substream in the group, not just the one the ioctl was issued on). -fn recover_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { - stream.pending_xrun.store(true, Ordering::Relaxed); - if stream.linked.load(Ordering::Relaxed) { - stream.capture.handle.drop()?; - stream.capture.handle.prepare()?; - } else { - stream.capture.handle.drop().ok(); - stream.playback.handle.drop().ok(); - stream.capture.handle.prepare()?; - stream.playback.handle.prepare()?; - } - begin_duplex_playback(stream) -} - -struct DuplexStreamWorkerContext { - descriptors: Box<[libc::pollfd]>, - capture_range: std::ops::Range, - playback_range: std::ops::Range, - capture_buffer: Box<[u8]>, - playback_buffer: Box<[u8]>, - poll_timeout: i32, -} - -impl DuplexStreamWorkerContext { - fn new( - poll_timeout: &Option, - stream: &DuplexStreamInner, - rx: &TriggerReceiver, - ) -> Self { - let poll_timeout: i32 = if let Some(d) = poll_timeout { - d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 - } else { - POLL_INFINITE - }; - - let capture_buffer = - vec![0u8; stream.period_size * stream.capture.frame_size].into_boxed_slice(); - let playback_buffer = - vec![0u8; stream.period_size * stream.playback.frame_size].into_boxed_slice(); - - let capture_count = stream.capture.handle.count(); - let playback_count = stream.playback.handle.count(); - let mut descriptors = vec![ - libc::pollfd { - fd: 0, - events: 0, - revents: 0 - }; - 1 + capture_count + playback_count - ] - .into_boxed_slice(); - - descriptors[0] = libc::pollfd { - fd: rx.0, - events: libc::POLLIN, - revents: 0, - }; - - let capture_range = 1..(1 + capture_count); - let playback_range = capture_range.end..(capture_range.end + playback_count); - - let filled = stream - .capture - .handle - .fill(&mut descriptors[capture_range.clone()]) - .expect("Failed to fill ALSA capture descriptors"); - debug_assert_eq!(filled, capture_count); - let filled = stream - .playback - .handle - .fill(&mut descriptors[playback_range.clone()]) - .expect("Failed to fill ALSA playback descriptors"); - debug_assert_eq!(filled, playback_count); - - Self { - descriptors, - capture_range, - playback_range, - capture_buffer, - playback_buffer, - poll_timeout, - } - } -} - -fn duplex_stream_worker( - rx: Arc, - stream: &DuplexStreamInner, - data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), - timeout: Option, -) { - #[cfg(feature = "realtime")] - if stream.is_rt_eligible() { - let period_frames = u32::try_from(stream.period_size).unwrap_or(0); - if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( - period_frames, - stream.sample_rate, - ) { - error_callback(err.into()); - } - } - - let mut ctxt = DuplexStreamWorkerContext::new(&timeout, stream, &rx); - loop { - if stream.control.dropping.load(Ordering::Relaxed) { - return; - } - if stream.control.parked.load(Ordering::Relaxed) { - stream.control.acknowledge_park(); - } - let result = match poll_for_duplex_period(&rx, stream, &mut ctxt) { - Ok(DuplexPoll::Pending) => continue, - Ok(DuplexPoll::Recover) => recover_duplex(stream), - Ok(DuplexPoll::Ready { - capture_status, - playback_status, - capture_delay, - playback_delay, - }) => process_duplex( - stream, - &mut ctxt.capture_buffer, - &mut ctxt.playback_buffer, - capture_status, - playback_status, - capture_delay, - playback_delay, - data_callback, - ), - Err(err) => Err(err), - }; - if let Err(err) = result { - match err.kind() { - ErrorKind::DeviceNotAvailable => { - error_callback(err); - stream.control.signal_worker_exit(); - return; - } - _ => error_callback(err), - } - } - } -} - -#[expect(clippy::large_enum_variant)] -enum DuplexPoll { - Pending, - Ready { - capture_status: alsa::pcm::Status, - playback_status: alsa::pcm::Status, - capture_delay: usize, - playback_delay: usize, - }, - Recover, -} - -// Neither direction is processed until both have a full period ready, keeping capture and -// playback in lockstep when snd_pcm_link() couldn't tie them together (virtual PCMs like -// default or pulse have no shared substream to link, so this is common on non-hw devices). -// Suspend goes straight to full recovery instead of a soft hardware resume, since duplex -// would need to keep that resume path in sync across two handles. -fn poll_for_duplex_period( - rx: &TriggerReceiver, - stream: &DuplexStreamInner, - ctxt: &mut DuplexStreamWorkerContext, -) -> Result { - let res = alsa::poll::poll(&mut ctxt.descriptors, ctxt.poll_timeout)?; - if res == 0 { - for handle in [&stream.capture.handle, &stream.playback.handle] { - match handle.state() { - alsa::pcm::State::Disconnected => { - return Err(Error::with_message( - ErrorKind::DeviceNotAvailable, - "Device disconnected", - )); - } - alsa::pcm::State::XRun | alsa::pcm::State::Suspended => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(DuplexPoll::Recover); - } - _ => {} - } - } - return Ok(DuplexPoll::Pending); - } - - if ctxt.descriptors[0].revents != 0 { - rx.clear_pipe(); - return Ok(DuplexPoll::Pending); - } - - let capture_revents = stream - .capture - .handle - .revents(&ctxt.descriptors[ctxt.capture_range.clone()])?; - let playback_revents = stream - .playback - .handle - .revents(&ctxt.descriptors[ctxt.playback_range.clone()])?; - if capture_revents.is_empty() && playback_revents.is_empty() { - return Ok(DuplexPoll::Pending); - } - if capture_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) - || playback_revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) - { - return Err(Error::with_message( - ErrorKind::DeviceNotAvailable, - "Device disconnected", - )); - } - - let (capture_avail, capture_delay) = match stream.capture.handle.avail_delay() { - Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(DuplexPoll::Recover); - } - Err(err) if err.errno() == libc::EPIPE => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(DuplexPoll::Recover); - } - res => res, - }?; - let (playback_avail, playback_delay) = match stream.playback.handle.avail_delay() { - Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(DuplexPoll::Recover); - } - Err(err) if err.errno() == libc::EPIPE => { - stream.pending_xrun.store(true, Ordering::Relaxed); - return Ok(DuplexPoll::Recover); - } - res => res, - }?; - if capture_avail < stream.period_size as alsa::pcm::Frames - || playback_avail < stream.period_size as alsa::pcm::Frames - { - return Ok(DuplexPoll::Pending); - } - - let capture_status = - status_with_timestamp(&stream.capture.handle, stream.capture.timestamp_mode)?; - let playback_status = - status_with_timestamp(&stream.playback.handle, stream.playback.timestamp_mode)?; - - Ok(DuplexPoll::Ready { - capture_status, - playback_status, - capture_delay: capture_delay.max(0) as usize, - playback_delay: playback_delay.max(0) as usize, - }) -} - -#[expect(clippy::too_many_arguments)] -fn process_duplex( - stream: &DuplexStreamInner, - capture_buffer: &mut [u8], - playback_buffer: &mut [u8], - capture_status: alsa::pcm::Status, - playback_status: alsa::pcm::Status, - capture_delay: usize, - playback_delay: usize, - data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), -) -> Result<(), Error> { - let mut frames_read = 0; - while frames_read < stream.period_size { - match stream - .capture - .handle - .io_bytes() - .readi(&mut capture_buffer[frames_read * stream.capture.frame_size..]) - { - Ok(n) => frames_read += n, - Err(err) if err.errno() == libc::EAGAIN && frames_read == 0 => return Ok(()), - Err(_) if matches!(stream.capture.handle.state(), alsa::pcm::State::Suspended) => { - return recover_duplex(stream); - } - Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { - return recover_duplex(stream); - } - Err(err) => return Err(err.into()), - } - } - - stream.playback.equilibrium.fill(playback_buffer); - - if !stream.control.draining.load(Ordering::Relaxed) { - let input_ptr = capture_buffer.as_ptr() as *mut (); - let input_data = unsafe { - Data::from_parts( - input_ptr, - stream.capture.period_samples, - stream.capture.sample_format, - ) - }; - let output_ptr = playback_buffer.as_mut_ptr() as *mut (); - let mut output_data = unsafe { - Data::from_parts( - output_ptr, - stream.playback.period_samples, - stream.playback.sample_format, - ) - }; - - let capture_instant = callback_instant_for( - stream.capture.timestamp_mode, - stream.capture.creation_ts, - stream.creation_instant, - &capture_status, - ); - let capture_delay_duration = - frames_to_duration(capture_delay as FrameCount, stream.sample_rate); - let capture_device = capture_instant - .checked_sub(capture_delay_duration) - .unwrap_or(StreamInstant::ZERO); - - let playback_instant = callback_instant_for( - stream.playback.timestamp_mode, - stream.playback.creation_ts, - stream.creation_instant, - &playback_status, - ); - let playback_delay_duration = - frames_to_duration(playback_delay as FrameCount, stream.sample_rate); - let playback_device = playback_instant + playback_delay_duration; - - let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); - let info = DuplexCallbackInfo::new( - CallbackInfo { - timestamp: StreamTimestamp { - callback: capture_instant, - device: capture_device, - }, - xrun, - }, - CallbackInfo { - timestamp: StreamTimestamp { - callback: playback_instant, - device: playback_device, - }, - xrun, - }, - ); - data_callback(&input_data, &mut output_data, &info); - } - - let mut frames_written = 0; - while frames_written < stream.period_size { - match stream - .playback - .handle - .io_bytes() - .writei(&playback_buffer[frames_written * stream.playback.frame_size..]) - { - Ok(n) => frames_written += n, - Err(err) if err.errno() == libc::EAGAIN && frames_written == 0 => return Ok(()), - Err(_) if matches!(stream.playback.handle.state(), alsa::pcm::State::Suspended) => { - return recover_duplex(stream); - } - Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { - return recover_duplex(stream); - } - Err(err) => return Err(err.into()), - } - } - Ok(()) -} - -// Adapted from `timestamp2ns` here: -// https://fossies.org/linux/alsa-lib/test/audio_time.c -#[inline] -#[expect(clippy::unnecessary_cast)] -fn timespec_to_nanos(ts: alsa::timespec) -> i64 { - ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64 -} - -// Adapted from `timediff` here: -// https://fossies.org/linux/alsa-lib/test/audio_time.c -#[inline] -fn timespec_diff_nanos(a: alsa::timespec, b: alsa::timespec) -> i64 { - timespec_to_nanos(a) - timespec_to_nanos(b) -} - -// StreamInstant representing how long htstamp is ahead of origin, clamped to zero. -// Used as the creation-relative timestamp source for SystemClock and AudioLink fallback paths. -#[inline] -fn htstamp_elapsed(status: &alsa::pcm::Status, origin: alsa::timespec) -> StreamInstant { - let nanos = timespec_diff_nanos(status.get_htstamp(), origin); - StreamInstant::from_nanos(nanos.max(0) as u64) -} - -impl Stream { - /// Parks the worker and gets exclusive access to the PCM handle(s). - fn park_worker(&self) { - self.latch.release(); - // Must be true before the trigger fires, so the worker sees it on the next loop iteration. - match &self.kind { - StreamKind::Single(inner) => { - inner.control.parked.store(true, Ordering::Relaxed); - self.trigger.wakeup(); - inner.control.park_worker(); - } - StreamKind::Duplex(inner) => { - inner.control.parked.store(true, Ordering::Relaxed); - self.trigger.wakeup(); - inner.control.park_worker(); - } - } - } - - fn new_input( - inner: Arc, - mut data_callback: D, - mut error_callback: E, - timeout: Option, - ) -> Stream - where - D: FnMut(&Data, &CallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - let (tx, rx) = trigger(); - let rx_thread = rx.clone(); - let stream = inner.clone(); - - // The latch is released by play(); the worker blocks here until then, keeping the PCM - // in PREPARED state with no DMA activity. - let mut latch = Latch::new(); - let waiter = latch.waiter(); - - let thread = thread::Builder::new() - .name("cpal_alsa_in".to_owned()) - .spawn(move || { - waiter.wait(); - input_stream_worker( - rx_thread, - &stream, - &mut data_callback, - &mut error_callback, - timeout, - ); - }) - .unwrap(); - latch.add_thread(thread.thread().clone()); - - Self { - thread: Some(thread), - kind: StreamKind::Single(inner), - trigger: tx, - _rx: rx, - latch, - } - } - - fn new_output( - inner: Arc, - mut data_callback: D, - mut error_callback: E, - timeout: Option, - ) -> Stream - where - D: FnMut(&mut Data, &CallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - let (tx, rx) = trigger(); - let rx_thread = rx.clone(); - let stream = inner.clone(); - - // The latch is released by play(); the worker blocks here until then, keeping the PCM - // in PREPARED state with no DMA activity. - let mut latch = Latch::new(); - let waiter = latch.waiter(); - - let thread = thread::Builder::new() - .name("cpal_alsa_out".to_owned()) - .spawn(move || { - waiter.wait(); - output_stream_worker( - rx_thread, - &stream, - &mut data_callback, - &mut error_callback, - timeout, - ); - }) - .unwrap(); - latch.add_thread(thread.thread().clone()); - - Self { - thread: Some(thread), - kind: StreamKind::Single(inner), - trigger: tx, - _rx: rx, - latch, - } - } - - fn new_duplex( - inner: Arc, - mut data_callback: D, - mut error_callback: E, - timeout: Option, - ) -> Stream - where - D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, - E: FnMut(Error) + Send + 'static, - { - let (tx, rx) = trigger(); - let rx_thread = rx.clone(); - let stream = inner.clone(); - - // The latch is released by play(); the worker blocks here until then, keeping both - // PCMs in PREPARED state with no DMA activity. - let mut latch = Latch::new(); - let waiter = latch.waiter(); - - let thread = thread::Builder::new() - .name("cpal_alsa_duplex".to_owned()) - .spawn(move || { - waiter.wait(); - duplex_stream_worker( - rx_thread, - &stream, - &mut data_callback, - &mut error_callback, - timeout, - ); - }) - .unwrap(); - latch.add_thread(thread.thread().clone()); - - Self { - thread: Some(thread), - kind: StreamKind::Duplex(inner), - trigger: tx, - _rx: rx, - latch, - } - } - - fn suspend_pcm(&self, inner: &StreamInner) -> Result<(), Error> { - let hw_params = inner.handle.hw_params_current()?; - if hw_params.can_pause() { - if inner.handle.state() != alsa::pcm::State::Paused { - inner.handle.pause(true)?; - } - } else { - self.park_worker(); - let result = if inner.handle.state() == alsa::pcm::State::Running { - inner - .handle - .drop() - .and_then(|_| inner.handle.prepare()) - .map_err(Error::from) - } else { - Ok(()) - }; - inner.control.unpark_worker(); - return result; - } - Ok(()) - } - - // Drops buffered PCM data so a resumed stream doesn't deliver stale audio. - fn discard_pcm(&self, inner: &StreamInner) -> Result<(), Error> { - self.park_worker(); - let result = if inner.handle.state() != alsa::pcm::State::Setup { - inner - .handle - .drop() - .and_then(|_| inner.handle.prepare()) - .map_err(Error::from) - } else { - Ok(()) - }; - inner.control.unpark_worker(); - result - } - - // PAUSE propagates through the link group like START does, so one call on capture pauses - // both when linked; the explicit playback call only does work when unlinked. - fn pause_duplex(&self, inner: &DuplexStreamInner) -> Result<(), Error> { - let can_pause = inner.capture.handle.hw_params_current()?.can_pause() - && inner.playback.handle.hw_params_current()?.can_pause(); - if can_pause { - let result: Result<(), alsa::Error> = (|| { - if inner.capture.handle.state() != alsa::pcm::State::Paused { - inner.capture.handle.pause(true)?; - } - if inner.playback.handle.state() != alsa::pcm::State::Paused { - inner.playback.handle.pause(true)?; - } - Ok(()) - })(); - // Some drivers advertise per-direction pause support that fails once the pair is - // linked; fall through to the discard path below instead of surfacing that error. - if result.is_ok() { - return Ok(()); - } - } - - self.park_worker(); - let capture_result = if inner.capture.handle.state() == alsa::pcm::State::Running { - inner - .capture - .handle - .drop() - .and_then(|_| inner.capture.handle.prepare()) - .map_err(Error::from) - } else { - Ok(()) - }; - let playback_result = if inner.playback.handle.state() == alsa::pcm::State::Running { - inner - .playback - .handle - .drop() - .and_then(|_| inner.playback.handle.prepare()) - .map_err(Error::from) - } else { - Ok(()) - }; - inner.control.unpark_worker(); - capture_result.and(playback_result) - } - - // Discards capture and drains playback, per StreamTrait::stop's per-direction contract. Left - // linked, capture.handle.start() in the next begin_duplex_playback() fails even after - // preparing capture: a linked start() needs the whole group ready, and playback sits in - // Setup until its own prepare() runs. Unlink first so each handle can be prepared and - // started independently. - fn stop_duplex( - &self, - inner: &DuplexStreamInner, - timeout: Option, - ) -> Result<(), Error> { - self.park_worker(); - if inner.linked.swap(false, Ordering::Relaxed) { - inner.capture.handle.unlink().ok(); // best-effort - } - let capture_result = if inner.capture.handle.state() != alsa::pcm::State::Setup { - inner - .capture - .handle - .drop() - .and_then(|_| inner.capture.handle.prepare()) - .map_err(Error::from) - } else { - Ok(()) - }; - let playback_result = drain_pcm(&inner.playback.handle, timeout); - inner.control.unpark_worker(); - capture_result.and(playback_result) - } -} - -// Drains a parked output PCM: caller holds exclusive access via park_worker()/unpark_worker(). -fn drain_pcm(handle: &alsa::pcm::PCM, timeout: Option) -> Result<(), Error> { - if timeout == Some(Duration::ZERO) { - handle.drop().ok(); - return handle.prepare().map_err(Into::into); - } - - // Non-blocking drain: the PCM is opened non-blocking, so snd_pcm_drain returns EAGAIN - // immediately. Poll the ALSA fds until drain completes or the deadline expires. - let deadline = timeout.and_then(|t| Instant::now().checked_add(t)); - let mut fds = handle.get()?; - let mut result: Result<(), Error> = Ok(()); - - 'drain: loop { - match handle.drain() { - Ok(()) => break, - Err(e) if e.errno() == libc::EAGAIN => { - let timeout_ms = match deadline { - None => POLL_INFINITE, - Some(deadline) => { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - handle.drop().ok(); - break 'drain; - } - remaining.as_millis().min(i32::MAX as u128) as i32 - } - }; - match alsa::poll::poll(&mut fds, timeout_ms) { - Ok(0) => { - handle.drop().ok(); - break 'drain; - } - Ok(_) => continue, - Err(e) => { - result = Err(e.into()); - break; - } - } - } - Err(e) => { - result = Err(e.into()); - break; - } - } - } - - // Leave PCM in PREPARED so the worker can resume normally. - match handle.state() { - alsa::pcm::State::Setup => { - // Drain completed or drop-on-timeout succeeded. - if let Err(e) = handle.prepare() { - result = result.and(Err(e.into())); - } - } - alsa::pcm::State::Draining => { - // A poll error interrupted an in-progress drain; abort it. - handle.drop().ok(); - if let Err(e) = handle.prepare() { - result = result.and(Err(e.into())); - } - } - _ => {} // XRun, Running, Disconnected: worker's own recovery handles it - } - - result -} - -impl Stream { - // Signals the worker to exit: marks it dropping, unblocks it from acknowledge_park() - // if parked, and wakes it from poll_for_period(). dropping must be set first so the - // worker exits on re-entry rather than polling again. - fn shutdown_worker(&self) { - match &self.kind { - StreamKind::Single(inner) => { - inner.control.dropping.store(true, Ordering::Relaxed); - inner.control.unpark_worker(); - } - StreamKind::Duplex(inner) => { - inner.control.dropping.store(true, Ordering::Relaxed); - inner.control.unpark_worker(); - } - } - self.trigger.wakeup(); - } -} - -impl Drop for Stream { - fn drop(&mut self) { - // Unblock the worker in case the stream is dropped before start() was called. - // Idempotent: no effect if the worker is already running. - self.latch.release(); - self.shutdown_worker(); - if let Some(handle) = self.thread.take() { - let _ = handle.join(); - } - } -} - -impl StreamTrait for Stream { - fn start(&self) -> Result<(), Error> { - match &self.kind { - StreamKind::Single(inner) => { - inner.control.draining.store(false, Ordering::Relaxed); - self.latch.release(); // idempotent: no-op after first call - inner.control.unpark_worker(); // resume if stop() left it parked; no-op otherwise - match inner.handle.state() { - // Calling start() on an empty output buffer would trigger an immediate XRUN. - alsa::pcm::State::Prepared if inner.direction == DeviceDirection::Input => { - inner.handle.start()?; - } - alsa::pcm::State::Paused => { - inner.handle.pause(false)?; - } - // Guard against Setup in case prepare() in stop() failed silently. - alsa::pcm::State::Setup => { - inner.handle.prepare()?; - if inner.direction == DeviceDirection::Input { - inner.handle.start()?; - } - } - _ => {} - } - Ok(()) - } - StreamKind::Duplex(inner) => { - inner.control.draining.store(false, Ordering::Relaxed); - self.latch.release(); - inner.control.unpark_worker(); - start_duplex(inner) - } - } - } - - fn pause(&self) -> Result<(), Error> { - match &self.kind { - StreamKind::Single(inner) => { - inner.control.draining.store(true, Ordering::Relaxed); - self.suspend_pcm(inner) - } - StreamKind::Duplex(inner) => { - inner.control.draining.store(true, Ordering::Relaxed); - self.pause_duplex(inner) - } - } - } - - fn stop(&self, timeout: Option) -> Result<(), Error> { - match &self.kind { - StreamKind::Single(inner) => { - inner.control.draining.store(true, Ordering::Relaxed); - - if inner.direction != DeviceDirection::Output { - // Unlike pause(), stop() discards rather than preserves buffered samples. - return self.discard_pcm(inner); - } - - self.park_worker(); - let result = drain_pcm(&inner.handle, timeout); - inner.control.unpark_worker(); - result - } - StreamKind::Duplex(inner) => { - inner.control.draining.store(true, Ordering::Relaxed); - self.stop_duplex(inner, timeout) - } - } - } - - fn now(&self) -> StreamInstant { - match &self.kind { - StreamKind::Single(inner) => { - if inner.timestamp_mode != TimestampMode::CreationInstant { - if let Ok(status) = status_with_timestamp(&inner.handle, inner.timestamp_mode) { - return inner.callback_instant(&status); - } - } - let d = std::time::Instant::now().duration_since(inner.creation_instant); - StreamInstant::new(d.as_secs(), d.subsec_nanos()) - } - StreamKind::Duplex(inner) => { - // Capture is the canonical clock, matching how process_duplex derives - // DuplexCallbackInfo's capture-side timestamp. - if inner.capture.timestamp_mode != TimestampMode::CreationInstant { - if let Ok(status) = - status_with_timestamp(&inner.capture.handle, inner.capture.timestamp_mode) - { - return callback_instant_for( - inner.capture.timestamp_mode, - inner.capture.creation_ts, - inner.creation_instant, - &status, - ); - } - } - let d = std::time::Instant::now().duration_since(inner.creation_instant); - StreamInstant::new(d.as_secs(), d.subsec_nanos()) - } - } - } - - fn buffer_size(&self) -> Result { - match &self.kind { - StreamKind::Single(inner) => Ok(inner.period_size as FrameCount), - StreamKind::Duplex(inner) => Ok(inner.period_size as FrameCount), - } - } -} - -fn supported_period_size_range( - hw_params: &alsa::pcm::HwParams<'_>, - alsa_format: alsa::pcm::Format, - channels: ChannelCount, -) -> SupportedBufferSize { - let p = hw_params.clone(); - if p.set_access(alsa::pcm::Access::RWInterleaved).is_err() - || p.set_channels(channels as u32).is_err() - || p.set_format(alsa_format).is_err() - { - return SupportedBufferSize::Unknown; - } - let Some((min, max)) = hw_params_period_size_min_max(&p) else { - return SupportedBufferSize::Unknown; - }; - let min_frames = min.max(1); - // cpal double-buffers (ring = DEFAULT_PERIODS × period), so the achievable - // period maximum is also bounded by max_buffer / DEFAULT_PERIODS. - let effective_max = match p.get_buffer_size_max() { - Ok(max_buf) if max_buf > 0 => max.min(max_buf / DEFAULT_PERIODS), - _ => max, - }; - if effective_max >= min_frames { - let Ok(min) = min_frames.try_into() else { - return SupportedBufferSize::Unknown; - }; - SupportedBufferSize::Range { - min, - max: effective_max.try_into().unwrap_or(FrameCount::MAX), - } - } else { - SupportedBufferSize::Unknown - } -} - -fn hw_params_period_size_min_max( - hw_params: &alsa::pcm::HwParams, -) -> Option<(alsa::pcm::Frames, alsa::pcm::Frames)> { - let min = hw_params.get_period_size_min().ok()?; - let max = hw_params.get_period_size_max().ok()?; - // min=0 means no hardware lower bound (PipeWire reports this on unconstrained params); - // it is handled in the caller by clamping to 1. max <= 0 is degenerate (or ULONG_MAX - // wrapping negative), so we return None in that case rather than a misleading range. - (max > 0 && max >= min).then_some((min, max)) -} - -fn init_hw_params<'a>( - pcm_handle: &'a alsa::pcm::PCM, - config: StreamConfig, - sample_format: SampleFormat, -) -> Result, Error> { - let hw_params = alsa::pcm::HwParams::any(pcm_handle)?; - hw_params.set_access(alsa::pcm::Access::RWInterleaved)?; - - // Determine which endianness the hardware actually supports for this format. - // We prefer native endian (no conversion needed) but fall back to the opposite - // endian if that's all the hardware supports (e.g., LE USB DAC on BE system). - let alsa_format = sample_format_to_alsa_format(&hw_params, sample_format)?; - hw_params.set_format(alsa_format)?; - - hw_params.set_rate(config.sample_rate, alsa::ValueOr::Nearest)?; - hw_params.set_channels(config.channels as u32)?; - Ok(hw_params) -} - -/// Convert SampleFormat to the appropriate alsa::pcm::Format based on what the hardware supports. -/// Prefers native endian, falls back to non-native if that's all the hardware supports. -fn sample_format_to_alsa_format( - hw_params: &alsa::pcm::HwParams, - sample_format: SampleFormat, -) -> Result { - use alsa::pcm::Format; - - // For each sample format, define (native_endian_format, opposite_endian_format) pairs - let (native, opposite) = match sample_format { - SampleFormat::I8 => return Ok(Format::S8), // No endianness - SampleFormat::U8 => return Ok(Format::U8), // No endianness - #[cfg(target_endian = "little")] - SampleFormat::I16 => (Format::S16LE, Format::S16BE), - #[cfg(target_endian = "big")] - SampleFormat::I16 => (Format::S16BE, Format::S16LE), - #[cfg(target_endian = "little")] - SampleFormat::U16 => (Format::U16LE, Format::U16BE), - #[cfg(target_endian = "big")] - SampleFormat::U16 => (Format::U16BE, Format::U16LE), - #[cfg(target_endian = "little")] - SampleFormat::I24 => (Format::S24LE, Format::S24BE), - #[cfg(target_endian = "big")] - SampleFormat::I24 => (Format::S24BE, Format::S24LE), - #[cfg(target_endian = "little")] - SampleFormat::U24 => (Format::U24LE, Format::U24BE), - #[cfg(target_endian = "big")] - SampleFormat::U24 => (Format::U24BE, Format::U24LE), - #[cfg(target_endian = "little")] - SampleFormat::I32 => (Format::S32LE, Format::S32BE), - #[cfg(target_endian = "big")] - SampleFormat::I32 => (Format::S32BE, Format::S32LE), - #[cfg(target_endian = "little")] - SampleFormat::U32 => (Format::U32LE, Format::U32BE), - #[cfg(target_endian = "big")] - SampleFormat::U32 => (Format::U32BE, Format::U32LE), - #[cfg(target_endian = "little")] - SampleFormat::F32 => (Format::FloatLE, Format::FloatBE), - #[cfg(target_endian = "big")] - SampleFormat::F32 => (Format::FloatBE, Format::FloatLE), - #[cfg(target_endian = "little")] - SampleFormat::F64 => (Format::Float64LE, Format::Float64BE), - #[cfg(target_endian = "big")] - SampleFormat::F64 => (Format::Float64BE, Format::Float64LE), - SampleFormat::DsdU8 => return Ok(Format::DSDU8), - #[cfg(target_endian = "little")] - SampleFormat::DsdU16 => (Format::DSDU16LE, Format::DSDU16BE), - #[cfg(target_endian = "big")] - SampleFormat::DsdU16 => (Format::DSDU16BE, Format::DSDU16LE), - #[cfg(target_endian = "little")] - SampleFormat::DsdU32 => (Format::DSDU32LE, Format::DSDU32BE), - #[cfg(target_endian = "big")] - SampleFormat::DsdU32 => (Format::DSDU32BE, Format::DSDU32LE), - _ => { - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!("Sample format {sample_format} is not supported"), - )); - } - }; - - // Try native endian first (optimal - no conversion needed) - if hw_params.test_format(native).is_ok() { - return Ok(native); - } - - // Fall back to opposite endian if hardware only supports that - if hw_params.test_format(opposite).is_ok() { - return Ok(opposite); - } - - Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!("Sample format {sample_format} is not supported in any byte order"), - )) -} - -fn set_hw_params_from_format( - pcm_handle: &alsa::pcm::PCM, - config: StreamConfig, - sample_format: SampleFormat, -) -> Result, Error> { - let hw_params = init_hw_params(pcm_handle, config, sample_format)?; - - // When BufferSize::Fixed(x) is specified, we configure double-buffering with - // buffer_size = 2x and period_size = x. This provides consistent low-latency - // behavior across different ALSA implementations and hardware. - if let BufferSize::Fixed(period_size) = config.buffer_size { - let period_size = period_size as alsa::pcm::Frames; - - // Validate the requested size against the device's supported ranges using the same PCM - // handle we'll use for streaming. This avoids a second PCM open (which can disturb - // hardware clock state on some drivers) while still catching wildly out-of-range - // requests before set_period_size_near silently rounds them. - if let Some((min_period, max_period)) = hw_params_period_size_min_max(&hw_params) { - if !(min_period..=max_period).contains(&period_size) { - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!( - "Buffer size {period_size} is not in the supported range {min_period}..={max_period}" - ), - )); - } - } - - let buffer_size = DEFAULT_PERIODS * period_size; - if let Ok(max_buffer) = hw_params.get_buffer_size_max() { - if max_buffer > 0 && buffer_size > max_buffer { - let effective_max = max_buffer / DEFAULT_PERIODS; - return Err(Error::with_message( - ErrorKind::UnsupportedConfig, - format!( - "Buffer size {period_size} exceeds the maximum supported value of {effective_max}" - ), - )); - } - } - - hw_params.set_buffer_size_near(buffer_size)?; - hw_params.set_period_size_near(period_size, alsa::ValueOr::Nearest)?; - } - - // Apply hardware parameters - pcm_handle.hw_params(&hw_params)?; - - // For BufferSize::Default, constrain to device's configured period with 2-period buffering. - // PipeWire-ALSA picks a good period size but pairs it with many periods (huge buffer). - // We need to re-initialize hw_params and set BOTH period and buffer to constrain properly. - if config.buffer_size == BufferSize::Default { - if let Ok(period_size) = hw_params.get_period_size() { - // Re-initialize hw_params to clear previous constraints - let hw_params = init_hw_params(pcm_handle, config, sample_format)?; - - // Set both period (to device's chosen value) and buffer (to 2 periods) - hw_params.set_period_size_near(period_size, alsa::ValueOr::Nearest)?; - hw_params.set_buffer_size_near(DEFAULT_PERIODS * period_size)?; - - // Re-apply with new constraints - pcm_handle.hw_params(&hw_params)?; - } - } - - pcm_handle.hw_params_current().map_err(Into::into) -} - -// What triggers ALSA's automatic Prepared -> Running transition. -enum StartThreshold { - // Capture: any read request satisfies this trivially - effectively immediate. - Immediate, - // Playback: starts once this many periods are queued, regardless of total buffer depth. - Periods(usize), - // Never automatically; the caller starts the PCM explicitly. - Disabled, -} - -fn set_sw_params_from_format( - pcm_handle: &alsa::pcm::PCM, - start_threshold: StartThreshold, -) -> Result<(alsa::pcm::Frames, alsa::pcm::Frames), Error> { - let sw_params = pcm_handle.sw_params_current()?; - let (buffer_size, period_size) = pcm_handle - .get_params() - .map(|(b, p)| (b as alsa::pcm::Frames, p as alsa::pcm::Frames))?; - - let threshold = match start_threshold { - StartThreshold::Immediate => 1, - StartThreshold::Periods(periods) => periods as alsa::pcm::Frames * period_size, - // boundary is unreachable, so auto-start never fires. - StartThreshold::Disabled => sw_params.get_boundary()?, - }; - sw_params.set_start_threshold(threshold)?; - sw_params.set_avail_min(period_size)?; - - sw_params.set_tstamp_mode(true)?; - sw_params.set_tstamp_type(alsa::pcm::TstampType::MonotonicRaw)?; - - // tstamp_type param cannot be changed after the device is opened. - // The default tstamp_type value on most Linux systems is "monotonic", - // let's try to use it if setting the tstamp_type fails. - if pcm_handle.sw_params(&sw_params).is_err() { - sw_params.set_tstamp_type(alsa::pcm::TstampType::Monotonic)?; - pcm_handle.sw_params(&sw_params)?; - } - - Ok((buffer_size, period_size)) -} +// TODO: Not yet defined in rust-lang/libc crate +const LIBC_ENOTSUPP: libc::c_int = 524; fn canonical_pcm_id(pcm_id: &str) -> String { if let Some((prefix, rest)) = pcm_id.split_once(':') { diff --git a/src/host/alsa/stream.rs b/src/host/alsa/stream.rs new file mode 100644 index 000000000..86266b1cb --- /dev/null +++ b/src/host/alsa/stream.rs @@ -0,0 +1,797 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use super::{ + AlsaContext, POLL_INFINITE, alsa, + alsa::poll::Descriptors, + duplex::{duplex_stream_worker, start_duplex}, + timestamp::{callback_instant_for, status_with_timestamp}, + trigger::{TriggerReceiver, TriggerSender, trigger}, + worker::{input_stream_worker, output_stream_worker}, +}; +use crate::{ + CallbackInfo, Data, DeviceDirection, DuplexCallbackInfo, Error, FrameCount, SampleFormat, + SampleRate, StreamInstant, + host::{ + Notify, + equilibrium::{DSD_EQUILIBRIUM_BYTE, U8_EQUILIBRIUM_BYTE, fill_equilibrium}, + latch::Latch, + }, + traits::StreamTrait, +}; + +#[derive(Debug)] +pub struct Stream { + /// The high-priority audio processing thread calling callbacks. + /// Option used for moving out in destructor. + thread: Option>, + + /// Single-direction or duplex. + kind: StreamKind, + + /// Used to signal to stop processing. + trigger: TriggerSender, + + /// Keeps the read end of the self-pipe alive for the lifetime of the Stream, so that + /// `trigger.wakeup()` never writes to a closed pipe, even if the worker exited early. + _rx: Arc, + + /// Latch that blocks the worker thread until `play()` is called for the first time. + latch: Latch, +} + +#[derive(Debug)] +enum StreamKind { + Single(Arc), + Duplex(Arc), +} + +impl Stream { + /// Parks the worker and gets exclusive access to the PCM handle(s). + fn park_worker(&self) { + self.latch.release(); + // Must be true before the trigger fires, so the worker sees it on the next loop iteration. + match &self.kind { + StreamKind::Single(inner) => { + inner.control.parked.store(true, Ordering::Relaxed); + self.trigger.wakeup(); + inner.control.park_worker(); + } + StreamKind::Duplex(inner) => { + inner.control.parked.store(true, Ordering::Relaxed); + self.trigger.wakeup(); + inner.control.park_worker(); + } + } + } + + pub(super) fn new_input( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&Data, &CallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let (tx, rx) = trigger(); + let rx_thread = rx.clone(); + let stream = inner.clone(); + + // The latch is released by play(); the worker blocks here until then, keeping the PCM + // in PREPARED state with no DMA activity. + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let thread = thread::Builder::new() + .name("cpal_alsa_in".to_owned()) + .spawn(move || { + waiter.wait(); + input_stream_worker( + rx_thread, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + latch.add_thread(thread.thread().clone()); + + Self { + thread: Some(thread), + kind: StreamKind::Single(inner), + trigger: tx, + _rx: rx, + latch, + } + } + + pub(super) fn new_output( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&mut Data, &CallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let (tx, rx) = trigger(); + let rx_thread = rx.clone(); + let stream = inner.clone(); + + // The latch is released by play(); the worker blocks here until then, keeping the PCM + // in PREPARED state with no DMA activity. + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let thread = thread::Builder::new() + .name("cpal_alsa_out".to_owned()) + .spawn(move || { + waiter.wait(); + output_stream_worker( + rx_thread, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + latch.add_thread(thread.thread().clone()); + + Self { + thread: Some(thread), + kind: StreamKind::Single(inner), + trigger: tx, + _rx: rx, + latch, + } + } + + pub(super) fn new_duplex( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static, + E: FnMut(Error) + Send + 'static, + { + let (tx, rx) = trigger(); + let rx_thread = rx.clone(); + let stream = inner.clone(); + + // The latch is released by play(); the worker blocks here until then, keeping both + // PCMs in PREPARED state with no DMA activity. + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let thread = thread::Builder::new() + .name("cpal_alsa_duplex".to_owned()) + .spawn(move || { + waiter.wait(); + duplex_stream_worker( + rx_thread, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + latch.add_thread(thread.thread().clone()); + + Self { + thread: Some(thread), + kind: StreamKind::Duplex(inner), + trigger: tx, + _rx: rx, + latch, + } + } + + fn suspend_pcm(&self, inner: &StreamInner) -> Result<(), Error> { + let hw_params = inner.handle.hw_params_current()?; + if hw_params.can_pause() { + if inner.handle.state() != alsa::pcm::State::Paused { + inner.handle.pause(true)?; + } + } else { + self.park_worker(); + let result = if inner.handle.state() == alsa::pcm::State::Running { + inner + .handle + .drop() + .and_then(|_| inner.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + inner.control.unpark_worker(); + return result; + } + Ok(()) + } + + // Drops buffered PCM data so a resumed stream doesn't deliver stale audio. + fn discard_pcm(&self, inner: &StreamInner) -> Result<(), Error> { + self.park_worker(); + let result = if inner.handle.state() != alsa::pcm::State::Setup { + inner + .handle + .drop() + .and_then(|_| inner.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + inner.control.unpark_worker(); + result + } + + // PAUSE propagates through the link group like START does, so one call on capture pauses + // both when linked; the explicit playback call only does work when unlinked. + fn pause_duplex(&self, inner: &DuplexStreamInner) -> Result<(), Error> { + let can_pause = inner.capture.handle.hw_params_current()?.can_pause() + && inner.playback.handle.hw_params_current()?.can_pause(); + if can_pause { + let result: Result<(), alsa::Error> = (|| { + if inner.capture.handle.state() != alsa::pcm::State::Paused { + inner.capture.handle.pause(true)?; + } + if inner.playback.handle.state() != alsa::pcm::State::Paused { + inner.playback.handle.pause(true)?; + } + Ok(()) + })(); + // Some drivers advertise per-direction pause support that fails once the pair is + // linked; fall through to the discard path below instead of surfacing that error. + if result.is_ok() { + return Ok(()); + } + } + + self.park_worker(); + let capture_result = if inner.capture.handle.state() == alsa::pcm::State::Running { + inner + .capture + .handle + .drop() + .and_then(|_| inner.capture.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + let playback_result = if inner.playback.handle.state() == alsa::pcm::State::Running { + inner + .playback + .handle + .drop() + .and_then(|_| inner.playback.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + inner.control.unpark_worker(); + capture_result.and(playback_result) + } + + // Discards capture and drains playback, per StreamTrait::stop's per-direction contract. Left + // linked, capture.handle.start() in the next begin_duplex_playback() fails even after + // preparing capture: a linked start() needs the whole group ready, and playback sits in + // Setup until its own prepare() runs. Unlink first so each handle can be prepared and + // started independently. + fn stop_duplex( + &self, + inner: &DuplexStreamInner, + timeout: Option, + ) -> Result<(), Error> { + self.park_worker(); + if inner.linked.swap(false, Ordering::Relaxed) { + inner.capture.handle.unlink().ok(); // best-effort + } + let capture_result = if inner.capture.handle.state() != alsa::pcm::State::Setup { + inner + .capture + .handle + .drop() + .and_then(|_| inner.capture.handle.prepare()) + .map_err(Error::from) + } else { + Ok(()) + }; + let playback_result = drain_pcm(&inner.playback.handle, timeout); + inner.control.unpark_worker(); + capture_result.and(playback_result) + } + + // Signals the worker to exit: marks it dropping, unblocks it from acknowledge_park() + // if parked, and wakes it from poll_for_period(). dropping must be set first so the + // worker exits on re-entry rather than polling again. + fn shutdown_worker(&self) { + match &self.kind { + StreamKind::Single(inner) => { + inner.control.dropping.store(true, Ordering::Relaxed); + inner.control.unpark_worker(); + } + StreamKind::Duplex(inner) => { + inner.control.dropping.store(true, Ordering::Relaxed); + inner.control.unpark_worker(); + } + } + self.trigger.wakeup(); + } +} + +// Drains a parked output PCM: caller holds exclusive access via park_worker()/unpark_worker(). +fn drain_pcm(handle: &alsa::pcm::PCM, timeout: Option) -> Result<(), Error> { + if timeout == Some(Duration::ZERO) { + handle.drop().ok(); + return handle.prepare().map_err(Into::into); + } + + // Non-blocking drain: the PCM is opened non-blocking, so snd_pcm_drain returns EAGAIN + // immediately. Poll the ALSA fds until drain completes or the deadline expires. + let deadline = timeout.and_then(|t| Instant::now().checked_add(t)); + let mut fds = handle.get()?; + let mut result: Result<(), Error> = Ok(()); + + 'drain: loop { + match handle.drain() { + Ok(()) => break, + Err(e) if e.errno() == libc::EAGAIN => { + let timeout_ms = match deadline { + None => POLL_INFINITE, + Some(deadline) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + handle.drop().ok(); + break 'drain; + } + remaining.as_millis().min(i32::MAX as u128) as i32 + } + }; + match alsa::poll::poll(&mut fds, timeout_ms) { + Ok(0) => { + handle.drop().ok(); + break 'drain; + } + Ok(_) => continue, + Err(e) => { + result = Err(e.into()); + break; + } + } + } + Err(e) => { + result = Err(e.into()); + break; + } + } + } + + // Leave PCM in PREPARED so the worker can resume normally. + match handle.state() { + alsa::pcm::State::Setup => { + // Drain completed or drop-on-timeout succeeded. + if let Err(e) = handle.prepare() { + result = result.and(Err(e.into())); + } + } + alsa::pcm::State::Draining => { + // A poll error interrupted an in-progress drain; abort it. + handle.drop().ok(); + if let Err(e) = handle.prepare() { + result = result.and(Err(e.into())); + } + } + _ => {} // XRun, Running, Disconnected: worker's own recovery handles it + } + + result +} + +impl Drop for Stream { + fn drop(&mut self) { + // Unblock the worker in case the stream is dropped before start() was called. + // Idempotent: no effect if the worker is already running. + self.latch.release(); + self.shutdown_worker(); + if let Some(handle) = self.thread.take() { + let _ = handle.join(); + } + } +} + +impl StreamTrait for Stream { + fn start(&self) -> Result<(), Error> { + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(false, Ordering::Relaxed); + self.latch.release(); // idempotent: no-op after first call + inner.control.unpark_worker(); // resume if stop() left it parked; no-op otherwise + match inner.handle.state() { + // Calling start() on an empty output buffer would trigger an immediate XRUN. + alsa::pcm::State::Prepared if inner.direction == DeviceDirection::Input => { + inner.handle.start()?; + } + alsa::pcm::State::Paused => { + inner.handle.pause(false)?; + } + // Guard against Setup in case prepare() in stop() failed silently. + alsa::pcm::State::Setup => { + inner.handle.prepare()?; + if inner.direction == DeviceDirection::Input { + inner.handle.start()?; + } + } + _ => {} + } + Ok(()) + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(false, Ordering::Relaxed); + self.latch.release(); + inner.control.unpark_worker(); + start_duplex(inner) + } + } + } + + fn pause(&self) -> Result<(), Error> { + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.suspend_pcm(inner) + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.pause_duplex(inner) + } + } + } + + fn stop(&self, timeout: Option) -> Result<(), Error> { + match &self.kind { + StreamKind::Single(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + + if inner.direction != DeviceDirection::Output { + // Unlike pause(), stop() discards rather than preserves buffered samples. + return self.discard_pcm(inner); + } + + self.park_worker(); + let result = drain_pcm(&inner.handle, timeout); + inner.control.unpark_worker(); + result + } + StreamKind::Duplex(inner) => { + inner.control.draining.store(true, Ordering::Relaxed); + self.stop_duplex(inner, timeout) + } + } + } + + fn now(&self) -> StreamInstant { + match &self.kind { + StreamKind::Single(inner) => { + if inner.timestamp_mode != TimestampMode::CreationInstant { + if let Ok(status) = status_with_timestamp(&inner.handle, inner.timestamp_mode) { + return inner.callback_instant(&status); + } + } + let d = std::time::Instant::now().duration_since(inner.creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) + } + StreamKind::Duplex(inner) => { + // Capture is the canonical clock, matching how process_duplex derives + // DuplexCallbackInfo's capture-side timestamp. + if inner.capture.timestamp_mode != TimestampMode::CreationInstant { + if let Ok(status) = + status_with_timestamp(&inner.capture.handle, inner.capture.timestamp_mode) + { + return callback_instant_for( + inner.capture.timestamp_mode, + inner.capture.creation_ts, + inner.creation_instant, + &status, + ); + } + } + let d = std::time::Instant::now().duration_since(inner.creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) + } + } + } + + fn buffer_size(&self) -> Result { + match &self.kind { + StreamKind::Single(inner) => Ok(inner.period_size as FrameCount), + StreamKind::Duplex(inner) => Ok(inner.period_size as FrameCount), + } + } +} + +/// Strategy for pre-filling an output buffer with the equilibrium value. +#[derive(Debug)] +pub(super) enum EquilibriumFill { + /// Equilibrium is represented as a single repeating byte value. + Byte(u8), + /// A period-sized buffer pre-filled with the equilibrium value. + Template(Box<[u8]>), +} + +impl EquilibriumFill { + /// Compute the equilibrium-fill strategy for the given sample format at stream creation. + pub(super) fn new(sample_format: SampleFormat, period_bytes: usize) -> Self { + if sample_format.is_int() || sample_format.is_float() { + Self::Byte(0) + } else if sample_format == SampleFormat::U8 { + Self::Byte(U8_EQUILIBRIUM_BYTE) + } else if sample_format.is_dsd() { + Self::Byte(DSD_EQUILIBRIUM_BYTE) + } else { + // Multi-byte unsigned integer formats require a fill equal to the midpoint of their + // range. + debug_assert!(sample_format.is_uint()); + let mut template = vec![0u8; period_bytes].into_boxed_slice(); + fill_equilibrium(&mut template, sample_format); + Self::Template(template) + } + } + + #[inline] + pub(super) fn fill(&self, buffer: &mut [u8]) { + match self { + Self::Byte(b) => buffer.fill(*b), + Self::Template(t) => buffer.copy_from_slice(t), + } + } +} + +// A zero get_htstamp() at prepare time indicates the device does not support hardware +// timestamps (e.g. PulseAudio ALSA plugin). Related: +// https://bugs.freedesktop.org/show_bug.cgi?id=88503 +pub(super) fn timestamp_mode_for( + hw_params: &alsa::pcm::HwParams<'_>, + creation_ts: alsa::timespec, +) -> TimestampMode { + if creation_ts.tv_sec == 0 && creation_ts.tv_nsec == 0 { + TimestampMode::CreationInstant + } else if hw_params.supports_audio_ts_type(alsa::pcm::AudioTstampType::LinkSynchronized) { + TimestampMode::AudioLink + } else { + TimestampMode::SystemClock + } +} + +// How callback timestamps are produced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TimestampMode { + // Hardware timestamps are unavailable (e.g. PulseAudio ALSA plugin returns zero htstamp). + // Timestamps are monotonic elapsed time since stream creation, sourced from Instant::now(). + CreationInstant, + + // The kernel records the monotonic clock at each DMA interrupt in htstamp. + // Subtracting creation_ts (same clock, captured at prepare time) gives elapsed time + // since stream creation. Uses CLOCK_MONOTONIC_RAW when available, CLOCK_MONOTONIC otherwise. + SystemClock, + + // The hardware maps the audio sample counter to CLOCK_MONOTONIC_RAW via TSC + // cross-timestamps (LinkSynchronized), giving a timestamp that tracks the actual audio + // clock rather than DMA interrupt delivery time. Higher fidelity than SystemClock. + AudioLink, +} + +// Park/drop plumbing shared by StreamInner and DuplexStreamInner, giving StreamTrait +// exclusive worker access for pause/stop/drain regardless of handle count. +#[derive(Debug, Default)] +pub(super) struct WorkerControl { + // Set when the worker should stop polling, e.g. after a device disconnect. + pub(super) dropping: AtomicBool, + + // Whether the user callback is currently suppressed. + pub(super) draining: AtomicBool, + + // Set by stop() to request the worker pause for exclusive PCM access during drain. + pub(super) parked: AtomicBool, + park: Notify, +} + +impl WorkerControl { + // Pauses the worker at its next loop iteration and waits for acknowledgment, or returns + // early if it already exited. Caller holds exclusive PCM access until unpark_worker(). + pub(super) fn park_worker(&self) { + self.parked.store(true, Ordering::Relaxed); + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + // Exit if the worker acknowledged the park OR if the worker has exited (dropping=true). + while !*guard && !self.dropping.load(Ordering::Relaxed) { + guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); + } + } + + // Acknowledges a pending park, then sleeps until unpark_worker() is called. + pub(super) fn acknowledge_park(&self) { + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + *guard = true; + cvar.notify_one(); + while self.parked.load(Ordering::Relaxed) { + guard = cvar.wait(guard).unwrap_or_else(|e| e.into_inner()); + } + *guard = false; + } + + // Marks the stream dead and wakes any thread blocked in park_worker(), so an exit + // other than a normal drop doesn't hang it. + pub(super) fn signal_worker_exit(&self) { + self.dropping.store(true, Ordering::Relaxed); + let (lock, cvar) = &self.park; + let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + cvar.notify_one(); + } + + // Releases the park: clears parked and wakes the worker from acknowledge_park(). + pub(super) fn unpark_worker(&self) { + let (lock, cvar) = &self.park; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + *guard = false; + self.parked.store(false, Ordering::Relaxed); + drop(guard); + cvar.notify_one(); + } +} + +#[derive(Debug)] +pub(super) struct StreamInner { + // Controls the worker thread's lifecycle and pause/drain state. + pub(super) control: WorkerControl, + + // Stream direction. + pub(super) direction: DeviceDirection, + + // The ALSA handle. + pub(super) handle: alsa::pcm::PCM, + + // Format of the samples. + pub(super) sample_format: SampleFormat, + + // Sample rate of the stream. + pub(super) sample_rate: SampleRate, + + // Cached values for performance in audio callback hot path. + pub(super) frame_size: usize, + pub(super) period_size: usize, + pub(super) period_samples: usize, + // Only used for Output direction. + pub(super) equilibrium: Option, + + // How callback timestamps are produced. + pub(super) timestamp_mode: TimestampMode, + + // htstamp value from the status query at prepare() time. + // Used as the creation-time anchor for SystemClock and AudioLink calculations. + pub(super) creation_ts: alsa::timespec, + + // Monotonic instant captured at stream creation. Timestamp origin for CreationInstant + // mode and last-resort fallback if the status query in now() fails. + pub(super) creation_instant: std::time::Instant, + + // Xrun pending delivery to the data callback. + pub(super) pending_xrun: AtomicBool, + + // Keep ALSA context alive to prevent premature ALSA config cleanup. + pub(super) _context: Arc, +} + +// Assume that the ALSA library is built with thread safe option. +unsafe impl Sync for StreamInner {} + +impl StreamInner { + #[inline] + pub(super) fn callback_instant(&self, status: &alsa::pcm::Status) -> StreamInstant { + callback_instant_for( + self.timestamp_mode, + self.creation_ts, + self.creation_instant, + status, + ) + } + + #[cfg(feature = "realtime")] + pub(super) fn is_rt_eligible(&self) -> bool { + pcm_is_rt_eligible(&self.handle) + } +} + +#[derive(Debug)] +pub(super) struct DuplexCaptureState { + pub(super) handle: alsa::pcm::PCM, + pub(super) sample_format: SampleFormat, + pub(super) frame_size: usize, + pub(super) period_samples: usize, + pub(super) timestamp_mode: TimestampMode, + pub(super) creation_ts: alsa::timespec, +} + +#[derive(Debug)] +pub(super) struct DuplexPlaybackState { + pub(super) handle: alsa::pcm::PCM, + pub(super) sample_format: SampleFormat, + pub(super) frame_size: usize, + pub(super) period_samples: usize, + pub(super) timestamp_mode: TimestampMode, + pub(super) creation_ts: alsa::timespec, + pub(super) equilibrium: EquilibriumFill, +} + +#[derive(Debug)] +pub(super) struct DuplexStreamInner { + pub(super) control: WorkerControl, + + pub(super) capture: DuplexCaptureState, + pub(super) playback: DuplexPlaybackState, + + pub(super) sample_rate: SampleRate, + pub(super) period_size: usize, + + // Ties capture and playback via snd_pcm_link(). begin_duplex_playback() retries when false. + // Recovery and pause leave it alone; ALSA doesn't document those as severing a link. + pub(super) linked: AtomicBool, + + pub(super) creation_instant: Instant, + pub(super) pending_xrun: AtomicBool, + pub(super) _context: Arc, +} + +// Assume that the ALSA library is built with thread safe option. +unsafe impl Sync for DuplexStreamInner {} + +impl DuplexStreamInner { + #[cfg(feature = "realtime")] + pub(super) fn is_rt_eligible(&self) -> bool { + pcm_is_rt_eligible(&self.capture.handle) && pcm_is_rt_eligible(&self.playback.handle) + } +} + +#[cfg(feature = "realtime")] +fn pcm_is_rt_eligible(handle: &alsa::pcm::PCM) -> bool { + use alsa_sys::*; + // SAFETY: `alsa::pcm::PCM` is `pub struct PCM(*mut snd_pcm_t, Cell)`. The crate + // does not expose a public `as_ptr()`, but we can cast and read from it. + // TODO: replace with `handle.as_ptr()` once alsa-rs exposes it publicly. + let raw = unsafe { + (handle as *const alsa::pcm::PCM) + .cast::<*mut snd_pcm_t>() + .read() + }; + let pcm_type = unsafe { snd_pcm_type(raw) }; + + // Only attempt RT promotion for types known not to spin and not to chain to a + // server-backed backend. Therefore, we exclude: + // - NULL: always-ready poll() spins and exhausts RLIMIT_RTTIME, causing SIGXCPU. + // - IOPLUG/EXTPLUG: may route to PulseAudio, causing priority inversion and SIGXCPU. + // - HOOKS, SOFTVOL, PLUG, RATE, ROUTE, COPY: that can chain to either of the above. + matches!( + pcm_type, + SND_PCM_TYPE_HW + | SND_PCM_TYPE_LINEAR + | SND_PCM_TYPE_ALAW + | SND_PCM_TYPE_MULAW + | SND_PCM_TYPE_ADPCM + | SND_PCM_TYPE_LINEAR_FLOAT + | SND_PCM_TYPE_IEC958 + ) +} diff --git a/src/host/alsa/timestamp.rs b/src/host/alsa/timestamp.rs new file mode 100644 index 000000000..80bf27949 --- /dev/null +++ b/src/host/alsa/timestamp.rs @@ -0,0 +1,91 @@ +use super::{alsa, stream::TimestampMode}; +use crate::{Error, StreamInstant}; + +#[inline] +pub(super) fn callback_instant_for( + timestamp_mode: TimestampMode, + creation_ts: alsa::timespec, + creation_instant: std::time::Instant, + status: &alsa::pcm::Status, +) -> StreamInstant { + // For playback the PCM starts in PREPARED state while the output buffer fills; + // snd_pcm_start() fires automatically at start_threshold, moving it to RUNNING. + // Therefore, callbacks arrive before RUNNING state. Using creation_ts as the + // anchor for all modes means timestamps advance monotonically through both the + // initial buffer fill and any later xrun recovery. + match timestamp_mode { + TimestampMode::CreationInstant => { + let d = std::time::Instant::now().duration_since(creation_instant); + StreamInstant::new(d.as_secs(), d.subsec_nanos()) + } + TimestampMode::SystemClock => { + // htstamp is the time of the most recent DMA interrupt on the configured + // monotonic clock. Subtracting creation_ts (same clock, prepare() time) + // gives elapsed time since stream creation in any PCM state. + htstamp_elapsed(status, creation_ts) + } + TimestampMode::AudioLink => { + // audio_htstamp measures elapsed time since snd_pcm_start() via hardware + // sample counter and TSC cross-timestamp, so it is only valid in RUNNING state. + if status.get_state() != alsa::pcm::State::Running { + // After xrun recovery, snd_pcm_prepare() does not reset trigger_htstamp + // (only snd_pcm_start() does), so it keeps its pre-xrun value while the + // hardware counter has not yet restarted. + htstamp_elapsed(status, creation_ts) + } else { + // When running, add (trigger_ts - creation_ts) to express elapsed time + // since stream creation rather than since the last snd_pcm_start(). + let trigger_ts = status.get_trigger_htstamp(); + let trigger_offset = timespec_diff_nanos(trigger_ts, creation_ts); + if trigger_offset < 0 { + // trigger_ts predates creation_ts (driver bug); fall back to + // htstamp - creation_ts to preserve a monotone result. + htstamp_elapsed(status, creation_ts) + } else { + let audio_ts = status.get_audio_htstamp(); + let nanos = timespec_to_nanos(audio_ts) + trigger_offset; + StreamInstant::from_nanos(nanos as u64) + } + } + } + } +} + +pub(super) fn status_with_timestamp( + handle: &alsa::pcm::PCM, + mode: TimestampMode, +) -> Result { + let audio_ts_type = match mode { + TimestampMode::AudioLink => alsa::pcm::AudioTstampType::LinkSynchronized, + TimestampMode::SystemClock | TimestampMode::CreationInstant => { + alsa::pcm::AudioTstampType::Compat + } + }; + alsa::pcm::StatusBuilder::new() + .audio_htstamp_config(audio_ts_type, false) + .build(handle) + .map_err(Into::into) +} + +// Adapted from `timestamp2ns` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +#[inline] +#[expect(clippy::unnecessary_cast)] +fn timespec_to_nanos(ts: alsa::timespec) -> i64 { + ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64 +} + +// Adapted from `timediff` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +#[inline] +fn timespec_diff_nanos(a: alsa::timespec, b: alsa::timespec) -> i64 { + timespec_to_nanos(a) - timespec_to_nanos(b) +} + +// StreamInstant representing how long htstamp is ahead of origin, clamped to zero. +// Used as the creation-relative timestamp source for SystemClock and AudioLink fallback paths. +#[inline] +fn htstamp_elapsed(status: &alsa::pcm::Status, origin: alsa::timespec) -> StreamInstant { + let nanos = timespec_diff_nanos(status.get_htstamp(), origin); + StreamInstant::from_nanos(nanos.max(0) as u64) +} diff --git a/src/host/alsa/trigger.rs b/src/host/alsa/trigger.rs new file mode 100644 index 000000000..c58f11e41 --- /dev/null +++ b/src/host/alsa/trigger.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use super::TRIGGER_PAYLOAD_SIZE; + +#[derive(Debug)] +pub(super) struct TriggerSender(pub(super) libc::c_int); + +#[derive(Debug)] +pub(super) struct TriggerReceiver(pub(super) libc::c_int); + +impl TriggerSender { + pub(super) fn wakeup(&self) { + let buf = !0u64; // any non-zero value wakes poll() + loop { + let ret = unsafe { + libc::write( + self.0, + &buf as *const u64 as *const _, + TRIGGER_PAYLOAD_SIZE as _, + ) + }; + if ret == TRIGGER_PAYLOAD_SIZE { + return; + } + // write() can be interrupted by a signal before writing any bytes; retry. + assert_eq!(ret, -1, "wakeup: unexpected return value {ret}"); + let err = std::io::Error::last_os_error(); + if err.kind() != std::io::ErrorKind::Interrupted { + panic!("wakeup: {err}"); + } + } + } +} + +impl TriggerReceiver { + pub(super) fn clear_pipe(&self) { + let mut out = 0u64; + loop { + let ret = unsafe { + libc::read( + self.0, + &mut out as *mut u64 as *mut _, + TRIGGER_PAYLOAD_SIZE as _, + ) + }; + if ret == TRIGGER_PAYLOAD_SIZE { + return; + } + // read() can be interrupted by a signal before reading any bytes; retry. + assert_eq!(ret, -1, "clear_pipe: unexpected return value {ret}"); + let err = std::io::Error::last_os_error(); + if err.kind() != std::io::ErrorKind::Interrupted { + panic!("clear_pipe: {err}"); + } + } + } +} + +pub(super) fn trigger() -> (TriggerSender, Arc) { + let mut fds = [0, 0]; + match unsafe { libc::pipe(fds.as_mut_ptr()) } { + 0 => (TriggerSender(fds[1]), Arc::new(TriggerReceiver(fds[0]))), + _ => panic!("Could not create pipe"), + } +} + +impl Drop for TriggerSender { + fn drop(&mut self) { + unsafe { + libc::close(self.0); + } + } +} + +impl Drop for TriggerReceiver { + fn drop(&mut self) { + unsafe { + libc::close(self.0); + } + } +} diff --git a/src/host/alsa/worker.rs b/src/host/alsa/worker.rs new file mode 100644 index 000000000..bb07b954a --- /dev/null +++ b/src/host/alsa/worker.rs @@ -0,0 +1,450 @@ +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use super::{ + POLL_INFINITE, alsa, alsa::poll::Descriptors, stream::StreamInner, + timestamp::status_with_timestamp, trigger::TriggerReceiver, +}; +use crate::{ + CallbackInfo, Data, Error, ErrorKind, FrameCount, StreamInstant, StreamTimestamp, + host::frames_to_duration, +}; + +pub(super) fn input_stream_worker( + rx: Arc, + stream: &StreamInner, + data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(Error) + Send + 'static), + timeout: Option, +) { + #[cfg(feature = "realtime")] + if stream.is_rt_eligible() { + let period_frames = u32::try_from(stream.period_size).unwrap_or(0); + if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( + period_frames, + stream.sample_rate, + ) { + error_callback(err.into()); + } + } + + let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); + loop { + if stream.control.dropping.load(Ordering::Relaxed) { + return; + } + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); + } + let result = match poll_for_period(&rx, stream, &mut ctxt) { + Ok(Poll::Pending) => continue, + Ok(Poll::Recover) => recover_input(stream), + Ok(Poll::Ready { + status, + delay_frames, + }) => process_input( + stream, + &mut ctxt.transfer_buffer, + status, + delay_frames, + data_callback, + ), + Err(err) => Err(err), + }; + if let Err(err) = result { + match err.kind() { + ErrorKind::DeviceNotAvailable => { + error_callback(err); + stream.control.signal_worker_exit(); + return; + } + _ => error_callback(err), + } + } + } +} + +pub(super) fn output_stream_worker( + rx: Arc, + stream: &StreamInner, + data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(Error) + Send + 'static), + timeout: Option, +) { + #[cfg(feature = "realtime")] + if stream.is_rt_eligible() { + let period_frames = u32::try_from(stream.period_size).unwrap_or(0); + if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time( + period_frames, + stream.sample_rate, + ) { + error_callback(err.into()); + } + } + + let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx); + + loop { + if stream.control.dropping.load(Ordering::Relaxed) { + return; + } + if stream.control.parked.load(Ordering::Relaxed) { + stream.control.acknowledge_park(); + } + let result = match poll_for_period(&rx, stream, &mut ctxt) { + Ok(Poll::Pending) => continue, + Ok(Poll::Recover) => recover_output(stream), + Ok(Poll::Ready { + status, + delay_frames, + }) => process_output( + stream, + &mut ctxt.transfer_buffer, + status, + delay_frames, + data_callback, + ), + Err(err) => Err(err), + }; + if let Err(err) = result { + match err.kind() { + ErrorKind::DeviceNotAvailable => { + error_callback(err); + stream.control.signal_worker_exit(); + return; + } + _ => error_callback(err), + } + } + } +} + +struct StreamWorkerContext { + descriptors: Box<[libc::pollfd]>, + transfer_buffer: Box<[u8]>, + poll_timeout: i32, +} + +impl StreamWorkerContext { + fn new(poll_timeout: &Option, stream: &StreamInner, rx: &TriggerReceiver) -> Self { + let poll_timeout: i32 = if let Some(d) = poll_timeout { + // Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll), + // but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested. + d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 + } else { + POLL_INFINITE + }; + + // Pre-allocate a period-sized working buffer. Contents are overwritten each callback. + let transfer_buffer = vec![0u8; stream.period_size * stream.frame_size].into_boxed_slice(); + + // Pre-allocate and initialize descriptors vector: 1 for self-pipe + ALSA descriptors. + // The descriptor count is constant for the lifetime of stream parameters, and + // poll() overwrites revents on each call, so we only need to set up fd and events once. + let num_descriptors = stream.handle.count(); + let total_descriptors = 1 + num_descriptors; + let mut descriptors = vec![ + libc::pollfd { + fd: 0, + events: 0, + revents: 0 + }; + total_descriptors + ] + .into_boxed_slice(); + + // Set up self-pipe descriptor at index 0 + descriptors[0] = libc::pollfd { + fd: rx.0, + events: libc::POLLIN, + revents: 0, + }; + + // Set up ALSA descriptors starting at index 1 + let filled = stream + .handle + .fill(&mut descriptors[1..]) + .expect("Failed to fill ALSA descriptors"); + debug_assert_eq!(filled, num_descriptors); + + Self { + descriptors, + transfer_buffer, + poll_timeout, + } + } +} + +/// Attempt hardware resume from a suspend event (`ESTRPIPE`). +fn try_resume(stream: &StreamInner) -> Result { + let handle = &stream.handle; + + let hw_params = handle.hw_params_current()?; + if !hw_params.can_resume() { + // Hardware doesn't support suspend/resume: fall back to full recovery. + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(Poll::Recover); + } + + match handle.resume() { + Ok(()) => { + if handle + .info() + .map(|i| i.get_stream() == alsa::Direction::Capture) + .unwrap_or(false) + { + // A successful `resume()` may leave the device `PREPARED` rather than `RUNNING`. + // `start()` to ensure the capture actually resumes. + if let Err(e) = handle.start() { + // `EBUSY` is ignored because it means the device is already running. + if e.errno() != libc::EBUSY { + return Err(e.into()); + } + } + } + Ok(Poll::Pending) + } + // device is still resuming; poll again until it is ready. + Err(e) if e.errno() == libc::EAGAIN => Ok(Poll::Pending), + // hardware does not support soft resume: fall back to full recovery. + Err(e) if e.errno() == libc::ENOSYS => { + stream.pending_xrun.store(true, Ordering::Relaxed); + Ok(Poll::Recover) + } + Err(e) => Err(e.into()), + } +} + +enum Poll { + Pending, + Ready { + status: alsa::pcm::Status, + delay_frames: usize, + }, + // An xrun was detected; the worker should call prepare() (+ start() for input) and loop. + Recover, +} + +fn poll_for_period( + rx: &TriggerReceiver, + stream: &StreamInner, + ctxt: &mut StreamWorkerContext, +) -> Result { + let StreamWorkerContext { + ref mut descriptors, + ref poll_timeout, + .. + } = *ctxt; + + let res = alsa::poll::poll(descriptors, *poll_timeout)?; + if res == 0 { + // Timeout expired with no events. Query PCM state to handle cases where + // POLLERR/POLLHUP was not delivered before the timeout fired (e.g. some + // power-management suspend paths or VM/container ALSA shims). + match stream.handle.state() { + alsa::pcm::State::Disconnected => { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + // Xrun with POLLERR missed: recover the same way the POLLERR path does. + alsa::pcm::State::XRun => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(Poll::Recover); + } + // Suspend with POLLHUP/POLLERR missed: attempt hardware resume. + alsa::pcm::State::Suspended => return try_resume(stream), + // No events and no error state: spurious wakeup, poll again. + _ => {} + } + return Ok(Poll::Pending); + } + + if descriptors[0].revents != 0 { + // Self-pipe fired: the stream is being dropped. Clear the pipe and let the + // worker loop detect the dropping flag on the next iteration. + rx.clear_pipe(); + return Ok(Poll::Pending); + } + + let revents = stream.handle.revents(&descriptors[1..])?; + // No events: spurious wakeup, poll again. + if revents.is_empty() { + return Ok(Poll::Pending); + } + // POLLHUP/POLLNVAL: the device has been disconnected. + if revents.intersects(alsa::poll::Flags::HUP | alsa::poll::Flags::NVAL) { + return Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + "Device disconnected", + )); + } + // POLLERR signals an xrun or suspend; avail_delay() below returns an error accordingly. + // POLLIN/POLLOUT: data is ready, fall through to process it. + let (avail_frames, delay_frames) = match stream.handle.avail_delay() { + // Suspend: try hardware resume first; fall back to prepare() if unsupported. + // BSD compat: check via PCM state rather than the Linux-specific ESTRPIPE errno. + Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { + return try_resume(stream); + } + // Xrun: recover via prepare() (+ start() for capture, handled by the worker). + Err(err) if err.errno() == libc::EPIPE => { + stream.pending_xrun.store(true, Ordering::Relaxed); + return Ok(Poll::Recover); + } + res => res, + }?; + // ALSA can have spurious wakeups where poll returns but avail < avail_min. + // This is documented to occur with dmix (timer-driven) and other plugins. + // Verify we have room for at least one full period before processing. + // See: https://bugzilla.kernel.org/show_bug.cgi?id=202499 + // + // Compare in Frames (i64) so that a negative avail_frames from a buggy driver + // naturally fails the guard rather than wrapping to a huge usize that passes it. + if avail_frames < stream.period_size as alsa::pcm::Frames { + return Ok(Poll::Pending); + } + + // From the guard above we know that this poll is not a spurious wakeup, + // so we also know we can query the device in a stable state. + let status = status_with_timestamp(&stream.handle, stream.timestamp_mode)?; + + Ok(Poll::Ready { + status, + delay_frames: delay_frames.max(0) as usize, + }) +} + +// Full input underrun recovery: mark the xrun, then prepare + start the stream. +fn recover_input(stream: &StreamInner) -> Result<(), Error> { + stream.pending_xrun.store(true, Ordering::Relaxed); + stream.handle.prepare()?; + stream.handle.start()?; + Ok(()) +} + +// Read input data from ALSA and deliver it to the user. +fn process_input( + stream: &StreamInner, + buffer: &mut [u8], + status: alsa::pcm::Status, + delay_frames: usize, + data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), +) -> Result<(), Error> { + let mut frames_read = 0; + while frames_read < stream.period_size { + match stream + .handle + .io_bytes() + .readi(&mut buffer[frames_read * stream.frame_size..]) + { + Ok(n) => frames_read += n, + // EAGAIN = no frames available: skip this cycle if no progress was made, + // otherwise treat as an underrun (partial period cannot be delivered safely). + Err(err) if err.errno() == libc::EAGAIN && frames_read == 0 => return Ok(()), + // Suspend: try soft resume first, falling back to underrun recovery if the + // hardware doesn't support it. BSD compat: check via PCM state rather than the + // Linux-specific ESTRPIPE errno. + Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { + return match try_resume(stream)? { + Poll::Recover => recover_input(stream), + _ => Ok(()), + }; + } + // EAGAIN with partial progress, or EPIPE: full underrun recovery required. + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_input(stream); + } + Err(err) => return Err(err.into()), + } + } + if !stream.control.draining.load(Ordering::Relaxed) { + let data = buffer.as_mut_ptr() as *mut (); + let data = unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; + let callback_instant = stream.callback_instant(&status); + let delay_duration = frames_to_duration(delay_frames as FrameCount, stream.sample_rate); + let capture = callback_instant + .checked_sub(delay_duration) + .unwrap_or(StreamInstant::ZERO); + let timestamp = StreamTimestamp { + callback: callback_instant, + device: capture, + }; + let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); + data_callback(&data, &CallbackInfo { timestamp, xrun }); + } + + Ok(()) +} + +// Request data from the user's function and write it via ALSA. +// Full output underrun recovery: mark the xrun, then prepare the stream. No need to call +// start(): ALSA automatically restarts output streams once the buffer is refilled and +// triggered again. +fn recover_output(stream: &StreamInner) -> Result<(), Error> { + stream.pending_xrun.store(true, Ordering::Relaxed); + stream.handle.prepare()?; + Ok(()) +} + +fn process_output( + stream: &StreamInner, + buffer: &mut [u8], + status: alsa::pcm::Status, + delay_frames: usize, + data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), +) -> Result<(), Error> { + // Pre-fill buffer with equilibrium; user callback overwrites what it wants. + stream + .equilibrium + .as_ref() + .expect("process_output only runs for Output-direction streams") + .fill(buffer); + + if !stream.control.draining.load(Ordering::Relaxed) { + let data = buffer.as_mut_ptr() as *mut (); + let mut data = + unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; + let callback_instant = stream.callback_instant(&status); + let delay_duration = frames_to_duration(delay_frames as FrameCount, stream.sample_rate); + let playback = callback_instant + delay_duration; + let timestamp = StreamTimestamp { + callback: callback_instant, + device: playback, + }; + let xrun = stream.pending_xrun.swap(false, Ordering::Relaxed); + data_callback(&mut data, &CallbackInfo { timestamp, xrun }); + } + + let mut frames_written = 0; + while frames_written < stream.period_size { + match stream + .handle + .io_bytes() + .writei(&buffer[frames_written * stream.frame_size..]) + { + Ok(n) => frames_written += n, + // EAGAIN = device cannot currently accept more frames: skip this cycle if no + // progress was made, otherwise treat as an underrun (partial period cannot be + // completed safely). + Err(err) if err.errno() == libc::EAGAIN && frames_written == 0 => return Ok(()), + // Suspend: try soft resume first, falling back to underrun recovery if the + // hardware doesn't support it. BSD compat: check via PCM state rather than the Linux-specific ESTRPIPE errno. + Err(_) if matches!(stream.handle.state(), alsa::pcm::State::Suspended) => { + return match try_resume(stream)? { + Poll::Recover => recover_output(stream), + _ => Ok(()), + }; + } + // EAGAIN with partial progress, or EPIPE: full underrun recovery required. + Err(err) if err.errno() == libc::EAGAIN || err.errno() == libc::EPIPE => { + return recover_output(stream); + } + Err(err) => return Err(err.into()), + } + } + Ok(()) +} From 493a6357b41437119fef458a662de228c7cb3240 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 Aug 2026 22:16:45 +0200 Subject: [PATCH 3/5] refactor(alsa): DRY up stream reset/timestamp helpers and tighten visibility --- src/host/alsa/device.rs | 29 +++++++++----------- src/host/alsa/duplex.rs | 9 ++---- src/host/alsa/mod.rs | 12 +++++++- src/host/alsa/stream.rs | 56 +++++++++++++++++--------------------- src/host/alsa/timestamp.rs | 1 + src/host/alsa/trigger.rs | 2 +- src/host/alsa/worker.rs | 10 ++----- 7 files changed, 56 insertions(+), 63 deletions(-) diff --git a/src/host/alsa/device.rs b/src/host/alsa/device.rs index 8122be595..8c251980b 100644 --- a/src/host/alsa/device.rs +++ b/src/host/alsa/device.rs @@ -16,7 +16,7 @@ use super::{ open_pcm, stream::{ DuplexCaptureState, DuplexPlaybackState, DuplexStreamInner, EquilibriumFill, StreamInner, - WorkerControl, timestamp_mode_for, + WorkerControl, creation_timestamp, }, }; use crate::{ @@ -192,14 +192,10 @@ impl Device { return Err(ErrorKind::DeviceNotAvailable.into()); } - // A zero get_htstamp() at prepare time indicates the device does not support hardware timestamps (e.g. PulseAudio ALSA plugin). - // Related: https://bugs.freedesktop.org/show_bug.cgi?id=88503 - let creation_ts = handle.status()?.get_htstamp(); - let timestamp_mode = timestamp_mode_for(&hw_params, creation_ts); - drop(hw_params); + let (creation_ts, timestamp_mode) = creation_timestamp(&handle, hw_params)?; let period_size = period_size as usize; - let frame_size = sample_format.sample_size() * conf.channels as usize; + let frame_size = frame_size(sample_format, conf.channels); let stream_inner = StreamInner { control: WorkerControl::default(), @@ -280,17 +276,14 @@ impl Device { return Err(ErrorKind::DeviceNotAvailable.into()); } - let capture_creation_ts = capture_handle.status()?.get_htstamp(); - let capture_timestamp_mode = timestamp_mode_for(&capture_hw_params, capture_creation_ts); - drop(capture_hw_params); - let playback_creation_ts = playback_handle.status()?.get_htstamp(); - let playback_timestamp_mode = timestamp_mode_for(&playback_hw_params, playback_creation_ts); - drop(playback_hw_params); + let (capture_creation_ts, capture_timestamp_mode) = + creation_timestamp(&capture_handle, capture_hw_params)?; + let (playback_creation_ts, playback_timestamp_mode) = + creation_timestamp(&playback_handle, playback_hw_params)?; let period_size = capture_period_size as usize; - let capture_frame_size = input_sample_format.sample_size() * config.input_channels as usize; - let playback_frame_size = - output_sample_format.sample_size() * config.output_channels as usize; + let capture_frame_size = frame_size(input_sample_format, config.input_channels); + let playback_frame_size = frame_size(output_sample_format, config.output_channels); let stream_inner = DuplexStreamInner { control: WorkerControl::default(), @@ -539,3 +532,7 @@ impl std::hash::Hash for Device { self.pcm_id.hash(state); } } + +fn frame_size(sample_format: SampleFormat, channels: ChannelCount) -> usize { + sample_format.sample_size() * channels as usize +} diff --git a/src/host/alsa/duplex.rs b/src/host/alsa/duplex.rs index 2e80d5abb..1d2ea332b 100644 --- a/src/host/alsa/duplex.rs +++ b/src/host/alsa/duplex.rs @@ -4,8 +4,9 @@ use std::{ }; use super::{ - DEFAULT_PERIODS, POLL_INFINITE, alsa, + DEFAULT_PERIODS, alsa, alsa::poll::Descriptors, + poll_timeout_millis, stream::DuplexStreamInner, timestamp::{callback_instant_for, status_with_timestamp}, trigger::TriggerReceiver, @@ -175,11 +176,7 @@ impl DuplexStreamWorkerContext { stream: &DuplexStreamInner, rx: &TriggerReceiver, ) -> Self { - let poll_timeout: i32 = if let Some(d) = poll_timeout { - d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 - } else { - POLL_INFINITE - }; + let poll_timeout = poll_timeout_millis(*poll_timeout); let capture_buffer = vec![0u8; stream.period_size * stream.capture.frame_size].into_boxed_slice(); diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 9297bfee7..8898e3b4a 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -10,6 +10,7 @@ extern crate libc; use std::{ mem, sync::{Arc, Mutex}, + time::Duration, }; pub use self::device::Device; @@ -94,7 +95,7 @@ static ALSA_CONTEXT_COUNT: Mutex = Mutex::new(0); /// ALSA backend context shared between `Host`, `Device`, and `Stream` via `Arc`. #[derive(Debug)] -pub(super) struct AlsaContext; +struct AlsaContext; impl AlsaContext { fn new() -> Result { @@ -174,6 +175,15 @@ const DEFAULT_PERIODS: alsa::pcm::Frames = 2; const POLL_INFINITE: i32 = -1; // "block until an event arrives" const TRIGGER_PAYLOAD_SIZE: libc::ssize_t = mem::size_of::() as libc::ssize_t; +// Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll), +// but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested. +fn poll_timeout_millis(timeout: Option) -> i32 { + match timeout { + Some(d) => d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32, + None => POLL_INFINITE, + } +} + // Some ALSA plugins (e.g. alsaequal, certain USB drivers) are not reentrant. static ALSA_OPEN_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/src/host/alsa/stream.rs b/src/host/alsa/stream.rs index 86266b1cb..fce1e78de 100644 --- a/src/host/alsa/stream.rs +++ b/src/host/alsa/stream.rs @@ -209,11 +209,7 @@ impl Stream { } else { self.park_worker(); let result = if inner.handle.state() == alsa::pcm::State::Running { - inner - .handle - .drop() - .and_then(|_| inner.handle.prepare()) - .map_err(Error::from) + discard_buffer(&inner.handle) } else { Ok(()) }; @@ -227,11 +223,7 @@ impl Stream { fn discard_pcm(&self, inner: &StreamInner) -> Result<(), Error> { self.park_worker(); let result = if inner.handle.state() != alsa::pcm::State::Setup { - inner - .handle - .drop() - .and_then(|_| inner.handle.prepare()) - .map_err(Error::from) + discard_buffer(&inner.handle) } else { Ok(()) }; @@ -263,22 +255,12 @@ impl Stream { self.park_worker(); let capture_result = if inner.capture.handle.state() == alsa::pcm::State::Running { - inner - .capture - .handle - .drop() - .and_then(|_| inner.capture.handle.prepare()) - .map_err(Error::from) + discard_buffer(&inner.capture.handle) } else { Ok(()) }; let playback_result = if inner.playback.handle.state() == alsa::pcm::State::Running { - inner - .playback - .handle - .drop() - .and_then(|_| inner.playback.handle.prepare()) - .map_err(Error::from) + discard_buffer(&inner.playback.handle) } else { Ok(()) }; @@ -301,12 +283,7 @@ impl Stream { inner.capture.handle.unlink().ok(); // best-effort } let capture_result = if inner.capture.handle.state() != alsa::pcm::State::Setup { - inner - .capture - .handle - .drop() - .and_then(|_| inner.capture.handle.prepare()) - .map_err(Error::from) + discard_buffer(&inner.capture.handle) } else { Ok(()) }; @@ -333,6 +310,14 @@ impl Stream { } } +// Discards buffered audio: drop() halts the PCM (-> Setup) and prepare() re-arms it (-> Prepared). +fn discard_buffer(handle: &alsa::pcm::PCM) -> Result<(), Error> { + handle + .drop() + .and_then(|_| handle.prepare()) + .map_err(Error::from) +} + // Drains a parked output PCM: caller holds exclusive access via park_worker()/unpark_worker(). fn drain_pcm(handle: &alsa::pcm::PCM, timeout: Option) -> Result<(), Error> { if timeout == Some(Duration::ZERO) { @@ -563,7 +548,7 @@ impl EquilibriumFill { // A zero get_htstamp() at prepare time indicates the device does not support hardware // timestamps (e.g. PulseAudio ALSA plugin). Related: // https://bugs.freedesktop.org/show_bug.cgi?id=88503 -pub(super) fn timestamp_mode_for( +fn timestamp_mode_for( hw_params: &alsa::pcm::HwParams<'_>, creation_ts: alsa::timespec, ) -> TimestampMode { @@ -576,6 +561,15 @@ pub(super) fn timestamp_mode_for( } } +// Derives a stream's timestamp anchor and mode from its handle at prepare() time. +pub(super) fn creation_timestamp( + handle: &alsa::pcm::PCM, + hw_params: alsa::pcm::HwParams<'_>, +) -> Result<(alsa::timespec, TimestampMode), Error> { + let creation_ts = handle.status()?.get_htstamp(); + Ok((creation_ts, timestamp_mode_for(&hw_params, creation_ts))) +} + // How callback timestamps are produced. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TimestampMode { @@ -612,7 +606,7 @@ pub(super) struct WorkerControl { impl WorkerControl { // Pauses the worker at its next loop iteration and waits for acknowledgment, or returns // early if it already exited. Caller holds exclusive PCM access until unpark_worker(). - pub(super) fn park_worker(&self) { + fn park_worker(&self) { self.parked.store(true, Ordering::Relaxed); let (lock, cvar) = &self.park; let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); @@ -644,7 +638,7 @@ impl WorkerControl { } // Releases the park: clears parked and wakes the worker from acknowledge_park(). - pub(super) fn unpark_worker(&self) { + fn unpark_worker(&self) { let (lock, cvar) = &self.park; let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); *guard = false; diff --git a/src/host/alsa/timestamp.rs b/src/host/alsa/timestamp.rs index 80bf27949..59dfc4957 100644 --- a/src/host/alsa/timestamp.rs +++ b/src/host/alsa/timestamp.rs @@ -51,6 +51,7 @@ pub(super) fn callback_instant_for( } } +#[inline] pub(super) fn status_with_timestamp( handle: &alsa::pcm::PCM, mode: TimestampMode, diff --git a/src/host/alsa/trigger.rs b/src/host/alsa/trigger.rs index c58f11e41..92d9f6731 100644 --- a/src/host/alsa/trigger.rs +++ b/src/host/alsa/trigger.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::TRIGGER_PAYLOAD_SIZE; #[derive(Debug)] -pub(super) struct TriggerSender(pub(super) libc::c_int); +pub(super) struct TriggerSender(libc::c_int); #[derive(Debug)] pub(super) struct TriggerReceiver(pub(super) libc::c_int); diff --git a/src/host/alsa/worker.rs b/src/host/alsa/worker.rs index bb07b954a..150368e8c 100644 --- a/src/host/alsa/worker.rs +++ b/src/host/alsa/worker.rs @@ -4,7 +4,7 @@ use std::{ }; use super::{ - POLL_INFINITE, alsa, alsa::poll::Descriptors, stream::StreamInner, + alsa, alsa::poll::Descriptors, poll_timeout_millis, stream::StreamInner, timestamp::status_with_timestamp, trigger::TriggerReceiver, }; use crate::{ @@ -129,13 +129,7 @@ struct StreamWorkerContext { impl StreamWorkerContext { fn new(poll_timeout: &Option, stream: &StreamInner, rx: &TriggerReceiver) -> Self { - let poll_timeout: i32 = if let Some(d) = poll_timeout { - // Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll), - // but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested. - d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 - } else { - POLL_INFINITE - }; + let poll_timeout = poll_timeout_millis(*poll_timeout); // Pre-allocate a period-sized working buffer. Contents are overwritten each callback. let transfer_buffer = vec![0u8; stream.period_size * stream.frame_size].into_boxed_slice(); From 503d346dec9114db582c0b7f0afcfddc810ca88f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 Aug 2026 22:54:20 +0200 Subject: [PATCH 4/5] doc(alsa): add duplex device configuration --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e7bf06599..c8c2e2a59 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,12 @@ If you are unable to build the library: - Verify you have installed the required development libraries, as documented above - **ASIO on Windows:** Verify `CPAL_ASIO_DIR` and `LIBCLANG_PATH` are set and LLVM is installed +## Duplex Devices + +cpal doesn't compose devices itself; it needs a device that already claims both capture and playback. + +On ALSA, that needs no extra setup if capture and playback are on the same `hw:`/`plughw:` device (most built-in and USB audio). If they are on separate cards, combine them into one named PCM with ALSA's `asym` plugin in `~/.asoundrc` or `/etc/asound.conf`, then address that name as the device. + ## Examples CPAL comes with several examples in `examples/`. From c52ce363fe741cab21c709d0a59471a46e528629 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 Aug 2026 23:31:30 +0200 Subject: [PATCH 5/5] refactor(alsa): alias callback types --- src/host/alsa/duplex.rs | 8 ++++---- src/host/alsa/stream.rs | 6 ++++++ src/host/alsa/worker.rs | 20 ++++++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/host/alsa/duplex.rs b/src/host/alsa/duplex.rs index 1d2ea332b..ee8a24a8e 100644 --- a/src/host/alsa/duplex.rs +++ b/src/host/alsa/duplex.rs @@ -7,7 +7,7 @@ use super::{ DEFAULT_PERIODS, alsa, alsa::poll::Descriptors, poll_timeout_millis, - stream::DuplexStreamInner, + stream::{DuplexDataCallback, DuplexStreamInner, ErrorCallback}, timestamp::{callback_instant_for, status_with_timestamp}, trigger::TriggerReceiver, }; @@ -52,8 +52,8 @@ pub(super) fn start_duplex(stream: &DuplexStreamInner) -> Result<(), Error> { pub(super) fn duplex_stream_worker( rx: Arc, stream: &DuplexStreamInner, - data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), + data_callback: &mut DuplexDataCallback, + error_callback: &mut ErrorCallback, timeout: Option, ) { #[cfg(feature = "realtime")] @@ -345,7 +345,7 @@ fn process_duplex( playback_status: alsa::pcm::Status, capture_delay: usize, playback_delay: usize, - data_callback: &mut (dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static), + data_callback: &mut DuplexDataCallback, ) -> Result<(), Error> { let mut frames_read = 0; while frames_read < stream.period_size { diff --git a/src/host/alsa/stream.rs b/src/host/alsa/stream.rs index fce1e78de..bcba11e87 100644 --- a/src/host/alsa/stream.rs +++ b/src/host/alsa/stream.rs @@ -26,6 +26,12 @@ use crate::{ traits::StreamTrait, }; +pub(super) type InputDataCallback = dyn FnMut(&Data, &CallbackInfo) + Send + 'static; +pub(super) type OutputDataCallback = dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static; +pub(super) type DuplexDataCallback = + dyn FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static; +pub(super) type ErrorCallback = dyn FnMut(Error) + Send + 'static; + #[derive(Debug)] pub struct Stream { /// The high-priority audio processing thread calling callbacks. diff --git a/src/host/alsa/worker.rs b/src/host/alsa/worker.rs index 150368e8c..e35795c7b 100644 --- a/src/host/alsa/worker.rs +++ b/src/host/alsa/worker.rs @@ -4,8 +4,12 @@ use std::{ }; use super::{ - alsa, alsa::poll::Descriptors, poll_timeout_millis, stream::StreamInner, - timestamp::status_with_timestamp, trigger::TriggerReceiver, + alsa, + alsa::poll::Descriptors, + poll_timeout_millis, + stream::{ErrorCallback, InputDataCallback, OutputDataCallback, StreamInner}, + timestamp::status_with_timestamp, + trigger::TriggerReceiver, }; use crate::{ CallbackInfo, Data, Error, ErrorKind, FrameCount, StreamInstant, StreamTimestamp, @@ -15,8 +19,8 @@ use crate::{ pub(super) fn input_stream_worker( rx: Arc, stream: &StreamInner, - data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), + data_callback: &mut InputDataCallback, + error_callback: &mut ErrorCallback, timeout: Option, ) { #[cfg(feature = "realtime")] @@ -69,8 +73,8 @@ pub(super) fn input_stream_worker( pub(super) fn output_stream_worker( rx: Arc, stream: &StreamInner, - data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), - error_callback: &mut (dyn FnMut(Error) + Send + 'static), + data_callback: &mut OutputDataCallback, + error_callback: &mut ErrorCallback, timeout: Option, ) { #[cfg(feature = "realtime")] @@ -326,7 +330,7 @@ fn process_input( buffer: &mut [u8], status: alsa::pcm::Status, delay_frames: usize, - data_callback: &mut (dyn FnMut(&Data, &CallbackInfo) + Send + 'static), + data_callback: &mut InputDataCallback, ) -> Result<(), Error> { let mut frames_read = 0; while frames_read < stream.period_size { @@ -389,7 +393,7 @@ fn process_output( buffer: &mut [u8], status: alsa::pcm::Status, delay_frames: usize, - data_callback: &mut (dyn FnMut(&mut Data, &CallbackInfo) + Send + 'static), + data_callback: &mut OutputDataCallback, ) -> Result<(), Error> { // Pre-fill buffer with equilibrium; user callback overwrites what it wants. stream