Skip to content

Commit 4687a67

Browse files
authored
fix(alsa): default buffering behavior on PipeWire-ALSA (#1033)
* For BufferSize::Default, constrain both period and buffer to enforce double-buffering (2 periods), preventing pathologically large periods with PipeWire-ALSA while respecting the device's period preference. * Adjust avail_min by direction: for playback wake when level drops to one period (buffer - period); for capture wake when one period is available (period) to prevent excessive capture latency. * Revert to v0.16 behavior: always terminate stream without attempting state-based drain or wait. Remove buffer-duration calculation and snd_pcm_wait usage to avoid delays during drop. * Introduce init_hw_params and a TryFrom<SampleFormat> implementation to centralize format and hw param initialization. * Make ALSA frame casts explicit for future type changes.
1 parent 7f876b1 commit 4687a67

File tree

1 file changed

+137
-108
lines changed

1 file changed

+137
-108
lines changed

src/host/alsa/mod.rs

Lines changed: 137 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ use crate::{
4949
//
5050
// This mirrors the behavior documented in the cpal API where `BufferSize::Fixed(x)`
5151
// requests but does not guarantee a specific callback size.
52+
//
53+
// ## BufferSize::Default Behavior
54+
//
55+
// When `BufferSize::Default` is specified, cpal does NOT set explicit period size or
56+
// period count constraints, allowing the device/driver to choose sensible defaults.
57+
//
58+
// **Why not set defaults?** Different audio systems have different behaviors:
59+
//
60+
// - **Native ALSA hardware**: Typically chooses reasonable defaults (e.g., 512-2048
61+
// frame periods with 2-4 periods)
62+
//
63+
// - **PipeWire-ALSA plugin**: Allocates a large ring buffer (~1M frames at 48kHz) but
64+
// uses small periods (512-1024 frames). Critically, if you request `set_periods(2)`
65+
// without specifying period size, PipeWire calculates period = buffer/2, resulting
66+
// in pathologically large periods (~524K frames = 10 seconds). See issues #1029 and
67+
// #1036.
68+
//
69+
// By not constraining period configuration, PipeWire-ALSA can use its optimized defaults
70+
// (small periods with many-period buffer), while native ALSA hardware uses its own defaults.
71+
//
72+
// **Startup latency**: Regardless of buffer size, cpal uses double-buffering for startup
73+
// (start_threshold = 2 periods), ensuring low latency even with large multi-period ring
74+
// buffers.
5275

5376
pub type SupportedInputConfigs = VecIntoIter<SupportedStreamConfigRange>;
5477
pub type SupportedOutputConfigs = VecIntoIter<SupportedStreamConfigRange>;
@@ -743,13 +766,6 @@ fn output_stream_worker(
743766

744767
let mut ctxt = StreamWorkerContext::new(&timeout, stream, &rx);
745768

746-
// As first period, always write one buffer with equilibrium values.
747-
// This ensures we start with a full period of silence, giving the user their
748-
// requested latency while avoiding underruns on the first callback.
749-
if let Err(err) = stream.channel.io_bytes().writei(&ctxt.transfer_buffer) {
750-
error_callback(err.into());
751-
}
752-
753769
loop {
754770
let flow =
755771
poll_descriptors_and_prepare_buffer(&rx, stream, &mut ctxt).unwrap_or_else(|err| {
@@ -877,7 +893,10 @@ fn poll_descriptors_and_prepare_buffer(
877893
};
878894
let available_samples = avail_frames * stream.conf.channels as usize;
879895

880-
// Only go on if there is at least one period's worth of space available.
896+
// ALSA can have spurious wakeups where poll returns but avail < avail_min.
897+
// This is documented to occur with dmix (timer-driven) and other plugins.
898+
// Verify we have room for at least one full period before processing.
899+
// See: https://bugzilla.kernel.org/show_bug.cgi?id=202499
881900
if available_samples < stream.period_samples {
882901
return Ok(PollDescriptorsFlow::Continue);
883902
}
@@ -1117,33 +1136,6 @@ impl Drop for Stream {
11171136
self.inner.dropping.set(true);
11181137
self.trigger.wakeup();
11191138
self.thread.take().unwrap().join().unwrap();
1120-
1121-
// State-based drop behavior: drain if playing, drop if paused. This allows audio to
1122-
// complete naturally when stopping during playback, but provides immediate termination
1123-
// when already paused.
1124-
match self.inner.channel.state() {
1125-
alsa::pcm::State::Running => {
1126-
// Audio is actively playing - attempt graceful drain.
1127-
if let Ok(()) = self.inner.channel.drain() {
1128-
// TODO: Use SND_PCM_WAIT_DRAIN (-10002) when alsa-rs supports it properly,
1129-
// although it requires ALSA 1.2.8+ which may not be available everywhere.
1130-
// For now, calculate timeout based on buffer latency.
1131-
let buffer_duration_ms = ((self.inner.period_frames as f64 * 1000.0)
1132-
/ self.inner.conf.sample_rate.0 as f64)
1133-
as u32;
1134-
1135-
// This is safe: snd_pcm_wait() checks device state first and returns
1136-
// immediately with error codes like -ENODEV for disconnected devices.
1137-
let _ = self.inner.channel.wait(Some(buffer_duration_ms));
1138-
}
1139-
// If drain fails or device has errors, stream terminates naturally
1140-
}
1141-
_ => {
1142-
// Not actively playing (paused, stopped, etc.) - immediate drop and discard any
1143-
// buffered audio data for immediate termination.
1144-
let _ = self.inner.channel.drop();
1145-
}
1146-
}
11471139
}
11481140
}
11491141

@@ -1158,9 +1150,9 @@ impl StreamTrait for Stream {
11581150
}
11591151
}
11601152

1161-
// Overly safe clamp because alsa Frames are i64
1153+
// Overly safe clamp because alsa Frames are i64 (64-bit) or i32 (32-bit)
11621154
fn clamp_frame_count(buffer_size: alsa::pcm::Frames) -> FrameCount {
1163-
buffer_size.clamp(1, FrameCount::MAX as _) as _
1155+
buffer_size.clamp(1, FrameCount::MAX as alsa::pcm::Frames) as FrameCount
11641156
}
11651157

11661158
fn hw_params_buffer_size_min_max(hw_params: &alsa::pcm::HwParams) -> (FrameCount, FrameCount) {
@@ -1224,85 +1216,55 @@ fn fill_with_equilibrium(buffer: &mut [u8], sample_format: SampleFormat) {
12241216
}
12251217
}
12261218

1227-
fn set_hw_params_from_format(
1228-
pcm_handle: &alsa::pcm::PCM,
1219+
fn init_hw_params<'a>(
1220+
pcm_handle: &'a alsa::pcm::PCM,
12291221
config: &StreamConfig,
12301222
sample_format: SampleFormat,
1231-
) -> Result<bool, BackendSpecificError> {
1223+
) -> Result<alsa::pcm::HwParams<'a>, BackendSpecificError> {
12321224
let hw_params = alsa::pcm::HwParams::any(pcm_handle)?;
12331225
hw_params.set_access(alsa::pcm::Access::RWInterleaved)?;
1234-
1235-
let sample_format = if cfg!(target_endian = "big") {
1236-
match sample_format {
1237-
SampleFormat::I8 => alsa::pcm::Format::S8,
1238-
SampleFormat::I16 => alsa::pcm::Format::S16BE,
1239-
SampleFormat::I24 => alsa::pcm::Format::S24BE,
1240-
SampleFormat::I32 => alsa::pcm::Format::S32BE,
1241-
// SampleFormat::I48 => alsa::pcm::Format::S48BE,
1242-
// SampleFormat::I64 => alsa::pcm::Format::S64BE,
1243-
SampleFormat::U8 => alsa::pcm::Format::U8,
1244-
SampleFormat::U16 => alsa::pcm::Format::U16BE,
1245-
SampleFormat::U24 => alsa::pcm::Format::U24BE,
1246-
SampleFormat::U32 => alsa::pcm::Format::U32BE,
1247-
// SampleFormat::U48 => alsa::pcm::Format::U48BE,
1248-
// SampleFormat::U64 => alsa::pcm::Format::U64BE,
1249-
SampleFormat::F32 => alsa::pcm::Format::FloatBE,
1250-
SampleFormat::F64 => alsa::pcm::Format::Float64BE,
1251-
sample_format => {
1252-
return Err(BackendSpecificError {
1253-
description: format!(
1254-
"Sample format '{sample_format}' is not supported by this backend"
1255-
),
1256-
})
1257-
}
1258-
}
1259-
} else {
1260-
match sample_format {
1261-
SampleFormat::I8 => alsa::pcm::Format::S8,
1262-
SampleFormat::I16 => alsa::pcm::Format::S16LE,
1263-
SampleFormat::I24 => alsa::pcm::Format::S24LE,
1264-
SampleFormat::I32 => alsa::pcm::Format::S32LE,
1265-
// SampleFormat::I48 => alsa::pcm::Format::S48LE,
1266-
// SampleFormat::I64 => alsa::pcm::Format::S64LE,
1267-
SampleFormat::U8 => alsa::pcm::Format::U8,
1268-
SampleFormat::U16 => alsa::pcm::Format::U16LE,
1269-
SampleFormat::U24 => alsa::pcm::Format::U24LE,
1270-
SampleFormat::U32 => alsa::pcm::Format::U32LE,
1271-
// SampleFormat::U48 => alsa::pcm::Format::U48LE,
1272-
// SampleFormat::U64 => alsa::pcm::Format::U64LE,
1273-
SampleFormat::F32 => alsa::pcm::Format::FloatLE,
1274-
SampleFormat::F64 => alsa::pcm::Format::Float64LE,
1275-
sample_format => {
1276-
return Err(BackendSpecificError {
1277-
description: format!(
1278-
"Sample format '{sample_format}' is not supported by this backend"
1279-
),
1280-
})
1281-
}
1282-
}
1283-
};
1284-
1285-
// Set the sample format, rate, and channels - if this fails, the format is not supported.
1286-
hw_params.set_format(sample_format)?;
1226+
hw_params.set_format(sample_format.try_into()?)?;
12871227
hw_params.set_rate(config.sample_rate.0, alsa::ValueOr::Nearest)?;
12881228
hw_params.set_channels(config.channels as u32)?;
1229+
Ok(hw_params)
1230+
}
12891231

1290-
// Configure period size based on buffer size request
1291-
// When BufferSize::Fixed(x) is specified, we request a period size of x frames
1292-
// to achieve approximately x-sized callbacks. ALSA may adjust this to the nearest
1293-
// supported value based on hardware constraints.
1232+
fn set_hw_params_from_format(
1233+
pcm_handle: &alsa::pcm::PCM,
1234+
config: &StreamConfig,
1235+
sample_format: SampleFormat,
1236+
) -> Result<bool, BackendSpecificError> {
1237+
let hw_params = init_hw_params(pcm_handle, config, sample_format)?;
1238+
1239+
// When BufferSize::Fixed(x) is specified, we configure double-buffering with
1240+
// buffer_size = 2x and period_size = x. This provides consistent low-latency
1241+
// behavior across different ALSA implementations and hardware.
12941242
if let BufferSize::Fixed(buffer_frames) = config.buffer_size {
1295-
hw_params.set_period_size_near(buffer_frames as _, alsa::ValueOr::Nearest)?;
1243+
hw_params.set_buffer_size_near((2 * buffer_frames) as alsa::pcm::Frames)?;
1244+
hw_params
1245+
.set_period_size_near(buffer_frames as alsa::pcm::Frames, alsa::ValueOr::Nearest)?;
12961246
}
12971247

1298-
// We shouldn't fail if the driver isn't happy here.
1299-
// `default` pcm sometimes fails here, but there's no reason to as we
1300-
// provide a direction and 2 is strictly the minimum number of periods.
1301-
let _ = hw_params.set_periods(2, alsa::ValueOr::Greater);
1302-
13031248
// Apply hardware parameters
13041249
pcm_handle.hw_params(&hw_params)?;
13051250

1251+
// For BufferSize::Default, constrain to device's configured period with 2-period buffering.
1252+
// PipeWire-ALSA picks a good period size but pairs it with many periods (huge buffer).
1253+
// We need to re-initialize hw_params and set BOTH period and buffer to constrain properly.
1254+
if config.buffer_size == BufferSize::Default {
1255+
if let Ok(period) = hw_params.get_period_size() {
1256+
// Re-initialize hw_params to clear previous constraints
1257+
let hw_params = init_hw_params(pcm_handle, config, sample_format)?;
1258+
1259+
// Set both period (to device's chosen value) and buffer (to 2 periods)
1260+
hw_params.set_period_size_near(period, alsa::ValueOr::Nearest)?;
1261+
hw_params.set_buffer_size_near(2 * period)?;
1262+
1263+
// Re-apply with new constraints
1264+
pcm_handle.hw_params(&hw_params)?;
1265+
}
1266+
}
1267+
13061268
Ok(hw_params.can_pause())
13071269
}
13081270

@@ -1320,18 +1282,35 @@ fn set_sw_params_from_format(
13201282
description: "initialization resulted in a null buffer".to_string(),
13211283
});
13221284
}
1323-
sw_params.set_avail_min(period as alsa::pcm::Frames)?;
1324-
13251285
let start_threshold = match stream_type {
13261286
alsa::Direction::Playback => {
1327-
// Start when ALSA buffer has enough data to maintain consistent playback
1328-
// while preserving user's expected latency across different period counts
1329-
buffer - period
1287+
// Always use 2-period double-buffering: one period playing from hardware, one
1288+
// period queued in the software buffer. This ensures consistent low latency
1289+
// regardless of the total buffer size.
1290+
2 * period
13301291
}
13311292
alsa::Direction::Capture => 1,
13321293
};
13331294
sw_params.set_start_threshold(start_threshold.try_into().unwrap())?;
13341295

1296+
// Set avail_min based on stream direction. For playback, "avail" means space available
1297+
// for writing (buffer_size - frames_queued). For capture, "avail" means data available
1298+
// for reading (frames_captured). These opposite semantics require different values.
1299+
let target_avail = match stream_type {
1300+
alsa::Direction::Playback => {
1301+
// Wake when buffer level drops to one period remaining (avail >= buffer - period).
1302+
// This maintains double-buffering by refilling when we're down to one period.
1303+
buffer - period
1304+
}
1305+
alsa::Direction::Capture => {
1306+
// Wake when one period of data is available to read (avail >= period).
1307+
// Using buffer - period here would cause excessive latency as capture would
1308+
// wait for nearly the entire buffer to fill before reading.
1309+
period
1310+
}
1311+
};
1312+
sw_params.set_avail_min(target_avail as alsa::pcm::Frames)?;
1313+
13351314
period as usize * config.channels as usize
13361315
};
13371316

@@ -1349,6 +1328,56 @@ fn set_sw_params_from_format(
13491328
Ok(period_samples)
13501329
}
13511330

1331+
impl TryFrom<SampleFormat> for alsa::pcm::Format {
1332+
type Error = BackendSpecificError;
1333+
1334+
#[cfg(target_endian = "big")]
1335+
fn try_from(sample_format: SampleFormat) -> Result<Self, Self::Error> {
1336+
Ok(match sample_format {
1337+
SampleFormat::I8 => alsa::pcm::Format::S8,
1338+
SampleFormat::I16 => alsa::pcm::Format::S16BE,
1339+
SampleFormat::I24 => alsa::pcm::Format::S24BE,
1340+
SampleFormat::I32 => alsa::pcm::Format::S32BE,
1341+
SampleFormat::U8 => alsa::pcm::Format::U8,
1342+
SampleFormat::U16 => alsa::pcm::Format::U16BE,
1343+
SampleFormat::U24 => alsa::pcm::Format::U24BE,
1344+
SampleFormat::U32 => alsa::pcm::Format::U32BE,
1345+
SampleFormat::F32 => alsa::pcm::Format::FloatBE,
1346+
SampleFormat::F64 => alsa::pcm::Format::Float64BE,
1347+
sample_format => {
1348+
return Err(BackendSpecificError {
1349+
description: format!(
1350+
"Sample format '{sample_format}' is not supported by this backend"
1351+
),
1352+
})
1353+
}
1354+
})
1355+
}
1356+
1357+
#[cfg(target_endian = "little")]
1358+
fn try_from(sample_format: SampleFormat) -> Result<Self, Self::Error> {
1359+
Ok(match sample_format {
1360+
SampleFormat::I8 => alsa::pcm::Format::S8,
1361+
SampleFormat::I16 => alsa::pcm::Format::S16LE,
1362+
SampleFormat::I24 => alsa::pcm::Format::S24LE,
1363+
SampleFormat::I32 => alsa::pcm::Format::S32LE,
1364+
SampleFormat::U8 => alsa::pcm::Format::U8,
1365+
SampleFormat::U16 => alsa::pcm::Format::U16LE,
1366+
SampleFormat::U24 => alsa::pcm::Format::U24LE,
1367+
SampleFormat::U32 => alsa::pcm::Format::U32LE,
1368+
SampleFormat::F32 => alsa::pcm::Format::FloatLE,
1369+
SampleFormat::F64 => alsa::pcm::Format::Float64LE,
1370+
sample_format => {
1371+
return Err(BackendSpecificError {
1372+
description: format!(
1373+
"Sample format '{sample_format}' is not supported by this backend"
1374+
),
1375+
})
1376+
}
1377+
})
1378+
}
1379+
}
1380+
13521381
impl From<alsa::Error> for BackendSpecificError {
13531382
fn from(err: alsa::Error) -> Self {
13541383
BackendSpecificError {

0 commit comments

Comments
 (0)