From 8aab112afe20ddc702e1737b46219aa6e7d64682 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:32:52 -0600 Subject: [PATCH] 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 --- crates/qbz-disc/src/sacd.rs | 26 ++- crates/qbz-dsd/src/demux.rs | 50 ++++- crates/qbz-dsd/src/dop.rs | 4 + crates/qbz-dsd/src/sacd_source.rs | 8 + crates/qbz-dsd/tests/demux_convert.rs | 45 +++- .../translations/de/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/es/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/fr/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/ja/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/nl/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/pt/LC_MESSAGES/qbz-ui.po | 4 +- .../translations/ru/LC_MESSAGES/qbz-ui.po | 4 +- crates/qbz-player/src/player/mod.rs | 199 ++++++++++++++++-- .../qbz-player/src/player/playback_engine.rs | 71 ++++++- crates/qbz-qt/qml/settings/AudioSettings.qml | 2 +- crates/qbz-qt/src/playback_qt.rs | 9 +- crates/qbz-qt/src/purchase_playback_qt.rs | 7 +- crates/qbzd/src/api/playback.rs | 17 +- crates/qbzd/src/mpris.rs | 6 - crates/qbzd/src/tui/strings.rs | 2 +- 20 files changed, 397 insertions(+), 77 deletions(-) diff --git a/crates/qbz-disc/src/sacd.rs b/crates/qbz-disc/src/sacd.rs index 84c4e28e3..7b1d6a33a 100644 --- a/crates/qbz-disc/src/sacd.rs +++ b/crates/qbz-disc/src/sacd.rs @@ -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 { + pub fn open_with(path: &Path, track: &SacdTrack, sync: FrameSync) -> Result { + 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. diff --git a/crates/qbz-dsd/src/demux.rs b/crates/qbz-dsd/src/demux.rs index 955716a17..514110a1c 100644 --- a/crates/qbz-dsd/src/demux.rs +++ b/crates/qbz-dsd/src/demux.rs @@ -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], @@ -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], diff --git a/crates/qbz-dsd/src/dop.rs b/crates/qbz-dsd/src/dop.rs index f688a750a..a1ee5e00f 100644 --- a/crates/qbz-dsd/src/dop.rs +++ b/crates/qbz-dsd/src/dop.rs @@ -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], diff --git a/crates/qbz-dsd/src/sacd_source.rs b/crates/qbz-dsd/src/sacd_source.rs index 13f38be7b..ae23f0604 100644 --- a/crates/qbz-dsd/src/sacd_source.rs +++ b/crates/qbz-dsd/src/sacd_source.rs @@ -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], diff --git a/crates/qbz-dsd/tests/demux_convert.rs b/crates/qbz-dsd/tests/demux_convert.rs index 538d321f6..6dca5f495 100644 --- a/crates/qbz-dsd/tests/demux_convert.rs +++ b/crates/qbz-dsd/tests/demux_convert.rs @@ -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 "); diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index ff24ddfff..aa4864dfd 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -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)" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index abf921558..70dab05ba 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -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)" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 0faf0e0d5..4787d6e14 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -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)" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 03797870d..43a266a8c 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -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に変換(すべての環境で動作)" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 9a1e1ff17..be6e5f977 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -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)" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 689e644c4..4678fc6da 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -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)" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index e07f46700..1c5dab028 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -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 (работает везде)" diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 44518028f..ecb121012 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -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), Streaming(Arc), - 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> = None; // Store streaming source for resume (when download completes, we can get the data) let mut current_streaming_source: Option> = 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 = 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, current_audio_data: &mut Option>, current_streaming_source: &mut Option>, + current_direct_dsd: &mut Option, stream_opt: &mut Option, current_device_name: &mut Option, 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 + 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 + 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 + 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, diff --git a/crates/qbz-player/src/player/playback_engine.rs b/crates/qbz-player/src/player/playback_engine.rs index 29b0d6eac..0a229be82 100644 --- a/crates/qbz-player/src/player/playback_engine.rs +++ b/crates/qbz-player/src/player/playback_engine.rs @@ -31,6 +31,9 @@ type BoxedSampleIter = Box + Send>; #[cfg(target_os = "linux")] type BoxedDopIter = Box + 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 { @@ -74,6 +77,14 @@ impl SourceQueue { 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, position_frames: Arc, source_queue: Arc>, + /// A seek replaces the live source without closing ALSA. The writer + /// inserts DSD silence before consuming it so the DAC stays locked. + replacement: Arc>>, writer_thread: Option>, source_transition: Arc, }, @@ -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> = Arc::new(SourceQueue::new()); + let replacement: Arc>> = 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, position_frames: Arc, source_queue: Arc>, + replacement: Arc>>, source_transition: Arc, 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) => { diff --git a/crates/qbz-qt/qml/settings/AudioSettings.qml b/crates/qbz-qt/qml/settings/AudioSettings.qml index d5ed0868a..128fa9e99 100644 --- a/crates/qbz-qt/qml/settings/AudioSettings.qml +++ b/crates/qbz-qt/qml/settings/AudioSettings.qml @@ -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 || [] diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index f3290f221..86ce5a572 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -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; diff --git a/crates/qbz-qt/src/purchase_playback_qt.rs b/crates/qbz-qt/src/purchase_playback_qt.rs index 3bddee62f..4038db55d 100644 --- a/crates/qbz-qt/src/purchase_playback_qt.rs +++ b/crates/qbz-qt/src/purchase_playback_qt.rs @@ -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. diff --git a/crates/qbzd/src/api/playback.rs b/crates/qbzd/src/api/playback.rs index 140122e0c..a51a083fc 100644 --- a/crates/qbzd/src/api/playback.rs +++ b/crates/qbzd/src/api/playback.rs @@ -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>> { 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 diff --git a/crates/qbzd/src/mpris.rs b/crates/qbzd/src/mpris.rs index 97a16a0f2..ea7835fb6 100644 --- a/crates/qbzd/src/mpris.rs +++ b/crates/qbzd/src/mpris.rs @@ -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 { diff --git a/crates/qbzd/src/tui/strings.rs b/crates/qbzd/src/tui/strings.rs index 1bdc15c20..eba1ebdf9 100644 --- a/crates/qbzd/src/tui/strings.rs +++ b/crates/qbzd/src/tui/strings.rs @@ -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…";