feat(dsd): support seeking in direct playback

Add random access for DSF, DFF, and SACD demuxers, replace live DoP/native sources without releasing ALSA, and expose seek through Qt, MPRIS, and the daemon API.

cx
This commit is contained in:
vicrodh committed 2026-09-03 02:32:52 -06:00
1 parent ea92f07b66
commit 8aab112afe
20 files changed
+397 -77

No files matched your search

+20 -6
View File
@@ -403,6 +403,7 @@ pub enum FrameSync {
pub struct SacdTrackReader {
iso: IsoImage,
start_lsn: u64,
next_lsn: u64,
end_lsn: u64,
/// Set once the first frame boundary has been found; until then audio
@@ -417,14 +418,12 @@ impl SacdTrackReader {
Self::open_with(path, track, FrameSync::ToFirstFrame)
}
pub fn open_with(
path: &Path,
track: &SacdTrack,
sync: FrameSync,
) -> Result<Self, SacdError> {
pub fn open_with(path: &Path, track: &SacdTrack, sync: FrameSync) -> Result<Self, SacdError> {
let start_lsn = track.start_lsn as u64;
Ok(Self {
iso: IsoImage::open(path)?,
next_lsn: track.start_lsn as u64,
start_lsn,
next_lsn: start_lsn,
// `length_lsn` is how many sectors to read, including the shared
// boundary sector — the disc's own accounting, not ours.
end_lsn: track.start_lsn as u64 + track.length_lsn as u64,
@@ -436,6 +435,21 @@ impl SacdTrackReader {
self.next_lsn >= self.end_lsn
}
/// Reposition proportionally inside the track and re-acquire the next
/// declared DSD frame boundary. Mapping against the TOC's sector span is
/// important: 75 Hz is the DSD audio-frame cadence, not the ISO sector
/// cadence (several sectors contribute to each audio frame).
pub fn seek_to_fraction(&mut self, offset_units: u64, total_units: u64) {
let span = self.end_lsn - self.start_lsn;
let sector_offset = if total_units == 0 {
0
} else {
((span as u128 * offset_units.min(total_units) as u128) / total_units as u128) as u64
};
self.next_lsn = self.start_lsn + sector_offset;
self.synced = false;
}
/// Append the next chunk of AUDIO payload to `out`, skipping every
/// non-audio packet — and, until the first frame boundary is found, every
/// audio byte too (see [`FrameSync`]). Returns bytes appended.
+47 -3
View File
@@ -73,6 +73,11 @@ impl DsdStreamInfo {
pub trait DsdDemuxer: Send {
fn info(&self) -> &DsdStreamInfo;
/// Reposition to a DSD bit offset per channel. Container alignment may
/// round the request down by less than one byte/frame; UI seeks are whole
/// seconds and therefore land exactly on byte boundaries at every
/// supported DSD rate.
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError>;
/// Append up to `max_bytes_per_ch` DSD bytes per channel to `out[ch]`
/// (planar). Returns the byte count appended to EACH channel (always
/// equal across channels); 0 means end of stream.
@@ -184,8 +189,12 @@ struct DsfReader {
file: File,
info: DsdStreamInfo,
block_size: usize,
data_start: u64,
total_bytes_per_ch: u64,
/// Valid (non-padding) DSD bytes remaining per channel.
remaining_per_ch: u64,
/// Bytes to discard from each channel's first physical block after seek.
first_block_skip: usize,
}
impl DsfReader {
@@ -261,10 +270,14 @@ impl DsfReader {
DsdTags::default()
};
let total_bytes_per_ch = sample_count.div_ceil(8);
Ok(Self {
file,
block_size: block_size as usize,
remaining_per_ch: sample_count.div_ceil(8),
data_start,
total_bytes_per_ch,
remaining_per_ch: total_bytes_per_ch,
first_block_skip: 0,
info: DsdStreamInfo {
dsd_rate: sampling_frequency,
channels: channel_num as u16,
@@ -281,6 +294,18 @@ impl DsdDemuxer for DsfReader {
&self.info
}
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> {
let target_byte = (bit_per_channel / 8).min(self.total_bytes_per_ch);
let block = target_byte / self.block_size as u64;
self.first_block_skip = (target_byte % self.block_size as u64) as usize;
let physical = self.data_start.saturating_add(
block.saturating_mul(self.block_size as u64 * self.info.channels as u64),
);
self.file.seek(SeekFrom::Start(physical))?;
self.remaining_per_ch = self.total_bytes_per_ch - target_byte;
Ok(())
}
fn read_planar(
&mut self,
out: &mut [Vec<u8>],
@@ -294,10 +319,13 @@ impl DsdDemuxer for DsfReader {
let mut block = vec![0u8; self.block_size];
while appended < max_bytes_per_ch && self.remaining_per_ch > 0 {
// One block group: block_size bytes for each channel in order.
let valid = (self.remaining_per_ch as usize).min(self.block_size);
let available = self.block_size - self.first_block_skip;
let valid = (self.remaining_per_ch as usize).min(available);
for ch in 0..self.info.channels as usize {
match self.file.read_exact(&mut block) {
Ok(()) => out[ch].extend_from_slice(&block[..valid]),
Ok(()) => out[ch].extend_from_slice(
&block[self.first_block_skip..self.first_block_skip + valid],
),
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
// Truncated file: stop at what we got.
self.remaining_per_ch = 0;
@@ -306,6 +334,7 @@ impl DsdDemuxer for DsfReader {
Err(e) => return Err(e.into()),
}
}
self.first_block_skip = 0;
self.remaining_per_ch -= valid as u64;
appended += valid;
}
@@ -320,6 +349,8 @@ impl DsdDemuxer for DsfReader {
struct DffReader {
file: File,
info: DsdStreamInfo,
data_offset: u64,
data_size: u64,
/// Bytes (all channels interleaved) remaining in the DSD data chunk.
remaining_total: u64,
}
@@ -425,6 +456,8 @@ impl DffReader {
Ok(Self {
file,
data_offset,
data_size,
remaining_total: data_size,
info: DsdStreamInfo {
dsd_rate,
@@ -442,6 +475,17 @@ impl DsdDemuxer for DffReader {
&self.info
}
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> {
let channels = self.info.channels as u64;
let total_per_channel = self.data_size / channels;
let target_per_channel = (bit_per_channel / 8).min(total_per_channel);
let target_total = target_per_channel.saturating_mul(channels);
self.file
.seek(SeekFrom::Start(self.data_offset + target_total))?;
self.remaining_total = self.data_size - target_total;
Ok(())
}
fn read_planar(
&mut self,
out: &mut [Vec<u8>],
+4
View File
@@ -218,6 +218,10 @@ mod io_error_tests {
fn info(&self) -> &DsdStreamInfo {
&self.info
}
fn seek_to_bit(&mut self, _bit_per_channel: u64) -> Result<(), DsdError> {
self.left = 1;
Ok(())
}
fn read_planar(
&mut self,
out: &mut [Vec<u8>],
+8
View File
@@ -174,6 +174,14 @@ impl DsdDemuxer for SacdDemuxer {
&self.info
}
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> {
let target = bit_per_channel.min(self.info.sample_count);
self.reader.seek_to_fraction(target, self.info.sample_count);
self.pending.clear();
self.done = self.reader.finished();
Ok(())
}
fn read_planar(
&mut self,
out: &mut [Vec<u8>],
+44 -1
View File
@@ -1,7 +1,7 @@
//! Integration tests over synthesized DSF/DFF files.
use qbz_dsd::{open_dsd, DsdError, DsdPcmConverter};
use std::io::Write;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
fn tmp(name: &str) -> PathBuf {
@@ -213,6 +213,49 @@ fn dff_parses_stereo() {
assert!(!info.lsb_first);
}
#[test]
fn dsf_seek_handles_channel_blocks_and_an_in_block_offset() {
const DATA_START: u64 = 28 + 52 + 12;
const BLOCK: u64 = 4096;
let path = write_dsf("seek-blocks.dsf", 2, 2_822_400, 2, None);
let mut file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
// Second block-group, byte 7 in each channel block.
file.seek(SeekFrom::Start(DATA_START + 2 * BLOCK + 7))
.unwrap();
file.write_all(&[0x12]).unwrap();
file.seek(SeekFrom::Start(DATA_START + 3 * BLOCK + 7))
.unwrap();
file.write_all(&[0x34]).unwrap();
let mut demux = open_dsd(&path).unwrap();
demux.seek_to_bit((BLOCK + 7) * 8).unwrap();
let mut planar = vec![Vec::new(), Vec::new()];
assert!(demux.read_planar(&mut planar, BLOCK as usize).unwrap() > 0);
assert_eq!(planar[0][0], 0x12);
assert_eq!(planar[1][0], 0x34);
}
#[test]
fn dff_seek_preserves_interleaved_channel_alignment() {
let path = write_dff("seek-interleaved.dff", 2, 2_822_400, 128, b"DSD ");
let bytes = std::fs::read(&path).unwrap();
let data_header = bytes
.windows(4)
.rposition(|window| window == b"DSD ")
.unwrap();
let data_start = data_header as u64 + 12;
let mut file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
file.seek(SeekFrom::Start(data_start + 34)).unwrap();
file.write_all(&[0x56, 0x78]).unwrap();
let mut demux = open_dsd(&path).unwrap();
demux.seek_to_bit(17 * 8).unwrap();
let mut planar = vec![Vec::new(), Vec::new()];
assert_eq!(demux.read_planar(&mut planar, 4).unwrap(), 4);
assert_eq!(planar[0][0], 0x56);
assert_eq!(planar[1][0], 0x78);
}
#[test]
fn dff_dst_rejected() {
let path = write_dff("dst.dff", 2, 2_822_400, 128, b"DST ");
@@ -4452,8 +4452,8 @@ msgstr "DSD-Datei kann nicht wiedergegeben werden"
msgid "DSD playback"
msgstr "DSD-Wiedergabe"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Wie DSD-Titel den DAC erreichen. WARNUNG: DoP oder Nativ nur wählen, wenn der DAC es unterstützt — auf jedem anderen DAC wird lautes Rauschen wiedergegeben. Lautstärke ist fest und Suchen ist im DoP/Nativ-Modus deaktiviert. Nativ erfordert zusätzlich Kernel-Unterstützung für den DAC."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Wie DSD-Titel den DAC erreichen. WARNUNG: DoP oder Nativ nur wählen, wenn der DAC es unterstützt — auf jedem anderen DAC wird lautes Rauschen wiedergegeben. Die Lautstärke ist im DoP/Nativ-Modus fest. Nativ erfordert zusätzlich Kernel-Unterstützung für den DAC."
msgid "Convert to PCM (works everywhere)"
msgstr "Zu PCM konvertieren (funktioniert überall)"
@@ -4488,8 +4488,8 @@ msgstr "No se puede reproducir el archivo DSD"
msgid "DSD playback"
msgstr "Reproducción DSD"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Cómo llegan las pistas DSD al DAC. ADVERTENCIA: elige DoP o Nativo solo si tu DAC lo soporta — en cualquier otro DAC se reproducen como ruido fuerte. El volumen queda fijo y la búsqueda se desactiva en modo DoP/Nativo. Nativo además requiere soporte del kernel para el DAC."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Cómo llegan las pistas DSD al DAC. ADVERTENCIA: elige DoP o Nativo solo si tu DAC lo soporta — en cualquier otro DAC se reproducen como ruido fuerte. El volumen queda fijo en modo DoP/Nativo. Nativo además requiere soporte del kernel para el DAC."
msgid "Convert to PCM (works everywhere)"
msgstr "Convertir a PCM (funciona en todos lados)"
@@ -4452,8 +4452,8 @@ msgstr "Impossible de lire le fichier DSD"
msgid "DSD playback"
msgstr "Lecture DSD"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Comment les pistes DSD atteignent le DAC. ATTENTION : ne choisissez DoP ou Natif que si votre DAC le prend en charge — sur tout autre DAC, cela produit un bruit fort. Le volume est fixe et la recherche est désactivée en mode DoP/Natif. Le mode Natif nécessite en plus la prise en charge du noyau pour le DAC."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Comment les pistes DSD atteignent le DAC. ATTENTION : ne choisissez DoP ou Natif que si votre DAC le prend en charge — sur tout autre DAC, cela produit un bruit fort. Le volume est fixe en mode DoP/Natif. Le mode Natif nécessite en plus la prise en charge du noyau pour le DAC."
msgid "Convert to PCM (works everywhere)"
msgstr "Convertir en PCM (fonctionne partout)"
@@ -4441,8 +4441,8 @@ msgstr "DSDファイルを再生できません"
msgid "DSD playback"
msgstr "DSD再生"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "DSDトラックをDACへ送る方法。警告:DACが対応している場合のみDoPまたはネイティブを選択してください。対応していないDACでは大きなノイズとして再生されます。DoP/ネイティブモード中は音量が固定され、シークは無効になります。ネイティブはさらにDAC用のカーネルサポートが必要です。"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "DSDトラックをDACへ送る方法。警告:DACが対応している場合のみDoPまたはネイティブを選択してください。対応していないDACでは大きなノイズとして再生されます。DoP/ネイティブモード中は音量が固定されます。ネイティブはさらにDAC用のカーネルサポートが必要です。"
msgid "Convert to PCM (works everywhere)"
msgstr "PCMに変換(すべての環境で動作)"
@@ -4465,8 +4465,8 @@ msgstr "Kan DSD-bestand niet afspelen"
msgid "DSD playback"
msgstr "DSD-weergave"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Hoe DSD-nummers de DAC bereiken. WAARSCHUWING: kies DoP of Native alleen als je DAC dit ondersteunt — op elke andere DAC klinken ze als harde ruis. Het volume staat vast en zoeken is uitgeschakeld in DoP/Native-modus. Native vereist bovendien kernelondersteuning voor de DAC."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Hoe DSD-nummers de DAC bereiken. WAARSCHUWING: kies DoP of Native alleen als je DAC dit ondersteunt — op elke andere DAC klinken ze als harde ruis. Het volume staat vast in DoP/Native-modus. Native vereist bovendien kernelondersteuning voor de DAC."
msgid "Convert to PCM (works everywhere)"
msgstr "Converteren naar PCM (werkt overal)"
@@ -4452,8 +4452,8 @@ msgstr "Não é possível reproduzir o ficheiro DSD"
msgid "DSD playback"
msgstr "Reprodução DSD"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Como as faixas DSD chegam ao DAC. AVISO: escolha DoP ou Nativo apenas se o seu DAC o suportar — em qualquer outro DAC reproduzem-se como ruído alto. O volume fica fixo e a procura é desativada no modo DoP/Nativo. O Nativo requer ainda suporte do kernel para o DAC."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Como as faixas DSD chegam ao DAC. AVISO: escolha DoP ou Nativo apenas se o seu DAC o suportar — em qualquer outro DAC reproduzem-se como ruído alto. O volume fica fixo no modo DoP/Nativo. O Nativo requer ainda suporte do kernel para o DAC."
msgid "Convert to PCM (works everywhere)"
msgstr "Converter para PCM (funciona em todo o lado)"
@@ -4468,8 +4468,8 @@ msgstr "Не удаётся воспроизвести файл DSD"
msgid "DSD playback"
msgstr "Воспроизведение DSD"
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Как DSD-треки попадают на ЦАП. ВНИМАНИЕ: выбирайте DoP или Native только если ваш ЦАП это поддерживает — на любом другом ЦАПе это воспроизводится как громкий шум. Громкость фиксирована, перемотка отключена в режиме DoP/Native. Native дополнительно требует поддержки ядра для ЦАПа."
msgid "How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC."
msgstr "Как DSD-треки попадают на ЦАП. ВНИМАНИЕ: выбирайте DoP или Native только если ваш ЦАП это поддерживает — на любом другом ЦАПе это воспроизводится как громкий шум. Громкость фиксирована в режиме DoP/Native. Native дополнительно требует поддержки ядра для ЦАПа."
msgid "Convert to PCM (works everywhere)"
msgstr "Конвертировать в PCM (работает везде)"
+177 -22
View File
@@ -129,21 +129,37 @@ enum AudioCommand {
/// Play a local DSD file via DoP (DSD over PCM) on ALSA direct (DSD plan
/// Phase 2). The audio thread opens the demuxer + an S32 stream at the
/// DoP carrier rate and feeds pre-packed words through the DoP engine.
PlayDsdDop { path: std::path::PathBuf, track_id: u64 },
PlayDsdDop {
path: std::path::PathBuf,
track_id: u64,
},
/// Play a local DSD file NATIVELY (ALSA DSD_U32, DSD plan Phase 3) —
/// requires the kernel to grant the device a DSD format (quirk table).
PlayDsdNative { path: std::path::PathBuf, track_id: u64 },
PlayDsdNative {
path: std::path::PathBuf,
track_id: u64,
},
/// Queue the next DSD track on the ACTIVE DoP engine (gapless DSD).
/// Ignored (with gapless_ready reset) when the engine isn't DoP or the
/// carrier rate differs — the normal track-end advance then handles it.
PlayNextDsdDop { path: std::path::PathBuf, track_id: u64 },
PlayNextDsdDop {
path: std::path::PathBuf,
track_id: u64,
},
}
/// Pending gapless track data (queued for seamless transition)
enum GaplessMedia {
Buffered(Vec<u8>),
Streaming(Arc<BufferedMediaSource>),
Direct,
Direct(DirectDsdMedia),
}
#[derive(Clone)]
struct DirectDsdMedia {
path: std::path::PathBuf,
/// 1 = DoP, 2 = native DSD_U32_BE, 3 = native DSD_U32_LE.
mode: u8,
}
struct GaplessPending {
@@ -2219,6 +2235,9 @@ impl Player {
let mut current_audio_data: Option<Vec<u8>> = None;
// Store streaming source for resume (when download completes, we can get the data)
let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None;
// Direct DSD bypasses rodio and therefore has no cached PCM/audio
// source to rebuild on seek. Keep the container identity instead.
let mut current_direct_dsd: Option<DirectDsdMedia> = None;
// Track consecutive sink creation failures to detect broken streams
let mut consecutive_sink_failures: u32 = 0;
const MAX_SINK_FAILURES: u32 = 3;
@@ -2248,6 +2267,7 @@ impl Player {
current_engine: &mut Option<PlaybackEngine>,
current_audio_data: &mut Option<Vec<u8>>,
current_streaming_source: &mut Option<Arc<BufferedMediaSource>>,
current_direct_dsd: &mut Option<DirectDsdMedia>,
stream_opt: &mut Option<StreamType>,
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
@@ -2282,11 +2302,15 @@ impl Player {
);
*pause_suspend_deadline = None;
thread_state.set_dsd_mode(0);
*current_direct_dsd = None;
// Clear any pending gapless state (new Play supersedes queued gapless)
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it.
log::info!("[gapless-trace] clear (Play) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id));
log::info!(
"[gapless-trace] clear (Play) pending was {:?}",
gapless_pending.as_ref().map(|p| p.track_id)
);
*gapless_pending = None;
*gapless_request_armed = false;
thread_state.set_gapless_ready(false);
@@ -2820,6 +2844,7 @@ impl Player {
start_position_secs
);
*pause_suspend_deadline = None;
*current_direct_dsd = None;
// Clear any pending gapless state — a new
// PlayStreaming supersedes queued gapless, exactly
// as `AudioCommand::Play` does above.
@@ -3438,10 +3463,14 @@ impl Player {
path.display()
);
*pause_suspend_deadline = None;
*current_direct_dsd = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it.
log::info!("[gapless-trace] clear (PlayDsdDop) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id));
log::info!(
"[gapless-trace] clear (PlayDsdDop) pending was {:?}",
gapless_pending.as_ref().map(|p| p.track_id)
);
*gapless_pending = None;
*gapless_request_armed = false;
thread_state.set_gapless_ready(false);
@@ -3526,6 +3555,10 @@ impl Player {
thread_state.set_stream_quality(dsd_rate, 1);
thread_state.duration.store(duration, Ordering::SeqCst);
thread_state.set_dsd_mode(1);
*current_direct_dsd = Some(DirectDsdMedia {
path: path.clone(),
mode: 1,
});
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state
@@ -3550,10 +3583,14 @@ impl Player {
path.display()
);
*pause_suspend_deadline = None;
*current_direct_dsd = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it.
log::info!("[gapless-trace] clear (PlayDsdNative) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id));
log::info!(
"[gapless-trace] clear (PlayDsdNative) pending was {:?}",
gapless_pending.as_ref().map(|p| p.track_id)
);
*gapless_pending = None;
*gapless_request_armed = false;
thread_state.set_gapless_ready(false);
@@ -3648,7 +3685,12 @@ impl Player {
thread_state.set_stream_error(false);
thread_state.set_stream_quality(info.dsd_rate, 1);
thread_state.duration.store(duration, Ordering::SeqCst);
thread_state.set_dsd_mode(if little_endian { 3 } else { 2 });
let direct_mode = if little_endian { 3 } else { 2 };
thread_state.set_dsd_mode(direct_mode);
*current_direct_dsd = Some(DirectDsdMedia {
path: path.clone(),
mode: direct_mode,
});
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state
@@ -3750,7 +3792,10 @@ impl Player {
track_id,
play_generation: thread_state.current_play_generation(),
duration_secs: duration,
media: GaplessMedia::Direct,
media: GaplessMedia::Direct(DirectDsdMedia {
path: path.clone(),
mode,
}),
sample_rate: rate,
channels: 2,
bit_depth: 1,
@@ -3954,12 +3999,16 @@ impl Player {
thread_state.set_hardware_volume_active(false);
*current_audio_data = None;
*current_streaming_source = None;
*current_direct_dsd = None;
*current_normalization_gain = None;
*current_gain_atomic = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it.
log::info!("[gapless-trace] clear (Stop) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id));
log::info!(
"[gapless-trace] clear (Stop) pending was {:?}",
gapless_pending.as_ref().map(|p| p.track_id)
);
*gapless_pending = None;
*gapless_request_armed = false;
thread_state.set_gapless_ready(false);
@@ -4011,23 +4060,123 @@ impl Player {
log::debug!("Audio thread: volume set to {}", volume);
}
AudioCommand::Seek(position_secs) => {
if current_engine.as_ref().map(|e| e.is_dop()).unwrap_or(false) {
// v1 limitation: no seek inside a DoP stream
// (demuxer-level seek + marker re-phase later).
log::info!("Seek ignored during DoP playback ({}s)", position_secs);
return;
}
*pause_suspend_deadline = None;
// Cancel any pending gapless — seek creates a new engine
// Seeking invalidates a queued successor in every
// engine, including direct DSD where the live
// source is replaced in place.
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it.
log::info!("[gapless-trace] clear (Seek) pending was {:?}", gapless_pending.as_ref().map(|p| p.track_id));
log::info!(
"[gapless-trace] clear (Seek) pending was {:?}",
gapless_pending.as_ref().map(|p| p.track_id)
);
*gapless_pending = None;
*gapless_request_armed = false;
thread_state.set_gapless_ready(false);
thread_state.set_gapless_next_track_id(0);
#[cfg(target_os = "linux")]
if current_engine.as_ref().map(|e| e.is_dop()).unwrap_or(false) {
let Some(media) = current_direct_dsd.as_ref().cloned() else {
log::error!(
"Direct DSD seek aborted: current container is unknown"
);
thread_state.set_stream_error(true);
return;
};
let target_secs = position_secs.min(thread_state.duration());
let mut demux = match qbz_dsd::open_dsd(&media.path) {
Ok(d) => d,
Err(e) => {
log::error!("Direct DSD seek: cannot reopen source: {e}");
thread_state.set_stream_error(true);
return;
}
};
let dsd_rate = demux.info().dsd_rate;
if let Err(e) =
demux.seek_to_bit(target_secs.saturating_mul(dsd_rate as u64))
{
log::error!("Direct DSD seek: demux seek failed: {e}");
thread_state.set_stream_error(true);
return;
}
let built: Result<
(Box<dyn Iterator<Item = i32> + Send>, u32),
String,
> = match media.mode {
1 => qbz_dsd::DopStream::new(demux)
.map_err(|e| e.to_string())
.map(|source| {
let rate = source.carrier_rate();
(
Box::new(DsdErrorReport::new(
source,
thread_state.clone(),
))
as Box<dyn Iterator<Item = i32> + Send>,
rate,
)
}),
2 | 3 => qbz_dsd::NativeDsdStream::new(demux, media.mode == 3)
.map_err(|e| e.to_string())
.map(|source| {
let rate = source.rate();
(
Box::new(DsdErrorReport::new(
source,
thread_state.clone(),
))
as Box<dyn Iterator<Item = i32> + Send>,
rate,
)
}),
_ => Err(format!("unknown direct DSD mode {}", media.mode)),
};
let (source, output_rate) = match built {
Ok(v) => v,
Err(e) => {
log::error!("Direct DSD seek: source build failed: {e}");
thread_state.set_stream_error(true);
return;
}
};
if *current_track_sample_rate != Some(output_rate) {
log::error!(
"Direct DSD seek: active rate {:?} differs from source rate {}",
*current_track_sample_rate,
output_rate
);
thread_state.set_stream_error(true);
return;
}
let Some(engine) = current_engine.as_mut() else {
return;
};
if let Err(e) = engine.replace_dop(
source,
target_secs.saturating_mul(output_rate as u64),
) {
log::error!("Direct DSD seek: replace failed: {e}");
thread_state.set_stream_error(true);
return;
}
let was_playing = thread_state.is_playing.load(Ordering::SeqCst);
thread_state.position.store(target_secs, Ordering::SeqCst);
if was_playing {
thread_state.start_playback_timer(target_secs);
}
thread_state.set_stream_error(false);
log::info!(
"Audio thread: direct DSD seeked to {}s (was_playing: {})",
target_secs,
was_playing
);
return;
}
// Three cases reach this handler:
// * full-file playback (current_audio_data set)
// * CMAF streaming, download complete (buffered
@@ -4666,6 +4815,7 @@ impl Player {
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
@@ -4834,12 +4984,14 @@ impl Player {
GaplessMedia::Streaming(source) => {
current_audio_data = None;
current_streaming_source = Some(source.clone());
thread_state
.promote_gapless_stream_feeder(pending.track_id);
thread_state.promote_gapless_stream_feeder(
pending.track_id,
);
}
GaplessMedia::Direct => {
GaplessMedia::Direct(media) => {
current_audio_data = None;
current_streaming_source = None;
current_direct_dsd = Some(media.clone());
}
}
thread_state.adopt_buffered_track(
@@ -4900,9 +5052,10 @@ impl Player {
pending.track_id,
);
}
GaplessMedia::Direct => {
GaplessMedia::Direct(media) => {
current_audio_data = None;
current_streaming_source = None;
current_direct_dsd = Some(media.clone());
}
}
thread_state.adopt_buffered_track(
@@ -5055,6 +5208,7 @@ impl Player {
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
@@ -5083,6 +5237,7 @@ impl Player {
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
@@ -31,6 +31,9 @@ type BoxedSampleIter = Box<dyn Iterator<Item = f32> + Send>;
#[cfg(target_os = "linux")]
type BoxedDopIter = Box<dyn Iterator<Item = i32> + Send>;
#[cfg(target_os = "linux")]
type DopReplacement = (BoxedDopIter, u64);
/// Thread-safe source queue for gapless playback.
/// The writer thread consumes sources; append() pushes new ones.
pub(crate) struct SourceQueue<S> {
@@ -74,6 +77,14 @@ impl<S> SourceQueue<S> {
fn is_empty(&self) -> bool {
self.queue.lock().unwrap().is_empty()
}
fn clear(&self) {
self.queue.lock().unwrap().clear();
}
fn wake(&self) {
self.notify.notify_one();
}
}
/// Unified playback engine
@@ -129,6 +140,9 @@ pub enum PlaybackEngine {
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
source_queue: Arc<SourceQueue<BoxedDopIter>>,
/// A seek replaces the live source without closing ALSA. The writer
/// inserts DSD silence before consuming it so the DAC stays locked.
replacement: Arc<Mutex<Option<DopReplacement>>>,
writer_thread: Option<thread::JoinHandle<()>>,
source_transition: Arc<AtomicBool>,
},
@@ -291,6 +305,7 @@ impl PlaybackEngine {
let should_stop = Arc::new(AtomicBool::new(false));
let position_frames = Arc::new(AtomicU64::new(0));
let source_queue: Arc<SourceQueue<BoxedDopIter>> = Arc::new(SourceQueue::new());
let replacement: Arc<Mutex<Option<DopReplacement>>> = Arc::new(Mutex::new(None));
let source_transition = Arc::new(AtomicBool::new(false));
let handle = {
let stream_c = stream.clone();
@@ -298,11 +313,20 @@ impl PlaybackEngine {
let stop_c = should_stop.clone();
let pos_c = position_frames.clone();
let queue_c = source_queue.clone();
let replacement_c = replacement.clone();
let transition_c = source_transition.clone();
let channels = stream.channels();
thread::spawn(move || {
dop_writer_thread(
stream_c, playing_c, stop_c, pos_c, queue_c, transition_c, channels, native,
stream_c,
playing_c,
stop_c,
pos_c,
queue_c,
replacement_c,
transition_c,
channels,
native,
);
})
};
@@ -312,6 +336,7 @@ impl PlaybackEngine {
should_stop,
position_frames,
source_queue,
replacement,
writer_thread: Some(handle),
source_transition,
}
@@ -346,6 +371,36 @@ impl PlaybackEngine {
}
}
/// Replace the currently playing direct-DSD source for seek while keeping
/// the exclusive ALSA stream open. Queued gapless successors are dropped;
/// the audio thread will request them again near the new end position.
#[cfg(target_os = "linux")]
pub fn replace_dop(
&mut self,
source: BoxedDopIter,
position_frames_base: u64,
) -> Result<(), String> {
match self {
Self::AlsaDop {
should_stop,
source_queue,
replacement,
source_transition,
..
} => {
if should_stop.load(Ordering::SeqCst) {
return Err("direct DSD writer is stopped".to_string());
}
source_queue.clear();
source_transition.store(false, Ordering::SeqCst);
*replacement.lock().unwrap() = Some((source, position_frames_base));
source_queue.wake();
Ok(())
}
_ => Err("replace_dop on a non-DoP engine".to_string()),
}
}
/// True when this engine is the DoP (DSD over PCM) writer.
pub fn is_dop(&self) -> bool {
#[cfg(target_os = "linux")]
@@ -1068,6 +1123,7 @@ fn dop_writer_thread(
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
source_queue: Arc<SourceQueue<BoxedDopIter>>,
replacement: Arc<Mutex<Option<DopReplacement>>>,
source_transition: Arc<AtomicBool>,
channels: u16,
native: bool,
@@ -1104,6 +1160,19 @@ fn dop_writer_thread(
break 'thread;
}
if let Some((source, position_base)) = replacement.lock().unwrap().take() {
// Do not drop/reopen ALSA here: that makes the DAC leave DSD mode
// and is the source of the loud click users hear on a naive seek.
// A short valid-silence bridge gives both DoP and native receivers
// a clean boundary while preserving exclusive ownership.
write_silence(&mut silence_packer, &mut silence_buf, carrier * 150 / 1000);
current = Some(source);
had_source = true;
position_frames.store(position_base, Ordering::SeqCst);
source_transition.store(false, Ordering::SeqCst);
log::info!("[DoP Engine] Source replaced in-place for seek");
}
if current.is_none() {
match source_queue.wait_for_source(Duration::from_millis(100)) {
Some(src) => {
+1 -1
View File
@@ -226,7 +226,7 @@ Column {
// still plays there -- converted to PCM, which is the default.
visible: root.doc.backendIsAlsa === true
label: QbzSession.tr("DSD playback", QbzSession.trRev)
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
QbzSelect {
menuWidth: 280
options: root.doc.dsdModes || []
+4 -5
View File
@@ -1331,14 +1331,13 @@ pub(crate) async fn play_resolved_offline_aware(
};
// A PURCHASED COPY ON DISK plays instead of the stream (contract
// 2026-09-01 §6): same Qobuz id, same queue row, different bytes. The
// registry row is probed before it is trusted, and a DSD copy cannot
// seek, so a start offset (resume, takeback) keeps the stream for this
// one play. Anything the copy cannot do falls through to the tier walk
// below exactly as if the download did not exist.
// registry row is probed before it is trusted. The DSD ticket does not
// carry an initial offset yet, so resume/takeback keeps the stream for
// this one play; interactive seek works once direct DSD is playing.
if let Some(copy) = crate::purchase_playback_qt::resolve_purchased_copy(track_id).await {
if copy.is_dsd() && start_position_secs > 0 {
log::info!(
"[qbz-qt] purchase: track {track_id} has a DSD copy but a start offset of {start_position_secs}s was asked; DSD cannot seek, streaming this play"
"[qbz-qt] purchase: track {track_id} has a DSD copy but its ticket cannot carry the requested initial offset of {start_position_secs}s; streaming this play"
);
} else {
let format_id = copy.format_id;
+4 -3
View File
@@ -16,9 +16,10 @@
//! pruned by the next Purchases visit (`get_downloaded_purchase_track_ids`).
//!
//! DSD is DSD: a `.dsf`/`.dff` copy rides the additive `DsdFile` ticket the
//! Local Library already uses, and because that path cannot seek, a play that
//! asks for a start offset (session resume, a takeback at position > 0)
//! streams instead (§7.4). Nothing here touches the protected audio path —
//! Local Library already uses. That ticket does not carry an initial offset,
//! so a play that asks for one (session resume, a takeback at position > 0)
//! streams instead (§7.4); interactive direct-DSD seek remains available.
//! Nothing here touches the protected audio path —
//! the ticket is performed by `audible_qt::play_ticket`, the one matcher every
//! source goes through.
+3 -14
View File
@@ -10,12 +10,9 @@
// in their own Errors columns (§3.3.11-12), so they act on whatever is
// already loaded regardless of auth state.
//
// DSD-direct guard: `Player::is_dsd_direct_active()` (qbz-player/src/player/
// mod.rs:4893, "True while a DoP stream is active (volume fixed, seek
// unsupported)") is the player's own guard — previously unconsumed anywhere
// in the workspace. `seek`/`volume` (incl. the `mute` body form) check it
// FIRST and refuse 409 rather than silently no-op (the brief's explicit
// requirement — a silent no-op reads as broken, 02 §1.4).
// DSD-direct guard: volume (including the `mute` body form) remains fixed for
// bit-perfect output. Seek is supported by replacing the demuxed DSD source
// while the direct stream remains open, so it is intentionally not guarded.
//
// Mute is daemon-owned state in `DaemonShared.{muted, premute_volume}` (T2
// seam), NOT the desktop's process statics (`crates/qbz/src/playback.rs:
@@ -193,14 +190,6 @@ pub fn seek(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
Err(response) => return response,
};
let player = state.runtime.core().player();
if player.is_dsd_direct_active() {
return err_json(
409,
"seek_unsupported_dsd",
"seek is unsupported in DSD-direct mode (bit-perfect passthrough)",
"set DSD mode to \"convert\": qbzd setup (Audio screen)",
);
}
let ev = player.get_playback_event();
let target: u64 = if let Some(pos) = body.get("position").and_then(|v| v.as_u64()) {
pos
-6
View File
@@ -220,9 +220,6 @@ fn handle_media_event(
return;
};
let player = core.player();
if player.is_dsd_direct_active() {
return;
}
let ev = player.get_playback_event();
let target = (ev.position as i64 + micros / 1_000_000).max(0) as u64;
let clamped = if ev.duration > 0 {
@@ -237,9 +234,6 @@ fn handle_media_event(
return;
};
let player = core.player();
if player.is_dsd_direct_active() {
return;
}
let ev = player.get_playback_event();
let target = (micros.max(0) as u64) / 1_000_000;
let clamped = if ev.duration > 0 {
+1 -1
View File
@@ -186,7 +186,7 @@ pub const ALSA_PCM: &str = "pcm (Most compatible)";
/// DSD guard (§3.2.4). Verbatim-in-spirit of the desktop warning.
pub const DSD_GUARD_TITLE: &str = "DSD direct mode";
pub const DSD_GUARD_BODY: &str = "Choose DoP or Native only if your DAC supports it. On any other DAC they play\nas LOUD NOISE. Volume is fixed and seeking is disabled in DoP/Native; Native\nadditionally needs kernel support.";
pub const DSD_GUARD_BODY: &str = "Choose DoP or Native only if your DAC supports it. On any other DAC they play\nas LOUD NOISE. Volume is fixed in DoP/Native; Native additionally needs\nkernel support.";
pub const DSD_GUARD_HINT: &str = "Enter confirm · Esc revert";
pub const AUDIO_SCANNING: &str = "scanning…";