93 files changed
+6794
-261
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user