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:
20 files changed
+397
-77
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -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 (работает везде)"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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…";
|
||||
|
||||
Reference in new issue
Block a user