feat(audio): enable gapless playback on ALSA Direct and default ON

ALSA Direct playback thread now supports gapless via mpsc channel:
when current source ends, it checks for a queued next source and
transitions seamlessly without draining the PCM buffer.

Format mismatch still blocks gapless (bit-perfect has priority).
Gapless now defaults to enabled for all backends.
This commit is contained in:
vicrodh committed 2026-03-05 11:45:32 -06:00
1 parent f1e2c99b5b
commit 01fc32e611
3 files changed
+62 -48

No files matched your search

+3 -3
View File
@@ -41,8 +41,8 @@ pub struct AudioSettings {
/// Target loudness in LUFS for normalization.
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
pub normalization_target_lufs: f32,
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
/// When true, consecutive same-format tracks play without gap.
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
pub gapless_enabled: bool,
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
@@ -71,7 +71,7 @@ impl Default for AudioSettings {
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
normalization_target_lufs: -14.0, // Spotify/YouTube standard
gapless_enabled: false, // Off by default — user opts in
gapless_enabled: true, // On by default — works for same-format tracks on all backends
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
}
+2 -7
View File
@@ -2159,13 +2159,8 @@ impl Player {
}
};
// Only Rodio supports sink queueing
if engine.is_alsa_direct() {
log::info!("Gapless: ALSA Direct backend, cannot queue — ignoring PlayNext for track {}", track_id);
return;
}
// Verify format compatibility (same sample rate and channels)
// Bit-perfect has priority: reject gapless if format changes
// (different sample rate/channels requires stream recreation)
if let (Some(cur_sr), Some(cur_ch)) =
(*current_sample_rate, *current_channels)
{
+57 -38
View File
@@ -9,10 +9,14 @@
use crate::audio::AlsaDirectStream;
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Type-erased source iterator for ALSA Direct gapless queueing
type BoxedSourceIter = Box<dyn Iterator<Item = f32> + Send>;
/// Unified playback engine
pub enum PlaybackEngine {
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
@@ -26,6 +30,8 @@ pub enum PlaybackEngine {
duration_frames: Arc<AtomicU64>,
playback_thread: Option<thread::JoinHandle<()>>,
hardware_volume: bool, // Use ALSA mixer for volume control
/// Channel to send next source for gapless playback
next_source_tx: Option<mpsc::SyncSender<BoxedSourceIter>>,
},
}
@@ -47,6 +53,7 @@ impl PlaybackEngine {
duration_frames: Arc::new(AtomicU64::new(0)),
playback_thread: None,
hardware_volume,
next_source_tx: None,
}
}
@@ -68,12 +75,22 @@ impl PlaybackEngine {
duration_frames,
playback_thread,
hardware_volume: _,
next_source_tx,
} => {
// For ALSA Direct, we need to spawn a thread that:
// 1. Streams f32 samples from source (no buffering entire file)
// 2. Writes to ALSA PCM (converts f32 to hardware format)
// 3. Tracks position
// 4. Supports pause/resume without terminating
// If playback thread is already running, send the source for gapless queueing
if playback_thread.is_some() {
if let Some(ref tx) = next_source_tx {
let boxed: BoxedSourceIter = Box::new(source.into_iter());
return tx.try_send(boxed).map_err(|_| {
"ALSA Direct gapless: next source channel full or closed".to_string()
});
}
return Err("ALSA Direct: playback thread running but no gapless channel".to_string());
}
// First source — spawn the playback thread with a gapless channel
let (tx, rx) = mpsc::sync_channel::<BoxedSourceIter>(1);
*next_source_tx = Some(tx);
let stream_clone = stream.clone();
let is_playing_clone = is_playing.clone();
@@ -87,34 +104,28 @@ impl PlaybackEngine {
should_stop.store(false, Ordering::SeqCst);
position_clone.store(0, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Starting streaming playback thread");
log::info!("[ALSA Direct Engine] Starting streaming playback thread (gapless-capable)");
let initial_source: BoxedSourceIter = Box::new(source.into_iter());
let handle = thread::spawn(move || {
// Stream samples in chunks (no pre-buffering entire file)
const CHUNK_SIZE: usize = 8192; // frames per chunk
const CHUNK_SIZE: usize = 8192;
let chunk_samples = CHUNK_SIZE * channels as usize;
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
let mut total_frames: u64 = 0;
let mut source_iter = source.into_iter();
let mut natural_end = false;
let mut source_iter: BoxedSourceIter = initial_source;
'playback: loop {
// Check if we should stop completely (not just pause)
if should_stop_clone.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
break 'playback;
}
// Check if paused - wait instead of terminating
while !is_playing_clone.load(Ordering::SeqCst) {
// Still check for stop while paused
if should_stop_clone.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop requested while paused");
break 'playback;
}
// Sleep briefly to avoid busy-waiting
std::thread::sleep(Duration::from_millis(50));
}
@@ -123,45 +134,50 @@ impl PlaybackEngine {
for _ in 0..chunk_samples {
match source_iter.next() {
Some(sample) => buffer_f32.push(sample),
None => break, // End of stream
None => break,
}
}
if buffer_f32.is_empty() {
// End of stream
log::info!(
"[ALSA Direct Engine] Stream ended (total frames: {})",
total_frames
);
natural_end = true;
break 'playback;
// Current source ended — check for queued next source (gapless)
match rx.try_recv() {
Ok(next) => {
log::info!(
"[ALSA Direct Engine] Gapless transition (total frames: {})",
total_frames
);
source_iter = next;
total_frames = 0;
position_clone.store(0, Ordering::SeqCst);
duration_clone.store(0, Ordering::SeqCst);
continue 'playback;
}
Err(_) => {
// No next source — natural end
log::info!(
"[ALSA Direct Engine] Stream ended (total frames: {})",
total_frames
);
log::info!("[ALSA Direct Engine] Song ended naturally, draining buffer");
if let Err(e) = stream_clone.drain() {
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
}
break 'playback;
}
}
}
// Write to ALSA (auto-converts based on detected format)
// This is bit-perfect: no resampling, no mixing, direct to hardware
if let Err(e) = stream_clone.write_f32(&buffer_f32) {
log::error!("[ALSA Direct Engine] Write failed: {}", e);
break 'playback;
}
// Update position
let frames_written = buffer_f32.len() / channels as usize;
total_frames += frames_written as u64;
position_clone.store(total_frames, Ordering::SeqCst);
duration_clone.store(total_frames, Ordering::SeqCst);
}
// Only drain if song ended naturally (not skipped/stopped)
// This prevents 2-5s delay when rapidly changing tracks
if natural_end {
log::info!("[ALSA Direct Engine] Song ended naturally, draining buffer");
if let Err(e) = stream_clone.drain() {
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
}
} else {
log::info!("[ALSA Direct Engine] Playback interrupted, skipping drain for faster response");
}
is_playing_clone.store(false, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Playback thread finished");
});
@@ -205,9 +221,12 @@ impl PlaybackEngine {
is_playing,
should_stop,
playback_thread,
next_source_tx,
..
} => {
log::info!("[ALSA Direct Engine] Stop requested");
// Drop the gapless channel so the playback thread won't wait for next source
drop(next_source_tx);
// Signal thread to stop completely
should_stop.store(true, Ordering::SeqCst);
is_playing.store(false, Ordering::SeqCst);