merge: integrate Windows port from PR #725

cx
This commit is contained in:
vicrodh committed 2026-08-28 04:18:06 -06:00
commit 912638c387
93 files changed
+6794 -261

No files matched your search

+100 -22
View File
@@ -563,8 +563,14 @@ enum StreamType {
#[cfg(target_os = "macos")]
exclusive_guard: Option<qbz_audio::CoreAudioExclusiveGuard>,
},
#[cfg(target_os = "linux")]
AlsaDirect(Arc<qbz_audio::AlsaDirectStream>),
/// Any bit-perfect DIRECT sink: ALSA hw: on Linux, WASAPI exclusive on
/// Windows. One variant rather than one per platform, because everything
/// downstream treats them identically - see `qbz_audio::backend::DirectSink`.
///
/// NOT cfg-gated: gating it to Linux is what made the Windows compiler
/// unable to check any of the arms that handle it, which is the whole
/// blind spot this port has to work around.
Direct(Arc<dyn qbz_audio::backend::DirectSink>),
/// Native JACK output (#263 Tier 3). QBZ as a JACK client with stable ports;
/// NOT bit-perfect (resampled to the graph rate).
#[cfg(target_os = "linux")]
@@ -900,6 +906,57 @@ fn try_init_stream_with_backend(
skip_sink_switch: audio_settings.skip_sink_switch,
};
// WASAPI EXCLUSIVE (Windows) - the bit-perfect path, tried before the
// shared CPAL stream exactly as ALSA Direct is on Linux.
//
// The device id is the WASAPI ENDPOINT ID, which is what the enumeration
// already publishes: Windows hands several endpoints the same friendly
// name, so the name cannot identify one. Without a chosen device there is
// nothing to open exclusively - falling through to shared is the honest
// answer, not picking the default DAC behind the user's back.
#[cfg(windows)]
if backend_type == AudioBackendType::WasapiExclusive {
match config.device_id.as_ref() {
None => {
log::info!(
"[WASAPI] Exclusive mode selected with no device chosen; using the shared stream. Pick an output device to get bit-perfect playback."
);
}
Some(endpoint_id) => {
use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming};
match WasapiDirectStream::new(
endpoint_id,
sample_rate,
config.channels,
WasapiTiming::Events,
) {
Ok(stream) => {
let mode = stream.bit_perfect_mode();
let info = stream.open_info();
log::info!(
"[WASAPI] Exclusive stream open: {} @ {} Hz, {:?}, mode {:?}",
info.endpoint_name,
info.rate,
info.rung,
mode
);
state.set_bit_perfect_mode(Some(mode));
return Some(Ok(StreamType::Direct(Arc::new(stream))));
}
Err(e) => {
// NOT fatal: a device that refuses this rate in
// exclusive mode still plays through the shared path,
// and silence would be a worse answer than a
// resampled track.
log::warn!(
"[WASAPI] Exclusive open failed ({e}); falling back to the shared stream"
);
}
}
}
}
}
// For ALSA backend with hw: devices, try direct ALSA first (Linux only)
#[cfg(target_os = "linux")]
if backend_type == AudioBackendType::Alsa {
@@ -917,7 +974,7 @@ fn try_init_stream_with_backend(
return Some(result.map(|(stream, mode)| {
log::info!("ALSA Direct stream created with mode: {:?}", mode);
state.set_bit_perfect_mode(Some(mode));
StreamType::AlsaDirect(Arc::new(stream))
StreamType::Direct(Arc::new(stream))
}));
}
}
@@ -2246,8 +2303,7 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
let hardware_volume = thread_settings
@@ -2255,7 +2311,7 @@ impl Player {
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -2695,14 +2751,13 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -3042,7 +3097,7 @@ impl Player {
carrier,
device
);
*stream_opt = Some(StreamType::AlsaDirect(stream.clone()));
*stream_opt = Some(StreamType::Direct(stream.clone()));
thread_state.set_current_device(Some(device));
*current_track_sample_rate = Some(carrier);
*current_track_channels = Some(2);
@@ -3164,7 +3219,7 @@ impl Player {
if little_endian { "LE" } else { "BE" },
device
);
*stream_opt = Some(StreamType::AlsaDirect(stream.clone()));
*stream_opt = Some(StreamType::Direct(stream.clone()));
thread_state.set_current_device(Some(device));
*current_track_sample_rate = Some(rate);
*current_track_channels = Some(2);
@@ -3394,19 +3449,18 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
)
}
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
)
}
#[cfg(target_os = "linux")]
StreamType::Jack(jack_stream) => {
PlaybackEngine::new_jack(jack_stream.clone())
@@ -3645,14 +3699,13 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -5697,6 +5750,31 @@ impl Player {
});
}
let carrier = qbz_dsd::dop_carrier_rate(info.dsd_rate);
// Ask the platform that owns the device. On Windows the
// ALSA probe cannot answer, so this guard was taking its
// `.unwrap_or(true)` and dispatching DoP on a DEFAULT
// rather than on a measurement -- on a DAC that refuses
// 176400, which is the DSD64 carrier.
// WINDOWS: never. Not "probe and decide" -- the DoP
// handler below is `cfg(target_os = "linux")` and answers
// "DoP playback is Linux-only" with a stream error, so
// dispatching here can only ever fail. Converting to PCM
// is the one outcome that plays the track.
//
// It is also FAIL-CLOSED, which this guard has to be. An
// undecoded DoP stream is read as ordinary PCM carrying
// packed DSD and marker bytes: it comes out as very loud
// broadband noise, which threatens tweeters and hearing.
// An earlier draft here fell back to `true` whenever the
// probe could not answer -- preserving exactly the guess
// this work existed to remove.
//
// `wasapi_backend::supported_rates` is what a future
// Windows DoP path would consult, and it already reports
// that the owner's DAC refuses 176400, the DSD64 carrier.
#[cfg(windows)]
let rate_ok = false;
#[cfg(not(windows))]
let rate_ok =
qbz_audio::alsa_backend::get_device_supported_rates(&device)
.map(|r| r.contains(&carrier))
+104 -68
View File
@@ -8,7 +8,9 @@
//! to enable gapless playback. When one source ends, the next is picked up
//! seamlessly without interrupting the PCM stream.
use qbz_audio::{AlsaDirectStream, VisualizerTap};
#[cfg(target_os = "linux")]
use qbz_audio::AlsaDirectStream;
use qbz_audio::VisualizerTap;
#[cfg(target_os = "linux")]
use qbz_audio::JackStream;
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
@@ -74,9 +76,14 @@ impl<S> SourceQueue<S> {
pub enum PlaybackEngine {
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
Rodio { sink: RodioPlayer },
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
AlsaDirect {
stream: Arc<AlsaDirectStream>,
/// A bit-perfect DIRECT sink with a gapless source queue: ALSA hw: on
/// Linux, WASAPI exclusive on Windows. The two are interchangeable here -
/// the writer thread only ever calls `DirectSink`.
Direct {
stream: Arc<dyn qbz_audio::backend::DirectSink>,
/// Log prefix for THIS sink. Without it `play`/`pause`/`stop`/
/// `set_volume` printed "ALSA Direct Engine" for a WASAPI stream.
label: &'static str,
is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
@@ -127,13 +134,16 @@ impl PlaybackEngine {
Ok(Self::Rodio { sink })
}
/// Create ALSA Direct engine with gapless source queue.
/// Create a bit-perfect direct engine with a gapless source queue.
/// Spawns a single writer thread that lives for the engine's lifetime.
pub fn new_alsa_direct(
stream: Arc<AlsaDirectStream>,
/// Takes ANY direct sink. The log prefix comes from the SINK, so no call
/// site can mislabel it.
pub fn new_direct(
stream: Arc<dyn qbz_audio::backend::DirectSink>,
hardware_volume: bool,
visualizer_tap: Option<VisualizerTap>,
) -> Self {
let label = stream.log_label();
let is_playing = Arc::new(AtomicBool::new(false));
let should_stop = Arc::new(AtomicBool::new(false));
let position_frames = Arc::new(AtomicU64::new(0));
@@ -154,7 +164,7 @@ impl PlaybackEngine {
let visualizer_c = visualizer_tap.clone();
thread::spawn(move || {
alsa_writer_thread(
direct_writer_thread(
stream_c,
playing_c,
stop_c,
@@ -163,13 +173,15 @@ impl PlaybackEngine {
queue_c,
transition_c,
channels,
label,
visualizer_c,
);
})
};
Self::AlsaDirect {
Self::Direct {
stream,
label,
is_playing,
should_stop,
position_frames,
@@ -308,7 +320,8 @@ impl PlaybackEngine {
sink.append(source);
Ok(())
}
Self::AlsaDirect {
Self::Direct {
label,
is_playing,
should_stop,
position_frames,
@@ -328,9 +341,9 @@ impl PlaybackEngine {
should_stop.store(false, Ordering::SeqCst);
source_transition.store(false, Ordering::SeqCst);
is_playing.store(true, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] First source queued, playback starting");
log::info!("[{label}] First source queued, playback starting");
} else {
log::info!("[ALSA Direct Engine] Source queued for gapless transition");
log::info!("[{label}] Source queued for gapless transition");
}
Ok(())
@@ -377,8 +390,10 @@ impl PlaybackEngine {
pub fn play(&self) {
match self {
Self::Rodio { sink } => sink.play(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Resume requested");
Self::Direct {
label, is_playing, ..
} => {
log::info!("[{label}] Resume requested");
is_playing.store(true, Ordering::SeqCst);
}
#[cfg(target_os = "linux")]
@@ -398,8 +413,10 @@ impl PlaybackEngine {
pub fn pause(&self) {
match self {
Self::Rodio { sink } => sink.pause(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Pause requested");
Self::Direct {
label, is_playing, ..
} => {
log::info!("[{label}] Pause requested");
is_playing.store(false, Ordering::SeqCst);
}
#[cfg(target_os = "linux")]
@@ -430,8 +447,9 @@ impl PlaybackEngine {
Self::Rodio { sink } => {
sink.stop();
}
Self::AlsaDirect {
Self::Direct {
stream,
label,
is_playing,
should_stop,
playback_thread,
@@ -440,7 +458,7 @@ impl PlaybackEngine {
if should_stop.load(Ordering::SeqCst) {
return; // Already stopped
}
log::info!("[ALSA Direct Engine] Stop requested");
log::info!("[{label}] Stop requested");
should_stop.store(true, Ordering::SeqCst);
is_playing.store(false, Ordering::SeqCst);
@@ -449,7 +467,7 @@ impl PlaybackEngine {
}
if let Err(e) = stream.stop() {
log::warn!("[ALSA Direct Engine] Stop failed: {}", e);
log::warn!("[{label}] Stop failed: {}", e);
}
}
#[cfg(target_os = "linux")]
@@ -498,8 +516,9 @@ impl PlaybackEngine {
pub fn set_volume(&self, volume: f32) {
match self {
Self::Rodio { sink } => sink.set_volume(volume),
Self::AlsaDirect {
Self::Direct {
stream,
label,
hardware_volume,
..
} => {
@@ -507,12 +526,24 @@ impl PlaybackEngine {
#[cfg(target_os = "linux")]
{
if let Err(e) = stream.set_hardware_volume(volume) {
log::warn!("[ALSA Direct Engine] Hardware volume failed: {}", e);
log::warn!("[{label}] Hardware volume failed: {}", e);
}
}
// Additive arm. `set_hardware_volume` is a DirectSink
// method now, so a Windows sink can answer for itself
// instead of the setting silently doing nothing; a sink
// with no hardware volume returns Err and says so. macOS
// and the other targets keep their previous silence --
// CoreAudio has its own path (set_coreaudio_hardware_volume).
#[cfg(windows)]
{
if let Err(e) = stream.set_hardware_volume(volume) {
log::warn!("[{label}] Hardware volume failed: {}", e);
}
}
} else {
log::debug!(
"[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"
"[{label}] Hardware volume control disabled (use DAC/amplifier)"
);
}
}
@@ -535,7 +566,7 @@ impl PlaybackEngine {
pub fn empty(&self) -> bool {
match self {
Self::Rodio { sink } => sink.empty(),
Self::AlsaDirect {
Self::Direct {
is_playing,
source_queue,
..
@@ -560,7 +591,7 @@ impl PlaybackEngine {
pub fn take_source_transition(&self) -> bool {
match self {
Self::Rodio { .. } => false,
Self::AlsaDirect {
Self::Direct {
source_transition, ..
} => source_transition
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
@@ -585,7 +616,7 @@ impl PlaybackEngine {
pub fn position_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect {
Self::Direct {
position_frames,
stream,
..
@@ -620,7 +651,7 @@ impl PlaybackEngine {
pub fn duration_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect {
Self::Direct {
duration_frames,
stream,
..
@@ -645,37 +676,41 @@ impl PlaybackEngine {
/// Check if using ALSA Direct engine
#[allow(dead_code)]
pub fn is_alsa_direct(&self) -> bool {
matches!(self, Self::AlsaDirect { .. })
pub fn is_direct(&self) -> bool {
matches!(self, Self::Direct { .. })
}
}
/// Single long-lived writer thread for ALSA Direct.
/// Single long-lived writer thread for any bit-perfect direct sink.
///
/// Continuously reads samples from the current source and writes to ALSA.
/// Continuously reads samples from the current source and writes to the sink.
/// When a source ends, seamlessly picks up the next one from the queue
/// (gapless transition). If no next source is available, drains the ALSA
/// buffer and waits for the next source or a stop signal.
///
/// The source is consumed in ~10 ms pieces instead of the historical fixed
/// 8192-frame blocks. This does not change PCM data or ALSA parameters; it
/// 8192-frame blocks. This does not change PCM data or device parameters; it
/// only keeps the passive visualizer tap from receiving ~186 ms bursts at
/// 44.1 kHz. `snd_pcm_delay` then points snapshots behind the queued PCM at
/// the part currently reaching the DAC.
const ALSA_PCM_FEED_TARGET_MS: u64 = 10;
const ALSA_PCM_FEED_MIN_FRAMES: u64 = 64;
const ALSA_PCM_FEED_MAX_FRAMES: u64 = 8192;
/// 44.1 kHz. Sinks that can measure their queued frames then point snapshots
/// behind the submitted PCM at the part currently reaching the DAC.
///
/// Generic rather than duplicated: ALSA and WASAPI need byte-identical
/// behaviour here — same pacing, gapless hand-off and drain — and two copies
/// of this state machine would drift.
const DIRECT_PCM_FEED_TARGET_MS: u64 = 10;
const DIRECT_PCM_FEED_MIN_FRAMES: u64 = 64;
const DIRECT_PCM_FEED_MAX_FRAMES: u64 = 8192;
fn alsa_pcm_feed_frames(sample_rate: u32) -> usize {
fn direct_pcm_feed_frames(sample_rate: u32) -> usize {
let frames = u64::from(sample_rate)
.saturating_mul(ALSA_PCM_FEED_TARGET_MS)
.saturating_mul(DIRECT_PCM_FEED_TARGET_MS)
.div_ceil(1000)
.clamp(ALSA_PCM_FEED_MIN_FRAMES, ALSA_PCM_FEED_MAX_FRAMES);
.clamp(DIRECT_PCM_FEED_MIN_FRAMES, DIRECT_PCM_FEED_MAX_FRAMES);
frames as usize
}
fn alsa_writer_thread(
stream: Arc<AlsaDirectStream>,
fn direct_writer_thread<S: qbz_audio::backend::DirectSink + ?Sized>(
stream: Arc<S>,
is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
@@ -683,9 +718,10 @@ fn alsa_writer_thread(
source_queue: Arc<SourceQueue<BoxedSampleIter>>,
source_transition: Arc<AtomicBool>,
channels: u16,
label: &'static str,
visualizer_tap: Option<VisualizerTap>,
) {
let chunk_frames = alsa_pcm_feed_frames(stream.sample_rate());
let chunk_frames = direct_pcm_feed_frames(stream.sample_rate());
let chunk_samples = chunk_frames * channels as usize;
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
let mut current_source: Option<BoxedSampleIter> = None;
@@ -698,15 +734,15 @@ fn alsa_writer_thread(
tap.clear_output_delay();
}
log::info!(
"[ALSA Direct Engine] Writer thread started (gapless-capable, viz feed {} frames / ~{} ms)",
"[{label}] Writer thread started (gapless-capable, viz feed {} frames / ~{} ms)",
chunk_frames,
ALSA_PCM_FEED_TARGET_MS
DIRECT_PCM_FEED_TARGET_MS
);
'thread: loop {
// Check global stop
if should_stop.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop signal, writer thread exiting");
log::info!("[{label}] Stop signal, writer thread exiting");
break 'thread;
}
@@ -718,7 +754,7 @@ fn alsa_writer_thread(
current_source = Some(src);
total_frames = 0;
position_frames.store(0, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Acquired new source from queue");
log::info!("[{label}] Acquired new source from queue");
}
None => {
// No source available, loop back to check stop
@@ -736,7 +772,7 @@ fn alsa_writer_thread(
}
// Keep the passive tap at the previous audible position while this
// next piece is pulled rapidly from the source. Once ALSA accepts it,
// next piece is pulled rapidly from the source. Once the sink accepts it,
// the delay query below advances the tap by exactly what the device
// consumed. Shared/callback backends leave this offset at zero.
let visualizer_enabled = visualizer_tap
@@ -750,7 +786,7 @@ fn alsa_writer_thread(
Err(error) => {
if !delay_error_reported {
log::warn!(
"[ALSA Direct Engine] Visualizer delay query failed; using paced feed without playhead compensation: {}",
"[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}",
error
);
delay_error_reported = true;
@@ -797,10 +833,10 @@ fn alsa_writer_thread(
}
}
// Write whatever we have to ALSA (even partial chunks on source end)
// Write whatever we have to the direct sink (even partial chunks on source end)
if !buffer_f32.is_empty() {
if let Err(e) = stream.write_f32(&buffer_f32) {
log::error!("[ALSA Direct Engine] Write failed: {}", e);
log::error!("[{label}] Write failed: {}", e);
break 'thread;
}
@@ -819,7 +855,7 @@ fn alsa_writer_thread(
Err(error) => {
if !delay_error_reported {
log::warn!(
"[ALSA Direct Engine] Visualizer delay query failed; using paced feed without playhead compensation: {}",
"[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}",
error
);
delay_error_reported = true;
@@ -835,14 +871,14 @@ fn alsa_writer_thread(
if source_ended {
log::info!(
"[ALSA Direct Engine] Source ended (total frames: {})",
"[{label}] Source ended (total frames: {})",
total_frames
);
// Try to get next source immediately (gapless transition)
match source_queue.try_pop() {
Some(next_src) => {
log::info!("[ALSA Direct Engine] Gapless transition to next source");
log::info!("[{label}] Gapless transition to next source");
current_source = Some(next_src);
total_frames = 0;
position_frames.store(0, Ordering::SeqCst);
@@ -852,9 +888,9 @@ fn alsa_writer_thread(
}
None => {
// No next source — this is a natural end of playback
log::info!("[ALSA Direct Engine] No next source, draining ALSA buffer");
log::info!("[{label}] No next source, draining the device buffer");
if let Err(e) = stream.drain() {
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
log::warn!("[{label}] Drain failed: {}", e);
}
current_source = None;
is_playing.store(false, Ordering::SeqCst);
@@ -868,12 +904,12 @@ fn alsa_writer_thread(
if let Some(tap) = visualizer_tap.as_ref() {
tap.clear_output_delay();
}
log::info!("[ALSA Direct Engine] Writer thread finished");
log::info!("[{label}] Writer thread finished");
}
/// Single long-lived feeder thread for JACK (#263 Tier 3).
///
/// Mirrors `alsa_writer_thread`, but writes graph-rate interleaved STEREO f32
/// Mirrors `direct_writer_thread`, but writes graph-rate interleaved STEREO f32
/// into the JACK client's lock-free ring buffer via `JackStream::write_f32`
/// (the RT process callback drains it), pacing itself when the ring is full.
/// Sources are resampled to the graph rate + stereo at `append` time.
@@ -976,7 +1012,7 @@ impl Drop for PlaybackEngine {
/// Single long-lived writer thread for DoP (DSD over PCM).
///
/// Mirrors `alsa_writer_thread`'s shape: pulls pre-packed S32 DoP words from
/// Mirrors `direct_writer_thread`'s shape: pulls pre-packed S32 DoP words from
/// the current source, writes them VERBATIM, and picks up the next queued
/// source seamlessly (gapless DSD). Differences forced by the format:
/// - pause writes 0x69 DSD silence (with valid alternating markers) instead
@@ -1090,20 +1126,20 @@ fn dop_writer_thread(
#[cfg(test)]
mod tests {
use super::alsa_pcm_feed_frames;
use super::direct_pcm_feed_frames;
#[test]
fn alsa_pcm_feed_stays_near_ten_ms_across_pcm_rates() {
assert_eq!(alsa_pcm_feed_frames(44_100), 441);
assert_eq!(alsa_pcm_feed_frames(48_000), 480);
assert_eq!(alsa_pcm_feed_frames(96_000), 960);
assert_eq!(alsa_pcm_feed_frames(192_000), 1_920);
assert_eq!(alsa_pcm_feed_frames(768_000), 7_680);
fn direct_pcm_feed_stays_near_ten_ms_across_pcm_rates() {
assert_eq!(direct_pcm_feed_frames(44_100), 441);
assert_eq!(direct_pcm_feed_frames(48_000), 480);
assert_eq!(direct_pcm_feed_frames(96_000), 960);
assert_eq!(direct_pcm_feed_frames(192_000), 1_920);
assert_eq!(direct_pcm_feed_frames(768_000), 7_680);
}
#[test]
fn alsa_pcm_feed_has_defensive_bounds() {
assert_eq!(alsa_pcm_feed_frames(0), 64);
assert_eq!(alsa_pcm_feed_frames(u32::MAX), 8_192);
fn direct_pcm_feed_has_defensive_bounds() {
assert_eq!(direct_pcm_feed_frames(0), 64);
assert_eq!(direct_pcm_feed_frames(u32::MAX), 8_192);
}
}