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 { pub struct SacdTrackReader {
iso: IsoImage, iso: IsoImage,
start_lsn: u64,
next_lsn: u64, next_lsn: u64,
end_lsn: u64, end_lsn: u64,
/// Set once the first frame boundary has been found; until then audio /// 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) Self::open_with(path, track, FrameSync::ToFirstFrame)
} }
pub fn open_with( pub fn open_with(path: &Path, track: &SacdTrack, sync: FrameSync) -> Result<Self, SacdError> {
path: &Path, let start_lsn = track.start_lsn as u64;
track: &SacdTrack,
sync: FrameSync,
) -> Result<Self, SacdError> {
Ok(Self { Ok(Self {
iso: IsoImage::open(path)?, 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 // `length_lsn` is how many sectors to read, including the shared
// boundary sector — the disc's own accounting, not ours. // boundary sector — the disc's own accounting, not ours.
end_lsn: track.start_lsn as u64 + track.length_lsn as u64, end_lsn: track.start_lsn as u64 + track.length_lsn as u64,
@@ -436,6 +435,21 @@ impl SacdTrackReader {
self.next_lsn >= self.end_lsn 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 /// Append the next chunk of AUDIO payload to `out`, skipping every
/// non-audio packet — and, until the first frame boundary is found, every /// non-audio packet — and, until the first frame boundary is found, every
/// audio byte too (see [`FrameSync`]). Returns bytes appended. /// audio byte too (see [`FrameSync`]). Returns bytes appended.
+47 -3
View File
@@ -73,6 +73,11 @@ impl DsdStreamInfo {
pub trait DsdDemuxer: Send { pub trait DsdDemuxer: Send {
fn info(&self) -> &DsdStreamInfo; 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]` /// Append up to `max_bytes_per_ch` DSD bytes per channel to `out[ch]`
/// (planar). Returns the byte count appended to EACH channel (always /// (planar). Returns the byte count appended to EACH channel (always
/// equal across channels); 0 means end of stream. /// equal across channels); 0 means end of stream.
@@ -184,8 +189,12 @@ struct DsfReader {
file: File, file: File,
info: DsdStreamInfo, info: DsdStreamInfo,
block_size: usize, block_size: usize,
data_start: u64,
total_bytes_per_ch: u64,
/// Valid (non-padding) DSD bytes remaining per channel. /// Valid (non-padding) DSD bytes remaining per channel.
remaining_per_ch: u64, remaining_per_ch: u64,
/// Bytes to discard from each channel's first physical block after seek.
first_block_skip: usize,
} }
impl DsfReader { impl DsfReader {
@@ -261,10 +270,14 @@ impl DsfReader {
DsdTags::default() DsdTags::default()
}; };
let total_bytes_per_ch = sample_count.div_ceil(8);
Ok(Self { Ok(Self {
file, file,
block_size: block_size as usize, 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 { info: DsdStreamInfo {
dsd_rate: sampling_frequency, dsd_rate: sampling_frequency,
channels: channel_num as u16, channels: channel_num as u16,
@@ -281,6 +294,18 @@ impl DsdDemuxer for DsfReader {
&self.info &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( fn read_planar(
&mut self, &mut self,
out: &mut [Vec<u8>], out: &mut [Vec<u8>],
@@ -294,10 +319,13 @@ impl DsdDemuxer for DsfReader {
let mut block = vec![0u8; self.block_size]; let mut block = vec![0u8; self.block_size];
while appended < max_bytes_per_ch && self.remaining_per_ch > 0 { while appended < max_bytes_per_ch && self.remaining_per_ch > 0 {
// One block group: block_size bytes for each channel in order. // 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 { for ch in 0..self.info.channels as usize {
match self.file.read_exact(&mut block) { 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 => { Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
// Truncated file: stop at what we got. // Truncated file: stop at what we got.
self.remaining_per_ch = 0; self.remaining_per_ch = 0;
@@ -306,6 +334,7 @@ impl DsdDemuxer for DsfReader {
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
} }
self.first_block_skip = 0;
self.remaining_per_ch -= valid as u64; self.remaining_per_ch -= valid as u64;
appended += valid; appended += valid;
} }
@@ -320,6 +349,8 @@ impl DsdDemuxer for DsfReader {
struct DffReader { struct DffReader {
file: File, file: File,
info: DsdStreamInfo, info: DsdStreamInfo,
data_offset: u64,
data_size: u64,
/// Bytes (all channels interleaved) remaining in the DSD data chunk. /// Bytes (all channels interleaved) remaining in the DSD data chunk.
remaining_total: u64, remaining_total: u64,
} }
@@ -425,6 +456,8 @@ impl DffReader {
Ok(Self { Ok(Self {
file, file,
data_offset,
data_size,
remaining_total: data_size, remaining_total: data_size,
info: DsdStreamInfo { info: DsdStreamInfo {
dsd_rate, dsd_rate,
@@ -442,6 +475,17 @@ impl DsdDemuxer for DffReader {
&self.info &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( fn read_planar(
&mut self, &mut self,
out: &mut [Vec<u8>], out: &mut [Vec<u8>],
+4
View File
@@ -218,6 +218,10 @@ mod io_error_tests {
fn info(&self) -> &DsdStreamInfo { fn info(&self) -> &DsdStreamInfo {
&self.info &self.info
} }
fn seek_to_bit(&mut self, _bit_per_channel: u64) -> Result<(), DsdError> {
self.left = 1;
Ok(())
}
fn read_planar( fn read_planar(
&mut self, &mut self,
out: &mut [Vec<u8>], out: &mut [Vec<u8>],
+8
View File
@@ -174,6 +174,14 @@ impl DsdDemuxer for SacdDemuxer {
&self.info &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( fn read_planar(
&mut self, &mut self,
out: &mut [Vec<u8>], out: &mut [Vec<u8>],
+44 -1
View File
@@ -1,7 +1,7 @@
//! Integration tests over synthesized DSF/DFF files. //! Integration tests over synthesized DSF/DFF files.
use qbz_dsd::{open_dsd, DsdError, DsdPcmConverter}; use qbz_dsd::{open_dsd, DsdError, DsdPcmConverter};
use std::io::Write; use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf; use std::path::PathBuf;
fn tmp(name: &str) -> PathBuf { fn tmp(name: &str) -> PathBuf {
@@ -213,6 +213,49 @@ fn dff_parses_stereo() {
assert!(!info.lsb_first); 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] #[test]
fn dff_dst_rejected() { fn dff_dst_rejected() {
let path = write_dff("dst.dff", 2, 2_822_400, 128, b"DST "); 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" msgid "DSD playback"
msgstr "DSD-Wiedergabe" 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." 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. Lautstärke ist fest und Suchen ist im DoP/Nativ-Modus deaktiviert. Nativ erfordert zusätzlich Kernel-Unterstützung für den 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)" msgid "Convert to PCM (works everywhere)"
msgstr "Zu PCM konvertieren (funktioniert überall)" msgstr "Zu PCM konvertieren (funktioniert überall)"
@@ -4488,8 +4488,8 @@ msgstr "No se puede reproducir el archivo DSD"
msgid "DSD playback" msgid "DSD playback"
msgstr "Reproducción DSD" 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." 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 y la búsqueda se desactiva en modo DoP/Nativo. Nativo además requiere soporte del kernel para el 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)" msgid "Convert to PCM (works everywhere)"
msgstr "Convertir a PCM (funciona en todos lados)" msgstr "Convertir a PCM (funciona en todos lados)"
@@ -4452,8 +4452,8 @@ msgstr "Impossible de lire le fichier DSD"
msgid "DSD playback" msgid "DSD playback"
msgstr "Lecture DSD" 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." 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 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." 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)" msgid "Convert to PCM (works everywhere)"
msgstr "Convertir en PCM (fonctionne partout)" msgstr "Convertir en PCM (fonctionne partout)"
@@ -4441,8 +4441,8 @@ msgstr "DSDファイルを再生できません"
msgid "DSD playback" msgid "DSD playback"
msgstr "DSD再生" 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." 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用のカーネルサポートが必要です。" msgstr "DSDトラックをDACへ送る方法。警告:DACが対応している場合のみDoPまたはネイティブを選択してください。対応していないDACでは大きなノイズとして再生されます。DoP/ネイティブモード中は音量が固定されます。ネイティブはさらにDAC用のカーネルサポートが必要です。"
msgid "Convert to PCM (works everywhere)" msgid "Convert to PCM (works everywhere)"
msgstr "PCMに変換(すべての環境で動作)" msgstr "PCMに変換(すべての環境で動作)"
@@ -4465,8 +4465,8 @@ msgstr "Kan DSD-bestand niet afspelen"
msgid "DSD playback" msgid "DSD playback"
msgstr "DSD-weergave" 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." 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 en zoeken is uitgeschakeld in DoP/Native-modus. Native vereist bovendien kernelondersteuning voor de 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)" msgid "Convert to PCM (works everywhere)"
msgstr "Converteren naar PCM (werkt overal)" msgstr "Converteren naar PCM (werkt overal)"
@@ -4452,8 +4452,8 @@ msgstr "Não é possível reproduzir o ficheiro DSD"
msgid "DSD playback" msgid "DSD playback"
msgstr "Reprodução DSD" 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." 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 e a procura é desativada no modo DoP/Nativo. O Nativo requer ainda suporte do kernel para o 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)" msgid "Convert to PCM (works everywhere)"
msgstr "Converter para PCM (funciona em todo o lado)" msgstr "Converter para PCM (funciona em todo o lado)"
@@ -4468,8 +4468,8 @@ msgstr "Не удаётся воспроизвести файл DSD"
msgid "DSD playback" msgid "DSD playback"
msgstr "Воспроизведение DSD" 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." 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 дополнительно требует поддержки ядра для ЦАПа." msgstr "Как DSD-треки попадают на ЦАП. ВНИМАНИЕ: выбирайте DoP или Native только если ваш ЦАП это поддерживает — на любом другом ЦАПе это воспроизводится как громкий шум. Громкость фиксирована в режиме DoP/Native. Native дополнительно требует поддержки ядра для ЦАПа."
msgid "Convert to PCM (works everywhere)" msgid "Convert to PCM (works everywhere)"
msgstr "Конвертировать в PCM (работает везде)" 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 /// 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 /// 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. /// 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) — /// 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). /// 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). /// 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 /// Ignored (with gapless_ready reset) when the engine isn't DoP or the
/// carrier rate differs — the normal track-end advance then handles it. /// 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) /// Pending gapless track data (queued for seamless transition)
enum GaplessMedia { enum GaplessMedia {
Buffered(Vec<u8>), Buffered(Vec<u8>),
Streaming(Arc<BufferedMediaSource>), 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 { struct GaplessPending {
@@ -2219,6 +2235,9 @@ impl Player {
let mut current_audio_data: Option<Vec<u8>> = None; let mut current_audio_data: Option<Vec<u8>> = None;
// Store streaming source for resume (when download completes, we can get the data) // Store streaming source for resume (when download completes, we can get the data)
let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None; 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 // Track consecutive sink creation failures to detect broken streams
let mut consecutive_sink_failures: u32 = 0; let mut consecutive_sink_failures: u32 = 0;
const MAX_SINK_FAILURES: u32 = 3; const MAX_SINK_FAILURES: u32 = 3;
@@ -2248,6 +2267,7 @@ impl Player {
current_engine: &mut Option<PlaybackEngine>, current_engine: &mut Option<PlaybackEngine>,
current_audio_data: &mut Option<Vec<u8>>, current_audio_data: &mut Option<Vec<u8>>,
current_streaming_source: &mut Option<Arc<BufferedMediaSource>>, current_streaming_source: &mut Option<Arc<BufferedMediaSource>>,
current_direct_dsd: &mut Option<DirectDsdMedia>,
stream_opt: &mut Option<StreamType>, stream_opt: &mut Option<StreamType>,
current_device_name: &mut Option<String>, current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32, consecutive_sink_failures: &mut u32,
@@ -2282,11 +2302,15 @@ impl Player {
); );
*pause_suspend_deadline = None; *pause_suspend_deadline = None;
thread_state.set_dsd_mode(0); thread_state.set_dsd_mode(0);
*current_direct_dsd = None;
// Clear any pending gapless state (new Play supersedes queued gapless) // Clear any pending gapless state (new Play supersedes queued gapless)
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining // survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it. // 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_pending = None;
*gapless_request_armed = false; *gapless_request_armed = false;
thread_state.set_gapless_ready(false); thread_state.set_gapless_ready(false);
@@ -2820,6 +2844,7 @@ impl Player {
start_position_secs start_position_secs
); );
*pause_suspend_deadline = None; *pause_suspend_deadline = None;
*current_direct_dsd = None;
// Clear any pending gapless state — a new // Clear any pending gapless state — a new
// PlayStreaming supersedes queued gapless, exactly // PlayStreaming supersedes queued gapless, exactly
// as `AudioCommand::Play` does above. // as `AudioCommand::Play` does above.
@@ -3438,10 +3463,14 @@ impl Player {
path.display() path.display()
); );
*pause_suspend_deadline = None; *pause_suspend_deadline = None;
*current_direct_dsd = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining // survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it. // 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_pending = None;
*gapless_request_armed = false; *gapless_request_armed = false;
thread_state.set_gapless_ready(false); thread_state.set_gapless_ready(false);
@@ -3526,6 +3555,10 @@ impl Player {
thread_state.set_stream_quality(dsd_rate, 1); thread_state.set_stream_quality(dsd_rate, 1);
thread_state.duration.store(duration, Ordering::SeqCst); thread_state.duration.store(duration, Ordering::SeqCst);
thread_state.set_dsd_mode(1); 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.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst); thread_state.position.store(0, Ordering::SeqCst);
thread_state thread_state
@@ -3550,10 +3583,14 @@ impl Player {
path.display() path.display()
); );
*pause_suspend_deadline = None; *pause_suspend_deadline = None;
*current_direct_dsd = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining // survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it. // 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_pending = None;
*gapless_request_armed = false; *gapless_request_armed = false;
thread_state.set_gapless_ready(false); thread_state.set_gapless_ready(false);
@@ -3648,7 +3685,12 @@ impl Player {
thread_state.set_stream_error(false); thread_state.set_stream_error(false);
thread_state.set_stream_quality(info.dsd_rate, 1); thread_state.set_stream_quality(info.dsd_rate, 1);
thread_state.duration.store(duration, Ordering::SeqCst); 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.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst); thread_state.position.store(0, Ordering::SeqCst);
thread_state thread_state
@@ -3750,7 +3792,10 @@ impl Player {
track_id, track_id,
play_generation: thread_state.current_play_generation(), play_generation: thread_state.current_play_generation(),
duration_secs: duration, duration_secs: duration,
media: GaplessMedia::Direct, media: GaplessMedia::Direct(DirectDsdMedia {
path: path.clone(),
mode,
}),
sample_rate: rate, sample_rate: rate,
channels: 2, channels: 2,
bit_depth: 1, bit_depth: 1,
@@ -3954,12 +3999,16 @@ impl Player {
thread_state.set_hardware_volume_active(false); thread_state.set_hardware_volume_active(false);
*current_audio_data = None; *current_audio_data = None;
*current_streaming_source = None; *current_streaming_source = None;
*current_direct_dsd = None;
*current_normalization_gain = None; *current_normalization_gain = None;
*current_gain_atomic = None; *current_gain_atomic = None;
// GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining // survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it. // 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_pending = None;
*gapless_request_armed = false; *gapless_request_armed = false;
thread_state.set_gapless_ready(false); thread_state.set_gapless_ready(false);
@@ -4011,23 +4060,123 @@ impl Player {
log::debug!("Audio thread: volume set to {}", volume); log::debug!("Audio thread: volume set to {}", volume);
} }
AudioCommand::Seek(position_secs) => { 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; *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 // GAPLESS LIFECYCLE TRACE (2026-08-10): the `X -> X` self-transition
// survived the PlayStreaming fix, and guessing at the remaining // survived the PlayStreaming fix, and guessing at the remaining
// path has failed twice. Every set/clear now says who did it. // 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_pending = None;
*gapless_request_armed = false; *gapless_request_armed = false;
thread_state.set_gapless_ready(false); thread_state.set_gapless_ready(false);
thread_state.set_gapless_next_track_id(0); 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: // Three cases reach this handler:
// * full-file playback (current_audio_data set) // * full-file playback (current_audio_data set)
// * CMAF streaming, download complete (buffered // * CMAF streaming, download complete (buffered
@@ -4666,6 +4815,7 @@ impl Player {
&mut current_engine, &mut current_engine,
&mut current_audio_data, &mut current_audio_data,
&mut current_streaming_source, &mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt, &mut stream_opt,
&mut current_device_name, &mut current_device_name,
&mut consecutive_sink_failures, &mut consecutive_sink_failures,
@@ -4834,12 +4984,14 @@ impl Player {
GaplessMedia::Streaming(source) => { GaplessMedia::Streaming(source) => {
current_audio_data = None; current_audio_data = None;
current_streaming_source = Some(source.clone()); current_streaming_source = Some(source.clone());
thread_state thread_state.promote_gapless_stream_feeder(
.promote_gapless_stream_feeder(pending.track_id); pending.track_id,
);
} }
GaplessMedia::Direct => { GaplessMedia::Direct(media) => {
current_audio_data = None; current_audio_data = None;
current_streaming_source = None; current_streaming_source = None;
current_direct_dsd = Some(media.clone());
} }
} }
thread_state.adopt_buffered_track( thread_state.adopt_buffered_track(
@@ -4900,9 +5052,10 @@ impl Player {
pending.track_id, pending.track_id,
); );
} }
GaplessMedia::Direct => { GaplessMedia::Direct(media) => {
current_audio_data = None; current_audio_data = None;
current_streaming_source = None; current_streaming_source = None;
current_direct_dsd = Some(media.clone());
} }
} }
thread_state.adopt_buffered_track( thread_state.adopt_buffered_track(
@@ -5055,6 +5208,7 @@ impl Player {
&mut current_engine, &mut current_engine,
&mut current_audio_data, &mut current_audio_data,
&mut current_streaming_source, &mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt, &mut stream_opt,
&mut current_device_name, &mut current_device_name,
&mut consecutive_sink_failures, &mut consecutive_sink_failures,
@@ -5083,6 +5237,7 @@ impl Player {
&mut current_engine, &mut current_engine,
&mut current_audio_data, &mut current_audio_data,
&mut current_streaming_source, &mut current_streaming_source,
&mut current_direct_dsd,
&mut stream_opt, &mut stream_opt,
&mut current_device_name, &mut current_device_name,
&mut consecutive_sink_failures, &mut consecutive_sink_failures,
@@ -31,6 +31,9 @@ type BoxedSampleIter = Box<dyn Iterator<Item = f32> + Send>;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
type BoxedDopIter = Box<dyn Iterator<Item = i32> + Send>; type BoxedDopIter = Box<dyn Iterator<Item = i32> + Send>;
#[cfg(target_os = "linux")]
type DopReplacement = (BoxedDopIter, u64);
/// Thread-safe source queue for gapless playback. /// Thread-safe source queue for gapless playback.
/// The writer thread consumes sources; append() pushes new ones. /// The writer thread consumes sources; append() pushes new ones.
pub(crate) struct SourceQueue<S> { pub(crate) struct SourceQueue<S> {
@@ -74,6 +77,14 @@ impl<S> SourceQueue<S> {
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.queue.lock().unwrap().is_empty() self.queue.lock().unwrap().is_empty()
} }
fn clear(&self) {
self.queue.lock().unwrap().clear();
}
fn wake(&self) {
self.notify.notify_one();
}
} }
/// Unified playback engine /// Unified playback engine
@@ -129,6 +140,9 @@ pub enum PlaybackEngine {
should_stop: Arc<AtomicBool>, should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>, position_frames: Arc<AtomicU64>,
source_queue: Arc<SourceQueue<BoxedDopIter>>, 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<()>>, writer_thread: Option<thread::JoinHandle<()>>,
source_transition: Arc<AtomicBool>, source_transition: Arc<AtomicBool>,
}, },
@@ -291,6 +305,7 @@ impl PlaybackEngine {
let should_stop = Arc::new(AtomicBool::new(false)); let should_stop = Arc::new(AtomicBool::new(false));
let position_frames = Arc::new(AtomicU64::new(0)); let position_frames = Arc::new(AtomicU64::new(0));
let source_queue: Arc<SourceQueue<BoxedDopIter>> = Arc::new(SourceQueue::new()); 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 source_transition = Arc::new(AtomicBool::new(false));
let handle = { let handle = {
let stream_c = stream.clone(); let stream_c = stream.clone();
@@ -298,11 +313,20 @@ impl PlaybackEngine {
let stop_c = should_stop.clone(); let stop_c = should_stop.clone();
let pos_c = position_frames.clone(); let pos_c = position_frames.clone();
let queue_c = source_queue.clone(); let queue_c = source_queue.clone();
let replacement_c = replacement.clone();
let transition_c = source_transition.clone(); let transition_c = source_transition.clone();
let channels = stream.channels(); let channels = stream.channels();
thread::spawn(move || { thread::spawn(move || {
dop_writer_thread( 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, should_stop,
position_frames, position_frames,
source_queue, source_queue,
replacement,
writer_thread: Some(handle), writer_thread: Some(handle),
source_transition, 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. /// True when this engine is the DoP (DSD over PCM) writer.
pub fn is_dop(&self) -> bool { pub fn is_dop(&self) -> bool {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1068,6 +1123,7 @@ fn dop_writer_thread(
should_stop: Arc<AtomicBool>, should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>, position_frames: Arc<AtomicU64>,
source_queue: Arc<SourceQueue<BoxedDopIter>>, source_queue: Arc<SourceQueue<BoxedDopIter>>,
replacement: Arc<Mutex<Option<DopReplacement>>>,
source_transition: Arc<AtomicBool>, source_transition: Arc<AtomicBool>,
channels: u16, channels: u16,
native: bool, native: bool,
@@ -1104,6 +1160,19 @@ fn dop_writer_thread(
break '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() { if current.is_none() {
match source_queue.wait_for_source(Duration::from_millis(100)) { match source_queue.wait_for_source(Duration::from_millis(100)) {
Some(src) => { Some(src) => {
+1 -1
View File
@@ -226,7 +226,7 @@ Column {
// still plays there -- converted to PCM, which is the default. // still plays there -- converted to PCM, which is the default.
visible: root.doc.backendIsAlsa === true visible: root.doc.backendIsAlsa === true
label: QbzSession.tr("DSD playback", QbzSession.trRev) 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 { QbzSelect {
menuWidth: 280 menuWidth: 280
options: root.doc.dsdModes || [] 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 // A PURCHASED COPY ON DISK plays instead of the stream (contract
// 2026-09-01 §6): same Qobuz id, same queue row, different bytes. The // 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 // registry row is probed before it is trusted. The DSD ticket does not
// seek, so a start offset (resume, takeback) keeps the stream for this // carry an initial offset yet, so resume/takeback keeps the stream for
// one play. Anything the copy cannot do falls through to the tier walk // this one play; interactive seek works once direct DSD is playing.
// below exactly as if the download did not exist.
if let Some(copy) = crate::purchase_playback_qt::resolve_purchased_copy(track_id).await { if let Some(copy) = crate::purchase_playback_qt::resolve_purchased_copy(track_id).await {
if copy.is_dsd() && start_position_secs > 0 { if copy.is_dsd() && start_position_secs > 0 {
log::info!( 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 { } else {
let format_id = copy.format_id; 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`). //! pruned by the next Purchases visit (`get_downloaded_purchase_track_ids`).
//! //!
//! DSD is DSD: a `.dsf`/`.dff` copy rides the additive `DsdFile` ticket the //! 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 //! Local Library already uses. That ticket does not carry an initial offset,
//! asks for a start offset (session resume, a takeback at position > 0) //! so a play that asks for one (session resume, a takeback at position > 0)
//! streams instead (§7.4). Nothing here touches the protected audio path — //! 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 //! the ticket is performed by `audible_qt::play_ticket`, the one matcher every
//! source goes through. //! 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 // in their own Errors columns (§3.3.11-12), so they act on whatever is
// already loaded regardless of auth state. // already loaded regardless of auth state.
// //
// DSD-direct guard: `Player::is_dsd_direct_active()` (qbz-player/src/player/ // DSD-direct guard: volume (including the `mute` body form) remains fixed for
// mod.rs:4893, "True while a DoP stream is active (volume fixed, seek // bit-perfect output. Seek is supported by replacing the demuxed DSD source
// unsupported)") is the player's own guard — previously unconsumed anywhere // while the direct stream remains open, so it is intentionally not guarded.
// 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).
// //
// Mute is daemon-owned state in `DaemonShared.{muted, premute_volume}` (T2 // Mute is daemon-owned state in `DaemonShared.{muted, premute_volume}` (T2
// seam), NOT the desktop's process statics (`crates/qbz/src/playback.rs: // 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, Err(response) => return response,
}; };
let player = state.runtime.core().player(); 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 ev = player.get_playback_event();
let target: u64 = if let Some(pos) = body.get("position").and_then(|v| v.as_u64()) { let target: u64 = if let Some(pos) = body.get("position").and_then(|v| v.as_u64()) {
pos pos
-6
View File
@@ -220,9 +220,6 @@ fn handle_media_event(
return; return;
}; };
let player = core.player(); let player = core.player();
if player.is_dsd_direct_active() {
return;
}
let ev = player.get_playback_event(); let ev = player.get_playback_event();
let target = (ev.position as i64 + micros / 1_000_000).max(0) as u64; let target = (ev.position as i64 + micros / 1_000_000).max(0) as u64;
let clamped = if ev.duration > 0 { let clamped = if ev.duration > 0 {
@@ -237,9 +234,6 @@ fn handle_media_event(
return; return;
}; };
let player = core.player(); let player = core.player();
if player.is_dsd_direct_active() {
return;
}
let ev = player.get_playback_event(); let ev = player.get_playback_event();
let target = (micros.max(0) as u64) / 1_000_000; let target = (micros.max(0) as u64) / 1_000_000;
let clamped = if ev.duration > 0 { 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. /// 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_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 DSD_GUARD_HINT: &str = "Enter confirm · Esc revert";
pub const AUDIO_SCANNING: &str = "scanning…"; pub const AUDIO_SCANNING: &str = "scanning…";