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.
|
/// Target loudness in LUFS for normalization.
|
||||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||||
pub normalization_target_lufs: f32,
|
pub normalization_target_lufs: f32,
|
||||||
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
|
/// When true, consecutive same-format tracks play without gap.
|
||||||
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
|
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||||
pub gapless_enabled: bool,
|
pub gapless_enabled: bool,
|
||||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
/// 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.
|
/// 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)
|
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
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
|
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
|
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
|
// Bit-perfect has priority: reject gapless if format changes
|
||||||
if engine.is_alsa_direct() {
|
// (different sample rate/channels requires stream recreation)
|
||||||
log::info!("Gapless: ALSA Direct backend, cannot queue — ignoring PlayNext for track {}", track_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify format compatibility (same sample rate and channels)
|
|
||||||
if let (Some(cur_sr), Some(cur_ch)) =
|
if let (Some(cur_sr), Some(cur_ch)) =
|
||||||
(*current_sample_rate, *current_channels)
|
(*current_sample_rate, *current_channels)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,10 +9,14 @@
|
|||||||
use crate::audio::AlsaDirectStream;
|
use crate::audio::AlsaDirectStream;
|
||||||
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
|
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Type-erased source iterator for ALSA Direct gapless queueing
|
||||||
|
type BoxedSourceIter = Box<dyn Iterator<Item = f32> + Send>;
|
||||||
|
|
||||||
/// Unified playback engine
|
/// Unified playback engine
|
||||||
pub enum PlaybackEngine {
|
pub enum PlaybackEngine {
|
||||||
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
|
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
|
||||||
@@ -26,6 +30,8 @@ pub enum PlaybackEngine {
|
|||||||
duration_frames: Arc<AtomicU64>,
|
duration_frames: Arc<AtomicU64>,
|
||||||
playback_thread: Option<thread::JoinHandle<()>>,
|
playback_thread: Option<thread::JoinHandle<()>>,
|
||||||
hardware_volume: bool, // Use ALSA mixer for volume control
|
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)),
|
duration_frames: Arc::new(AtomicU64::new(0)),
|
||||||
playback_thread: None,
|
playback_thread: None,
|
||||||
hardware_volume,
|
hardware_volume,
|
||||||
|
next_source_tx: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +75,22 @@ impl PlaybackEngine {
|
|||||||
duration_frames,
|
duration_frames,
|
||||||
playback_thread,
|
playback_thread,
|
||||||
hardware_volume: _,
|
hardware_volume: _,
|
||||||
|
next_source_tx,
|
||||||
} => {
|
} => {
|
||||||
// For ALSA Direct, we need to spawn a thread that:
|
// If playback thread is already running, send the source for gapless queueing
|
||||||
// 1. Streams f32 samples from source (no buffering entire file)
|
if playback_thread.is_some() {
|
||||||
// 2. Writes to ALSA PCM (converts f32 to hardware format)
|
if let Some(ref tx) = next_source_tx {
|
||||||
// 3. Tracks position
|
let boxed: BoxedSourceIter = Box::new(source.into_iter());
|
||||||
// 4. Supports pause/resume without terminating
|
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 stream_clone = stream.clone();
|
||||||
let is_playing_clone = is_playing.clone();
|
let is_playing_clone = is_playing.clone();
|
||||||
@@ -87,34 +104,28 @@ impl PlaybackEngine {
|
|||||||
should_stop.store(false, Ordering::SeqCst);
|
should_stop.store(false, Ordering::SeqCst);
|
||||||
position_clone.store(0, 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 || {
|
let handle = thread::spawn(move || {
|
||||||
// Stream samples in chunks (no pre-buffering entire file)
|
const CHUNK_SIZE: usize = 8192;
|
||||||
const CHUNK_SIZE: usize = 8192; // frames per chunk
|
|
||||||
let chunk_samples = CHUNK_SIZE * channels as usize;
|
let chunk_samples = CHUNK_SIZE * channels as usize;
|
||||||
|
|
||||||
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
|
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
|
||||||
|
|
||||||
let mut total_frames: u64 = 0;
|
let mut total_frames: u64 = 0;
|
||||||
let mut source_iter = source.into_iter();
|
let mut source_iter: BoxedSourceIter = initial_source;
|
||||||
let mut natural_end = false;
|
|
||||||
|
|
||||||
'playback: loop {
|
'playback: loop {
|
||||||
// Check if we should stop completely (not just pause)
|
|
||||||
if should_stop_clone.load(Ordering::SeqCst) {
|
if should_stop_clone.load(Ordering::SeqCst) {
|
||||||
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
|
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
|
||||||
break 'playback;
|
break 'playback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if paused - wait instead of terminating
|
|
||||||
while !is_playing_clone.load(Ordering::SeqCst) {
|
while !is_playing_clone.load(Ordering::SeqCst) {
|
||||||
// Still check for stop while paused
|
|
||||||
if should_stop_clone.load(Ordering::SeqCst) {
|
if should_stop_clone.load(Ordering::SeqCst) {
|
||||||
log::info!("[ALSA Direct Engine] Stop requested while paused");
|
log::info!("[ALSA Direct Engine] Stop requested while paused");
|
||||||
break 'playback;
|
break 'playback;
|
||||||
}
|
}
|
||||||
// Sleep briefly to avoid busy-waiting
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,45 +134,50 @@ impl PlaybackEngine {
|
|||||||
for _ in 0..chunk_samples {
|
for _ in 0..chunk_samples {
|
||||||
match source_iter.next() {
|
match source_iter.next() {
|
||||||
Some(sample) => buffer_f32.push(sample),
|
Some(sample) => buffer_f32.push(sample),
|
||||||
None => break, // End of stream
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if buffer_f32.is_empty() {
|
if buffer_f32.is_empty() {
|
||||||
// End of stream
|
// Current source ended — check for queued next source (gapless)
|
||||||
log::info!(
|
match rx.try_recv() {
|
||||||
"[ALSA Direct Engine] Stream ended (total frames: {})",
|
Ok(next) => {
|
||||||
total_frames
|
log::info!(
|
||||||
);
|
"[ALSA Direct Engine] Gapless transition (total frames: {})",
|
||||||
natural_end = true;
|
total_frames
|
||||||
break 'playback;
|
);
|
||||||
|
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) {
|
if let Err(e) = stream_clone.write_f32(&buffer_f32) {
|
||||||
log::error!("[ALSA Direct Engine] Write failed: {}", e);
|
log::error!("[ALSA Direct Engine] Write failed: {}", e);
|
||||||
break 'playback;
|
break 'playback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update position
|
|
||||||
let frames_written = buffer_f32.len() / channels as usize;
|
let frames_written = buffer_f32.len() / channels as usize;
|
||||||
total_frames += frames_written as u64;
|
total_frames += frames_written as u64;
|
||||||
position_clone.store(total_frames, Ordering::SeqCst);
|
position_clone.store(total_frames, Ordering::SeqCst);
|
||||||
duration_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);
|
is_playing_clone.store(false, Ordering::SeqCst);
|
||||||
log::info!("[ALSA Direct Engine] Playback thread finished");
|
log::info!("[ALSA Direct Engine] Playback thread finished");
|
||||||
});
|
});
|
||||||
@@ -205,9 +221,12 @@ impl PlaybackEngine {
|
|||||||
is_playing,
|
is_playing,
|
||||||
should_stop,
|
should_stop,
|
||||||
playback_thread,
|
playback_thread,
|
||||||
|
next_source_tx,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
log::info!("[ALSA Direct Engine] Stop requested");
|
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
|
// Signal thread to stop completely
|
||||||
should_stop.store(true, Ordering::SeqCst);
|
should_stop.store(true, Ordering::SeqCst);
|
||||||
is_playing.store(false, Ordering::SeqCst);
|
is_playing.store(false, Ordering::SeqCst);
|
||||||
|
|||||||
Reference in new issue
Block a user