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:
3 files changed
+62
-48
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user