From bfd141e3dcf33b82019659369e7d921525a09d31 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 11:27:21 +0200 Subject: [PATCH 01/12] feat(audio): add DSD-over-PCM (DoP) encoder core module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First layer of native DSD playback (#495): a pure, streamable encoder that repackages the raw 1-bit DSD stream into 24-bit DoP frames (marker 0x05/0xFA alternating per frame, payload MSB-first, output rate = DSD bit rate / 16) for a bit-perfect transport to a DoP-capable DAC. No wiring yet — this is the tested foundation. DFF bytes pass through verbatim; DSF bytes are bit-reversed to honour the MSB-first payload convention. Marker cadence and leftover-byte carry survive arbitrary block chunking. 8 unit tests cover rate math, bit order, marker alternation, streaming continuity and the zeroed top byte. --- .../crates/core/src/audio_format/dsd/dop.rs | 371 ++++++++++++++++++ .../crates/core/src/audio_format/dsd/mod.rs | 1 + 2 files changed, 372 insertions(+) create mode 100644 src-tauri/crates/core/src/audio_format/dsd/dop.rs diff --git a/src-tauri/crates/core/src/audio_format/dsd/dop.rs b/src-tauri/crates/core/src/audio_format/dsd/dop.rs new file mode 100644 index 00000000..3fd91829 --- /dev/null +++ b/src-tauri/crates/core/src/audio_format/dsd/dop.rs @@ -0,0 +1,371 @@ +//! DSD → DoP (DSD over PCM) encoder. +//! +//! Where [`super::pcm`] *decodes* the 1-bit stream into audible PCM, +//! this module does the opposite of decoding: it leaves the DSD bits +//! untouched and merely **repackages** them inside 24-bit PCM frames so +//! they can travel down a bit-perfect PCM transport (WASAPI Exclusive) +//! to a DoP-capable DAC, which recognises the marker pattern, strips it, +//! and reconstructs the original 1-bit stream in its own hardware +//! sigma-delta modulator. The host performs no filtering, no volume, no +//! decimation — the sample that leaves here is the sample that hits the +//! DAC. That is the whole point: true native DSD, not DSD→PCM. +//! +//! ## Wire format (DoP Open Standard v1.1) +//! +//! Each 24-bit output sample carries **16 DSD bits** (two bytes) of one +//! channel plus an 8-bit marker: +//! +//! ```text +//! bits 23..16 : marker byte, alternating 0x05 / 0xFA every frame +//! bits 15..8 : first (earlier-in-time) DSD byte +//! bits 7..0 : second (later-in-time) DSD byte +//! ``` +//! +//! The marker toggles once per output frame and is **shared by every +//! channel of that frame** (L and R at frame `n` carry the same marker; +//! frame `n+1` carries the other). The DAC watches that alternation to +//! lock onto the DoP stream and to reject ordinary PCM (which would +//! never produce a stable 0x05/0xFA cadence in its top byte). +//! +//! Output sample rate is therefore the DSD bit rate divided by 16: +//! DSD64 (2.8224 MHz) → 176.4 kHz, DSD128 → 352.8 kHz, DSD256 → +//! 705.6 kHz. The transport must be opened at that exact rate in 24-bit, +//! which is why DoP only works over WASAPI Exclusive (the shared mixer +//! would resample and destroy the marker cadence). +//! +//! ## Bit order +//! +//! DoP defines the 16-bit payload as **MSB-first in time** (the DSD bit +//! that comes first in time is the most-significant bit of the field). +//! - **DFF** stores bytes MSB-first already → used verbatim. +//! - **DSF** stores bytes LSB-first → each byte is bit-reversed here so +//! the earliest-in-time bit lands in the high position. Getting this +//! wrong plays the stream time-reversed within every byte — audible +//! as harsh noise on real hardware, so it is covered by tests. +//! +//! ## Streaming +//! +//! Like [`super::pcm::DsdToPcm`], the encoder is fully streamable: it +//! keeps per-channel leftover bytes and the marker phase across +//! [`DsdToDop::encode_block`] calls, so an arbitrary chunking of the +//! input produces the same output as one giant call. + +use super::parser::DsdLayout; + +/// DSD bits carried per DoP output sample (two bytes). +const DSD_BITS_PER_SAMPLE: u32 = 16; + +/// The two DoP marker bytes, placed in bits 23..16 of each 24-bit word. +/// They alternate every output frame; the DAC locks onto the cadence. +const DOP_MARKER_A: u32 = 0x05; +const DOP_MARKER_B: u32 = 0xFA; + +/// Streaming DSD → DoP repackager. +/// +/// Holds no filter state (there is no filtering) — just enough to +/// de-interleave the container, pair bytes two-at-a-time per channel, +/// and keep the marker phase coherent across calls. +pub struct DsdToDop { + channels: usize, + /// Resulting 24-bit PCM sample rate (DSD bit rate / 16). Stamped on + /// the stream so the transport is opened at the matching rate. + pub output_rate_hz: u32, + /// True when the container stores bytes LSB-first (DSF): each byte + /// is bit-reversed so the DoP payload stays MSB-first in time. + lsb_first: bool, + /// `Some(block_size)` for DSF's per-channel block interleave, `None` + /// for DFF's byte interleave. Mirrors [`DsdLayout::block_interleave`]. + block_interleave: Option, + /// One leftover byte per channel, waiting for its pair from the next + /// block. Even-sized reads never populate this; it exists for odd + /// final reads and odd block sizes. + pending: Vec>, + /// Global output-frame counter. Its parity picks the marker, so the + /// cadence stays coherent no matter how the input was chunked. + frame_counter: u64, +} + +impl DsdToDop { + /// Build a DoP encoder for `layout`. The output rate is fixed at + /// `dsd_rate / 16`; the caller opens the exclusive device at that + /// rate before feeding blocks. + pub fn new(layout: &DsdLayout) -> Self { + let channels = layout.channels.count() as usize; + Self { + channels, + output_rate_hz: layout.sample_rate_hz / DSD_BITS_PER_SAMPLE, + lsb_first: layout.lsb_first, + block_interleave: layout.block_interleave, + pending: vec![None; channels], + frame_counter: 0, + } + } + + /// Number of channels the encoder interleaves. + pub fn channels(&self) -> usize { + self.channels + } + + /// Reset the streaming state (post-seek). Drops any half-formed + /// sample and restarts the marker cadence — the DAC re-locks within + /// a frame or two, inaudible. + pub fn reset(&mut self) { + for p in &mut self.pending { + *p = None; + } + self.frame_counter = 0; + } + + /// Encode a chunk of raw DSD container bytes into interleaved 24-bit + /// DoP samples, appended to `out`. Each value uses the low 24 bits; + /// the top byte of a 32-bit word is left zero for the transport to + /// ignore (it only ships three bytes per sample). + /// + /// The output length is always a whole number of frames + /// (`channels` samples), so a downstream interleaved writer never + /// sees a torn frame. + pub fn encode_block(&mut self, input: &[u8], out: &mut Vec) { + // De-interleave the container into one byte stream per channel, + // prepending any leftover byte from the previous call. + let per_channel = self.split_channels(input); + + // Pair bytes 2-at-a-time per channel into 16-bit payloads. Every + // channel yields the same count for valid stereo/mono DSD, so we + // key the frame count off the shortest to stay frame-aligned. + let payloads: Vec> = per_channel + .iter() + .enumerate() + .map(|(ch, bytes)| self.pack_payloads(ch, bytes)) + .collect(); + + let frames = payloads.iter().map(Vec::len).min().unwrap_or(0); + out.reserve(frames * self.channels); + for f in 0..frames { + let marker = if self.frame_counter & 1 == 0 { + DOP_MARKER_A + } else { + DOP_MARKER_B + }; + self.frame_counter += 1; + for payload in payloads.iter() { + // Safe: `frames` is the min length across channels. + out.push((marker << 16) | payload[f] as u32); + } + } + + // Any byte a channel produced beyond `frames * 2` (only possible + // when channels desynchronise on a truncated final read) is + // dropped rather than carried — the stream is ending anyway and + // half a DoP sample can't be shipped. In the common even-aligned + // case there is nothing to drop. + } + + /// De-interleave `input` into one `Vec` per channel, each led by + /// this channel's leftover byte from the previous call (consumed). + fn split_channels(&mut self, input: &[u8]) -> Vec> { + let mut per_channel: Vec> = (0..self.channels) + .map(|ch| { + let mut v = Vec::new(); + if let Some(b) = self.pending[ch].take() { + v.push(b); + } + v + }) + .collect(); + + match self.block_interleave { + // DSF: blocks of `block_size` bytes per channel, looping. + Some(block_size) => { + let block_size = block_size as usize; + let stride = block_size * self.channels; + for chunk in input.chunks(stride) { + for (ch, dst) in per_channel.iter_mut().enumerate() { + let start = ch * block_size; + if start >= chunk.len() { + break; + } + let end = (start + block_size).min(chunk.len()); + dst.extend_from_slice(&chunk[start..end]); + } + } + } + // DFF: bytes alternate channel by channel. + None => { + for (i, &byte) in input.iter().enumerate() { + per_channel[i % self.channels].push(byte); + } + } + } + + per_channel + } + + /// Pair a channel's byte stream into 16-bit DoP payloads, applying + /// the MSB-first bit order. A trailing odd byte is stashed in + /// `pending[ch]` for the next call. + fn pack_payloads(&mut self, ch: usize, bytes: &[u8]) -> Vec { + let mut payloads = Vec::with_capacity(bytes.len() / 2); + let mut iter = bytes.chunks_exact(2); + for pair in &mut iter { + let hi = self.orient(pair[0]) as u16; + let lo = self.orient(pair[1]) as u16; + payloads.push((hi << 8) | lo); + } + // Carry a single trailing byte to be paired with the first byte + // of the next block, preserving time order. + if let [b] = iter.remainder() { + self.pending[ch] = Some(*b); + } + payloads + } + + /// Orient one DSD byte to MSB-first-in-time. DFF bytes are already + /// MSB-first (verbatim); DSF bytes are LSB-first and get reversed. + #[inline] + fn orient(&self, byte: u8) -> u8 { + if self.lsb_first { + byte.reverse_bits() + } else { + byte + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio_format::dsd::parser::{DsdChannels, DsdContainer, DsdLayout}; + + fn layout(rate: u32, block_interleave: Option, lsb_first: bool) -> DsdLayout { + DsdLayout { + container: if block_interleave.is_some() { + DsdContainer::Dsf + } else { + DsdContainer::Dff + }, + channels: DsdChannels::Stereo, + sample_rate_hz: rate, + samples_per_channel: rate as u64, + data_offset: 0, + data_len_bytes: 0, + block_interleave, + lsb_first, + } + } + + #[test] + fn output_rate_is_dsd_rate_over_sixteen() { + // DSD64 → 176.4 kHz, DSD128 → 352.8, DSD256 → 705.6. + assert_eq!(DsdToDop::new(&layout(2_822_400, None, false)).output_rate_hz, 176_400); + assert_eq!(DsdToDop::new(&layout(5_644_800, None, false)).output_rate_hz, 352_800); + assert_eq!(DsdToDop::new(&layout(11_289_600, None, false)).output_rate_hz, 705_600); + } + + #[test] + fn dff_bytes_are_packed_verbatim_msb_first() { + // DFF byte-interleaved stereo: [L0, R0, L1, R1]. + // L payload = L0<<8 | L1, R payload = R0<<8 | R1, bytes verbatim. + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut out = Vec::new(); + enc.encode_block(&[0x12, 0xAB, 0x34, 0xCD], &mut out); + assert_eq!(out.len(), 2, "one stereo frame"); + // Frame 0 → marker A (0x05) on both channels. + assert_eq!(out[0], (DOP_MARKER_A << 16) | 0x1234, "L: 0x05_12_34"); + assert_eq!(out[1], (DOP_MARKER_A << 16) | 0xABCD, "R: 0x05_AB_CD"); + } + + #[test] + fn dsf_bytes_are_bit_reversed() { + // DSF is LSB-first: each byte must be reversed to MSB-first. + // 0x01 → reverse_bits → 0x80. Block-interleaved, block_size 2: + // [L0, L1, R0, R1]. + let mut enc = DsdToDop::new(&layout(2_822_400, Some(2), true)); + let mut out = Vec::new(); + enc.encode_block(&[0x01, 0x02, 0x03, 0x04], &mut out); + assert_eq!(out.len(), 2); + let rev = |b: u8| b.reverse_bits() as u32; + assert_eq!(out[0], (DOP_MARKER_A << 16) | (rev(0x01) << 8) | rev(0x02)); + assert_eq!(out[1], (DOP_MARKER_A << 16) | (rev(0x03) << 8) | rev(0x04)); + } + + #[test] + fn marker_alternates_every_frame_and_is_shared_across_channels() { + // Four stereo frames worth of DFF bytes → markers A,B,A,B, each + // shared by L and R of the same frame. + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut out = Vec::new(); + // 4 frames × 2 ch × 2 bytes = 16 bytes. + enc.encode_block(&vec![0u8; 16], &mut out); + assert_eq!(out.len(), 8, "4 stereo frames"); + let marker = |w: u32| w >> 16; + for f in 0..4 { + let expected = if f % 2 == 0 { DOP_MARKER_A } else { DOP_MARKER_B }; + assert_eq!(marker(out[f * 2]), expected, "L frame {f}"); + assert_eq!(marker(out[f * 2 + 1]), expected, "R frame {f}"); + } + } + + #[test] + fn marker_cadence_survives_block_chunking() { + // Encoding in two calls must produce the identical marker phase + // to one call — the counter persists across calls. + let bytes: Vec = (0..32).collect(); + let mut whole = Vec::new(); + DsdToDop::new(&layout(2_822_400, None, false)).encode_block(&bytes, &mut whole); + + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut split = Vec::new(); + enc.encode_block(&bytes[..12], &mut split); + enc.encode_block(&bytes[12..], &mut split); + + assert_eq!(whole, split, "chunking must not change the output"); + } + + #[test] + fn odd_trailing_byte_is_carried_to_the_next_call() { + // A channel gets an odd byte count on the first call; the leftover + // must pair with the first byte of the next call, in time order. + // DFF stereo, 6 bytes → 3 per channel → 1 full frame + 1 pending. + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut out = Vec::new(); + enc.encode_block(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66], &mut out); + assert_eq!(out.len(), 2, "only one complete frame so far"); + // L bytes seen: 0x11, 0x33 → frame0; 0x55 pending. + assert_eq!(out[0], (DOP_MARKER_A << 16) | 0x1133); + assert_eq!(out[1], (DOP_MARKER_A << 16) | 0x2244); + + // Next call supplies the pairs' second bytes. + out.clear(); + enc.encode_block(&[0x77, 0x88], &mut out); + assert_eq!(out.len(), 2, "the carried bytes complete a frame"); + // L: pending 0x55 + new 0x77 ; marker is now B (frame 1). + assert_eq!(out[0], (DOP_MARKER_B << 16) | 0x5577); + assert_eq!(out[1], (DOP_MARKER_B << 16) | 0x6688); + } + + #[test] + fn reset_clears_pending_and_restarts_cadence() { + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut out = Vec::new(); + // Leave a pending byte and advance the marker. + enc.encode_block(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66], &mut out); + enc.reset(); + out.clear(); + // After reset the pending 0x55/0x66 are gone and marker is A again. + enc.encode_block(&[0xAA, 0xBB, 0xCC, 0xDD], &mut out); + assert_eq!(out[0], (DOP_MARKER_A << 16) | 0xAACC, "L uses fresh bytes only"); + assert_eq!(out[1], (DOP_MARKER_A << 16) | 0xBBDD); + } + + #[test] + fn top_byte_above_the_marker_is_zero() { + // The transport ships 3 bytes; bits 31..24 must stay clear so a + // 32-bit reinterpretation never leaks garbage into the sample. + let mut enc = DsdToDop::new(&layout(2_822_400, None, false)); + let mut out = Vec::new(); + enc.encode_block(&[0xFF, 0xFF, 0xFF, 0xFF], &mut out); + for w in out { + assert_eq!(w >> 24, 0, "bits 31..24 must be zero"); + } + } +} diff --git a/src-tauri/crates/core/src/audio_format/dsd/mod.rs b/src-tauri/crates/core/src/audio_format/dsd/mod.rs index e136168b..f380cc91 100644 --- a/src-tauri/crates/core/src/audio_format/dsd/mod.rs +++ b/src-tauri/crates/core/src/audio_format/dsd/mod.rs @@ -21,6 +21,7 @@ //! exhaustive tests against synthesised fixtures. PCM conversion and //! metadata follow in subsequent commits. +pub mod dop; pub mod metadata; pub mod parser; pub mod pcm; From bd2673563bf489f8418fef095ef054911130f3e0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 11:36:48 +0200 Subject: [PATCH 02/12] feat(audio): add wasapi exclusive dop output backend (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second layer of native DSD: the exclusive backend can now open at a forced DoP format and ship the stream bit-perfect. - `DopFormat` (rate, channels) threaded through `spawn_output_with_mode` -> `spawn_exclusive_output_thread` -> `open_exclusive_session`. When set, the session pins the exact DoP layout (`dsd_rate / 16`, source channels) and a 24-bit-only format chain (packed then padded) — Float and 16-bit can't carry a DoP word. A refusal returns an error instead of dropping to shared mode, so the caller can fall back to DSD -> PCM. - Dedicated `run_dop_event_loop`: bit-exact (no volume / mono / normalize / clamp), pulls the decoder's 24-bit DoP words from the ring and writes them little-endian. Pause / drain / underrun emit marker-carrying DoP idle frames (0x69 silence payload, alternating 0x05/0xFA marker) so the DAC keeps DoP lock instead of clicking. The PCM hot path is dispatched around untouched. - `OutputHandle.dop_rate` records the active DoP rate so the engine can tell when the next track needs an output rebuild. - `SharedPlayback.dsd_dop_enabled` carries the user opt-in (default OFF). Not yet wired to track loading. Windows-only; requires on-hardware validation with a DoP-capable DAC before shipping. --- src-tauri/crates/app/src/audio/engine.rs | 4 + src-tauri/crates/app/src/audio/output.rs | 46 ++++ src-tauri/crates/app/src/audio/state.rs | 11 + .../crates/app/src/audio/wasapi_exclusive.rs | 235 +++++++++++++++++- 4 files changed, 286 insertions(+), 10 deletions(-) diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index d902611c..80a350c8 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -383,6 +383,7 @@ impl AudioEngine { app.clone(), device_name, wasapi_exclusive, + None, ) { Ok((producer, handle)) => { let active = handle.wasapi_exclusive; @@ -772,6 +773,7 @@ impl AudioEngine { self.app.clone(), device_name, exclusive, + None, ) { Ok(pair) => pair, Err(err) => { @@ -935,6 +937,7 @@ impl AudioEngine { device_name, self.wasapi_exclusive .load(std::sync::atomic::Ordering::Relaxed), + None, )?; // Step 3 — interrupt any current playback. The decoder will @@ -1134,6 +1137,7 @@ impl AudioEngine { self.app.clone(), active.clone(), enabled, + None, ) { Ok(pair) => pair, Err(err) => { diff --git a/src-tauri/crates/app/src/audio/output.rs b/src-tauri/crates/app/src/audio/output.rs index da994ef6..0e387b4f 100644 --- a/src-tauri/crates/app/src/audio/output.rs +++ b/src-tauri/crates/app/src/audio/output.rs @@ -223,6 +223,20 @@ fn silence_alsa_stderr R>(f: F) -> R { /// headroom while keeping latency low. pub const RING_CAPACITY: usize = 96_000; +/// Exact (rate, channels) an output must open at to carry a DoP (DSD +/// over PCM) stream, #495. Unlike the normal path — where the device +/// picks the rate and the decoder resamples to it — a DoP stream must +/// reach the DAC at precisely `dsd_rate / 16` in 24-bit or the marker +/// cadence breaks. So the caller (the engine, when it loads a DSD track +/// with DoP enabled) hands the exclusive backend this forced format +/// instead of letting it negotiate. Ignored entirely on the cpal shared +/// path — DoP only works over WASAPI Exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DopFormat { + pub sample_rate: u32, + pub channels: u16, +} + /// Pick the right output backend based on the runtime preference. /// On Windows + `exclusive=true`, tries the WASAPI Exclusive backend /// first and falls back to cpal shared if init fails (device busy, no @@ -236,13 +250,37 @@ pub fn spawn_output_with_mode( app: AppHandle, device_name: Option, exclusive: bool, + dop: Option, ) -> AppResult<(Producer, OutputHandle)> { + // DoP (#495) is a hard requirement, not a preference: a DoP stream + // that can't open its exact rate in exclusive 24-bit must NOT fall + // back to shared mode (the OS mixer would resample the marker + // cadence into white noise) — the caller instead falls back to + // ordinary DSD → PCM. So when a DoP format is requested we try the + // exclusive DoP path only, and surface its error verbatim. + #[cfg(target_os = "windows")] + if let Some(dop) = dop { + return super::wasapi_exclusive::spawn_exclusive_output_thread( + shared, + app, + device_name, + Some(dop), + ); + } + #[cfg(not(target_os = "windows"))] + if dop.is_some() { + return Err(AppError::Audio( + "DoP output requires WASAPI Exclusive (Windows only)".into(), + )); + } + #[cfg(target_os = "windows")] if exclusive { match super::wasapi_exclusive::spawn_exclusive_output_thread( shared.clone(), app.clone(), device_name.clone(), + None, ) { Ok(pair) => { tracing::info!("audio output: WASAPI Exclusive Mode engaged"); @@ -369,6 +407,13 @@ pub struct OutputHandle { /// The user preference can request exclusive mode, but startup may /// fall back to cpal shared mode when the device rejects it. pub wasapi_exclusive: bool, + /// `Some(rate)` when this output is carrying a DoP (DSD over PCM) + /// stream, at the given 24-bit PCM rate (`dsd_rate / 16`), #495. + /// `None` for every ordinary PCM output. The engine reads it to + /// decide whether the next track needs an output rebuild: a DoP + /// track at a different rate, or any PCM track after a DoP one, + /// forces a re-open; a DoP track at the same rate can reuse it. + pub dop_rate: Option, } impl OutputHandle { @@ -430,6 +475,7 @@ pub fn spawn_output_thread( join, device_name, wasapi_exclusive: false, + dop_rate: None, }, )), Ok(Err(err)) => { diff --git a/src-tauri/crates/app/src/audio/state.rs b/src-tauri/crates/app/src/audio/state.rs index 445547f8..fd819fe3 100644 --- a/src-tauri/crates/app/src/audio/state.rs +++ b/src-tauri/crates/app/src/audio/state.rs @@ -190,6 +190,16 @@ pub struct SharedPlayback { /// hardware. Only affects DSD playback — symphonia formats ignore /// it. Takes effect on the next track open. pub dsd_taps: AtomicU32, + /// User opt-in for native DSD output via DoP (DSD over PCM), #495. + /// When `true` AND the active output is WASAPI Exclusive AND the DAC + /// accepts the DoP rate/format, a `.dsf` / `.dff` stream is shipped + /// as raw 1-bit DoP frames instead of being converted to PCM — the + /// DAC decodes the DSD natively (bit-perfect). Any of those + /// conditions failing falls back silently to the DSD → PCM path, so + /// this is safe to leave on. Read by the decoder when it opens a DSD + /// stream (not in the hot path); Windows-only in practice. Persisted + /// in `profile_setting['audio.dsd_dop']`, default OFF. + pub dsd_dop_enabled: AtomicBool, } impl SharedPlayback { @@ -222,6 +232,7 @@ impl SharedPlayback { playback_speed_bits: AtomicU32::new(1.0_f32.to_bits()), speed_dirty: AtomicBool::new(false), dsd_taps: AtomicU32::new(256), + dsd_dop_enabled: AtomicBool::new(false), } } diff --git a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs index 90997d5e..70f83ce0 100644 --- a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs +++ b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs @@ -43,7 +43,7 @@ use wasapi::{ StreamMode, WaveFormat, }; -use super::output::{OutputHandle, RING_CAPACITY}; +use super::output::{DopFormat, OutputHandle, RING_CAPACITY}; use super::state::SharedPlayback; use crate::error::{AppError, AppResult}; @@ -62,6 +62,7 @@ pub fn spawn_exclusive_output_thread( shared: Arc, app: AppHandle, device_name: Option, + dop: Option, ) -> AppResult<(Producer, OutputHandle)> { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); let (shutdown_tx, shutdown_rx) = bounded::<()>(1); @@ -80,6 +81,7 @@ pub fn spawn_exclusive_output_thread( init_tx, thread_app, thread_device, + dop, ) }) .map_err(|e| AppError::Audio(format!("spawn wasapi exclusive thread: {e}")))?; @@ -92,6 +94,7 @@ pub fn spawn_exclusive_output_thread( join, device_name, wasapi_exclusive: true, + dop_rate: dop.map(|d| d.sample_rate), }, )), Ok(Err(err)) => { @@ -112,6 +115,7 @@ fn output_thread_main( init_tx: Sender>, app: AppHandle, device_name: Option, + dop: Option, ) { // COM init for this thread. MTA is the right choice for an audio // worker that doesn't touch UI. Any HRESULT other than S_OK / @@ -124,7 +128,7 @@ fn output_thread_main( return; } - let session = match open_exclusive_session(&device_name, &shared) { + let session = match open_exclusive_session(&device_name, &shared, dop) { Ok(s) => s, Err(err) => { tracing::warn!(?err, "wasapi exclusive init failed"); @@ -263,6 +267,15 @@ const FORMAT_FALLBACK_CHAIN: [ExclusiveSampleFormat; 4] = [ ExclusiveSampleFormat::Pcm16, ]; +/// Format chain tried for a DoP stream (#495): 24-bit only, packed +/// first (3 bytes = exactly the 24-bit DoP word), then the 32-bit +/// padded container some codecs prefer. Float32 and 16-bit are +/// deliberately excluded — neither can carry a DoP payload intact. +const DOP_FORMAT_CHAIN: [ExclusiveSampleFormat; 2] = [ + ExclusiveSampleFormat::Pcm24Packed, + ExclusiveSampleFormat::Pcm24Padded, +]; + /// A (sample rate, channel count) pair to try in exclusive mode, /// tagged with where it came from so the log says which source /// actually got the device open (#409). @@ -399,6 +412,11 @@ struct ExclusiveSession { /// Format the device actually accepted. Drives the f32 → bytes /// conversion inside `run_event_loop`. format: ExclusiveSampleFormat, + /// True when this session carries a DoP (DSD over PCM) stream, #495. + /// In DoP mode the event loop bypasses every DSP stage (volume, + /// mono, normalize, clamp) and ships the ring's 24-bit values + /// bit-exact — the DAC decodes the embedded 1-bit DSD itself. + dop: bool, } /// Resolve the device, then walk every (layout × bit depth) candidate @@ -422,15 +440,37 @@ struct ExclusiveSession { fn open_exclusive_session( device_name: &Option, shared: &Arc, + dop: Option, ) -> AppResult { let device = pick_device(device_name)?; - let layouts = collect_layout_candidates(&device); - if layouts.is_empty() { - return Err(AppError::Audio( - "wasapi exclusive: device reported no usable sample rate / channel layout".into(), - )); - } + // DoP (#495) pins both axes: the layout is the exact DoP format + // (`dsd_rate / 16`, source channels) and the bit-depth chain is + // 24-bit only. A DoP payload lives in 24 bits — Float32 would + // reinterpret the marker bytes as an exponent and 16-bit would + // truncate the low DSD byte, so neither can carry it. If the DAC + // refuses 24-bit at the DoP rate the whole open fails and the + // caller falls back to DSD → PCM (it never drops to shared mode). + let (layouts, formats): (Vec, &[ExclusiveSampleFormat]) = match dop { + Some(d) => ( + vec![LayoutCandidate { + sample_rate: d.sample_rate as usize, + channels: d.channels as usize, + origin: "dop", + }], + &DOP_FORMAT_CHAIN, + ), + None => { + let layouts = collect_layout_candidates(&device); + if layouts.is_empty() { + return Err(AppError::Audio( + "wasapi exclusive: device reported no usable sample rate / channel layout" + .into(), + )); + } + (layouts, &FORMAT_FALLBACK_CHAIN) + } + }; let mut last_err: Option = None; // Every rejection, kept for the summary below. `debug` alone was @@ -439,7 +479,7 @@ fn open_exclusive_session( // other seven had to be guessed at. let mut failures: Vec = Vec::new(); for layout in &layouts { - for &format in FORMAT_FALLBACK_CHAIN.iter() { + for &format in formats.iter() { match try_open_with_format(&device, format, layout.sample_rate, layout.channels) { Ok((client, render, event, buffer_frames)) => { tracing::info!( @@ -463,6 +503,7 @@ fn open_exclusive_session( channels: layout.channels as u16, buffer_frames, format, + dop: dop.is_some(), }); } Err(err) => { @@ -682,10 +723,18 @@ fn pick_device(device_name: &Option) -> AppResult { /// recovery is warranted — see [`ExitReason`]. fn run_event_loop( session: ExclusiveSession, - mut consumer: Consumer, + consumer: Consumer, shutdown_rx: Receiver<()>, shared: &Arc, ) -> ExitReason { + // DoP streams run an entirely separate loop: bit-exact, no DSP, and + // marker-carrying silence on pause. Dispatch up-front so the PCM hot + // path below stays exactly as it was (#495). + if session.dop { + return run_dop_event_loop(session, consumer, shutdown_rx, shared); + } + + let mut consumer = consumer; let ExclusiveSession { client, render, @@ -847,6 +896,172 @@ fn run_event_loop( exit } +/// DoP (DSD over PCM) render loop, #495. Structurally the twin of +/// [`run_event_loop`] — same event wait, shutdown checks, device-loss +/// exit and teardown — but the sample stage is fundamentally different: +/// +/// - **Bit-exact, no DSP.** The decoder already produced fully-formed +/// 24-bit DoP words (marker + two DSD bytes) and shipped them through +/// the ring as `f32` bit patterns. We pull `to_bits()`, mask to 24 +/// bits and write the little-endian bytes straight out. No volume, no +/// mono, no normalize, no clamp — touching a DoP word would corrupt +/// the embedded DSD and the marker cadence the DAC locks onto. +/// - **Marker-carrying silence.** On pause / drain / underrun we can't +/// write PCM zero: the DAC would lose DoP lock and click. Instead we +/// emit DoP *idle* frames — the standard 0x69 DSD-silence payload with +/// a live, per-frame-alternating marker — so the DAC stays in DSD mode +/// outputting analog silence. +fn run_dop_event_loop( + session: ExclusiveSession, + mut consumer: Consumer, + shutdown_rx: Receiver<()>, + shared: &Arc, +) -> ExitReason { + let ExclusiveSession { + client, + render, + event, + channels, + buffer_frames, + format, + .. + } = session; + + let channels = channels as usize; + let padded = matches!(format, ExclusiveSampleFormat::Pcm24Padded); + let sample_bytes = if padded { 4 } else { 3 }; + let need_frames = buffer_frames as usize; + let buffer_bytes = need_frames * channels * sample_bytes; + + // One byte scratch reused every period; no allocations in the loop. + let mut bytes_scratch: Vec = vec![0u8; buffer_bytes]; + // Running DoP marker phase for generated silence. Advances per + // emitted silence frame so the idle cadence never stalls. + let mut silence_phase: u64 = 0; + + const EVENT_TIMEOUT_MS: u32 = 2000; + + let exit = loop { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + + match event.wait_for_event(EVENT_TIMEOUT_MS) { + Ok(()) => {} + Err(err) => { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + tracing::warn!(?err, "wasapi exclusive (DoP) wait_for_event failed"); + break ExitReason::DeviceLost(format!("wasapi wait_for_event failed: {err:?}")); + } + } + + let paused = shared.paused_output.load(Ordering::Acquire); + let draining = shared.drain_silent.load(Ordering::Acquire); + + if paused || draining { + if draining { + while consumer.pop().is_ok() {} + } + render_dop_silence(padded, channels, need_frames, &mut silence_phase, &mut bytes_scratch); + } else { + // Pull whole frames while the ring can satisfy them; on the + // first short frame, fill the rest of the buffer with DoP + // idle so the marker cadence never breaks mid-period. + let mut written: u64 = 0; + let mut f = 0usize; + while f < need_frames { + if consumer.slots() < channels { + for g in f..need_frames { + let word = dop_silence_word(silence_phase); + silence_phase = silence_phase.wrapping_add(1); + for ch in 0..channels { + write_dop_word(padded, word, &mut bytes_scratch, g * channels + ch); + } + } + break; + } + for ch in 0..channels { + // Slots checked above — this pop cannot fail. + let word = consumer.pop().map(|s| s.to_bits()).unwrap_or(0); + write_dop_word(padded, word, &mut bytes_scratch, f * channels + ch); + } + written += channels as u64; + f += 1; + } + if written > 0 { + shared.samples_played.fetch_add(written, Ordering::Relaxed); + } + } + + if let Err(err) = render.write_to_device(need_frames, &bytes_scratch, None) { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + tracing::warn!(?err, "wasapi (DoP) write_to_device failed"); + break ExitReason::DeviceLost(format!("wasapi write_to_device failed: {err:?}")); + } + + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + }; + + let _ = client.stop_stream(); + let _ = event; + std::thread::sleep(Duration::from_millis(5)); + wasapi::deinitialize(); + + exit +} + +/// Write one 24-bit DoP word at `sample_idx`, little-endian. `padded` +/// selects the 4-byte 24-in-32 container (high byte 0) vs the compact +/// 3-byte packing. The word is masked to 24 bits — the marker sits in +/// bits 23..16, the two DSD bytes below it. +#[inline] +fn write_dop_word(padded: bool, word: u32, bytes: &mut [u8], sample_idx: usize) { + let v = word & 0x00FF_FFFF; + if padded { + let off = sample_idx * 4; + bytes[off..off + 4].copy_from_slice(&v.to_le_bytes()); + } else { + let off = sample_idx * 3; + bytes[off] = v as u8; + bytes[off + 1] = (v >> 8) as u8; + bytes[off + 2] = (v >> 16) as u8; + } +} + +/// A DoP idle (silence) word: the standard 0x69 DSD-silence payload in +/// both data bytes, with the marker chosen by `phase` parity. Decodes to +/// analog silence on the DAC while keeping it in DoP lock. +#[inline] +fn dop_silence_word(phase: u64) -> u32 { + let marker: u32 = if phase & 1 == 0 { 0x05 } else { 0xFA }; + (marker << 16) | 0x6969 +} + +/// Fill `bytes` with `need_frames` DoP idle frames, advancing `phase` +/// once per frame so the marker keeps alternating. Every channel of a +/// frame carries the same marker. +fn render_dop_silence( + padded: bool, + channels: usize, + need_frames: usize, + phase: &mut u64, + bytes: &mut [u8], +) { + for f in 0..need_frames { + let word = dop_silence_word(*phase); + *phase = phase.wrapping_add(1); + for ch in 0..channels { + write_dop_word(padded, word, bytes, f * channels + ch); + } + } +} + /// Pack the decoded `f32` sample buffer into the little-endian byte /// layout the negotiated exclusive format expects. /// From 9e4c3264b25482b7f855b1f02934bcc1d6efeed0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 11:59:17 +0200 Subject: [PATCH 03/12] feat(audio): wire dop into the decoder and per-track output switch (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third layer: a DSD track now plays as native DoP end to end when the opt-in is on and the DAC accepts it. crossfade.rs — new `StreamBackend::Dop`: `ActiveStream::open` gains a `dop` flag that builds a `DsdToDop` encoder instead of `DsdToPcm`. A dedicated `decode_dop_block` reads the raw bitstream and emits 24-bit DoP words with no FIR / resampler / channel-convert; seek + reset drop the encoder's marker phase so the DAC re-locks cleanly. Prefetch always opens PCM (DoP never crossfades). decoder.rs — on a cold LoadAndPlay, `maybe_switch_dop_output` parses the DSD header for the DoP rate and asks the engine to re-open the output at it. On success it swaps in the fresh producer and runs `play_dop_track` (the bit-perfect twin of `play_track`: no crossfade / gapless / EQ / ReplayGain / speed / A-B, reusing `drain_commands` + `push_samples` for identical transport control). A refused DoP format falls back to the DSD → PCM path. engine.rs — `switch_output_for_track` rebuilds the output at the DoP format (or restores normal PCM after a DoP track), handing the new ring producer straight back to the decoder instead of via the SwapProducer channel. No-op when the format already matches, so PCM-to-PCM tracks pay nothing. Still no user setting to turn it on (`dsd_dop_enabled` stays false). Windows-only; needs on-hardware validation with a DoP DAC. --- src-tauri/crates/app/src/audio/crossfade.rs | 150 ++++++++++- src-tauri/crates/app/src/audio/decoder.rs | 270 ++++++++++++++++++++ src-tauri/crates/app/src/audio/engine.rs | 106 +++++++- 3 files changed, 520 insertions(+), 6 deletions(-) diff --git a/src-tauri/crates/app/src/audio/crossfade.rs b/src-tauri/crates/app/src/audio/crossfade.rs index e6cef0b7..f9cc650e 100644 --- a/src-tauri/crates/app/src/audio/crossfade.rs +++ b/src-tauri/crates/app/src/audio/crossfade.rs @@ -18,6 +18,7 @@ use symphonia::core::io::{MediaSource, MediaSourceStream}; use symphonia::core::meta::MetadataOptions; use symphonia::core::units::Time; +use waveflow_core::audio_format::dsd::dop::DsdToDop; use waveflow_core::audio_format::dsd::parser::{parse_dff, parse_dsf, DsdLayout}; use waveflow_core::audio_format::dsd::pcm::DsdToPcm; @@ -62,6 +63,21 @@ pub enum StreamBackend { /// default. taps: usize, }, + /// Native DSD passthrough via DoP (DSD over PCM), #495. No FIR, no + /// resampler, no channel convert: the raw 1-bit stream is repackaged + /// into 24-bit DoP words and shipped straight to a DoP-capable DAC. + /// Only reachable when the engine has (re)opened the output at the + /// exact DoP format — driven by [`ActiveStream::decode_dop_block`], + /// never by [`ActiveStream::decode_next`]. + Dop { + file: File, + layout: DsdLayout, + encoder: Box, + /// Bytes consumed from the data chunk so far (EOF + seek math). + bytes_read: u64, + /// Reusable scratch for raw DSD bytes pulled from disk. + dsd_scratch: Vec, + }, } /// All per-stream decoder state, packaged so the primary stream can be @@ -219,6 +235,7 @@ impl ActiveStream { source_id: Option, replay_gain_db: Option, dsd_taps: usize, + dop: bool, ) -> Result { let ext = path .extension() @@ -239,6 +256,7 @@ impl ActiveStream { source_id, replay_gain_db, dsd_taps, + dop, ); } @@ -348,7 +366,9 @@ impl ActiveStream { /// is parsed up-front so we know the bit rate, channel count and /// where the bitstream starts; the DsdToPcm converter is built /// from the layout so its FIR ring buffers match the channel - /// count. + /// count. `dop` selects the native DoP passthrough backend over the + /// DSD → PCM one (#495). + #[allow(clippy::too_many_arguments)] fn open_dsd( path: &Path, ext: &str, @@ -358,6 +378,7 @@ impl ActiveStream { source_id: Option, replay_gain_db: Option, dsd_taps: usize, + dop: bool, ) -> Result { let mut file = File::open(path).map_err(|e| format!("open: {e}"))?; let layout = match ext { @@ -366,11 +387,37 @@ impl ActiveStream { _ => return Err(format!("unexpected DSD extension: {ext}")), }; // Position the file cursor at the start of the bitstream so - // the first decode_next call streams from the right offset. + // the first decode call streams from the right offset. file.seek(SeekFrom::Start(layout.data_offset)) .map_err(|e| format!("dsd seek to data: {e}"))?; - let converter = Box::new(DsdToPcm::new_with_taps(&layout, dsd_taps)); let src_channels = layout.channels.count() as usize; + // DoP path (#495): repackage the raw bitstream instead of + // decoding it. The engine has already re-opened the output at + // this stream's DoP rate; here we just build the encoder and + // let `decode_dop_block` feed 24-bit words straight to the ring. + if dop { + let encoder = Box::new(DsdToDop::new(&layout)); + return Ok(Self { + backend: StreamBackend::Dop { + file, + layout, + encoder, + bytes_read: 0, + dsd_scratch: Vec::with_capacity(DSD_READ_CHUNK), + }, + // DoP is bit-exact: no resampler ever engages. + resampler: Resampler::Passthrough, + src_channels, + track_id, + duration_ms, + source_type, + source_id, + replay_gain_linear: 1.0, + src_sample_rate: 0, + playback_speed: 1.0, + }); + } + let converter = Box::new(DsdToPcm::new_with_taps(&layout, dsd_taps)); Ok(Self { backend: StreamBackend::Dsd { file, @@ -431,7 +478,7 @@ impl ActiveStream { StreamBackend::Symphonia { symphonia_track_id, .. } => Some(*symphonia_track_id), - StreamBackend::Dsd { .. } => None, + StreamBackend::Dsd { .. } | StreamBackend::Dop { .. } => None, } } @@ -441,7 +488,7 @@ impl ActiveStream { pub fn format_mut(&mut self) -> Option<&mut Box> { match &mut self.backend { StreamBackend::Symphonia { format, .. } => Some(format), - StreamBackend::Dsd { .. } => None, + StreamBackend::Dsd { .. } | StreamBackend::Dop { .. } => None, } } @@ -460,6 +507,7 @@ impl ActiveStream { } => { **converter = DsdToPcm::new_with_taps(layout, *taps); } + StreamBackend::Dop { encoder, .. } => encoder.reset(), } } @@ -526,6 +574,37 @@ impl ActiveStream { } *bytes_read = aligned; } + StreamBackend::Dop { + file, + layout, + bytes_read, + encoder, + .. + } => { + // Same byte math as the DSD path — align to a full + // interleave stride so the encoder never pairs bytes + // across a channel boundary — then drop the encoder's + // marker phase + pending byte so the DAC re-locks cleanly. + let bps = + (layout.sample_rate_hz as u128) * (layout.channels.count() as u128) / 8 / 1000; + let target = (ms as u128 * bps) as u64; + let stride = match layout.block_interleave { + Some(block_size) => (block_size as u64) * (layout.channels.count() as u64), + None => layout.channels.count() as u64, + }; + let aligned = target + .checked_div(stride) + .map(|q| q * stride) + .unwrap_or(target) + .min(layout.data_len_bytes); + let absolute = layout.data_offset + aligned; + if let Err(err) = file.seek(SeekFrom::Start(absolute)) { + tracing::warn!(?err, ms, "dop seek failed"); + return; + } + *bytes_read = aligned; + encoder.reset(); + } } } @@ -701,7 +780,68 @@ impl ActiveStream { .map_err(|e| format!("dsd resample: {e}"))?; Ok(false) } + // A DoP stream is driven exclusively by `decode_dop_block`, + // which produces raw 24-bit words with no resampling. Reaching + // the PCM decode path for it is a wiring bug, not a runtime + // condition — surface it rather than emit silence. + StreamBackend::Dop { .. } => { + Err("decode_next called on a DoP stream (use decode_dop_block)".into()) + } + } + } + + /// Decode one chunk of a DoP stream into interleaved 24-bit DoP + /// words (marker in bits 23..16, two DSD bytes below), #495. No FIR, + /// no resampler, no channel convert — the words go straight to the + /// ring for a bit-perfect transport to the DAC. Returns `Ok(true)` + /// at EOF, `Ok(false)` after a successful block. + /// + /// Only valid on a [`StreamBackend::Dop`]; other backends return an + /// error (the caller picks `play_dop_track` vs `play_track` off the + /// same DoP flag, so this never happens at runtime). + pub fn decode_dop_block(&mut self, out: &mut Vec) -> Result { + let StreamBackend::Dop { + file, + layout, + encoder, + bytes_read, + dsd_scratch, + } = &mut self.backend + else { + return Err("decode_dop_block called on a non-DoP stream".into()); + }; + + if *bytes_read >= layout.data_len_bytes { + return Ok(true); + } + // Round the read down to a whole interleave stride so the encoder + // never pairs bytes across a channel boundary (same reasoning as + // the DSD → PCM path). + let stride = match layout.block_interleave { + Some(block) => (block as u64) * (layout.channels.count() as u64), + None => layout.channels.count() as u64, + }; + let remaining = layout.data_len_bytes - *bytes_read; + let raw_want = remaining.min(DSD_READ_CHUNK as u64); + let aligned = raw_want + .checked_div(stride) + .map(|q| q * stride) + .unwrap_or(raw_want); + if aligned == 0 { + // Sub-stride residual at EOF — dropping < ~12 ms is + // imperceptible and avoids a torn final frame. + *bytes_read = layout.data_len_bytes; + return Ok(true); + } + dsd_scratch.resize(aligned as usize, 0); + let read = file.read(dsd_scratch).map_err(|e| format!("dop read: {e}"))?; + if read == 0 { + return Ok(true); } + *bytes_read += read as u64; + dsd_scratch.truncate(read); + encoder.encode_block(dsd_scratch, out); + Ok(false) } } diff --git a/src-tauri/crates/app/src/audio/decoder.rs b/src-tauri/crates/app/src/audio/decoder.rs index 48cd30ef..de1a939b 100644 --- a/src-tauri/crates/app/src/audio/decoder.rs +++ b/src-tauri/crates/app/src/audio/decoder.rs @@ -9,7 +9,9 @@ //! Commands are polled between packets via `cmd_rx.try_recv()` so //! pause / stop / seek feel responsive even during long tracks. +use std::fs::File; use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; use std::sync::atomic::Ordering; use std::sync::Arc; use std::thread::JoinHandle; @@ -21,12 +23,15 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::mpsc::UnboundedSender; +use waveflow_core::audio_format::dsd::parser::{parse_dff, parse_dsf}; use super::analytics::AnalyticsMsg; use super::crossfade::{equal_power_gains, ActiveStream}; use super::engine::AudioCmd; use super::events::{emit_radio_metadata, RadioMetadataPayload}; +use super::output::DopFormat; use super::state::{PlayerState, SharedPlayback}; +use super::AudioEngine; /// Minimum interval between `player:position` events emitted during /// playback. Keeps UI traffic bounded to ~4 Hz regardless of packet @@ -334,6 +339,16 @@ fn decoder_loop( shared.base_offset_ms.store(start_ms, Ordering::Relaxed); shared.current_track_id.store(track_id, Ordering::Release); + // Native DSD via DoP (#495): before opening the stream, + // ask the engine to re-open the output at this track's + // DoP rate when the file is DSD and the opt-in is on. It + // hands back a fresh ring producer when it actually + // rebuilt (we swap ours) and reports whether DoP really + // engaged — a DAC that refused the format falls back to + // the ordinary DSD → PCM path, and a non-DSD track after + // a DoP one rebuilds the output back to normal PCM. + let dop_engaged = maybe_switch_dop_output(&app, &shared, &path, producer); + let stream = match ActiveStream::open( &path, track_id, @@ -342,6 +357,7 @@ fn decoder_loop( source_id, replay_gain_db, shared.dsd_taps.load(Ordering::Acquire) as usize, + dop_engaged, ) { Ok(s) => s, Err(err) => { @@ -352,6 +368,27 @@ fn decoder_loop( } }; + if dop_engaged { + let outcome = play_dop_track( + stream, + start_ms, + producer, + &shared, + cmd_rx, + &app, + &mut pending_cmd, + analytics_tx, + ); + handle_playback_outcome( + outcome, + &shared, + &app, + analytics_tx, + Some(path.display().to_string()), + ); + continue; + } + let outcome = play_track( stream, start_ms, @@ -603,6 +640,234 @@ pub struct FinishedTrack { /// needs) and folding them into a struct just to satisfy a lint would /// obscure the call site without changing what the function does. #[allow(clippy::too_many_arguments)] +/// Resolve the DoP output format a DSD file would need — `dsd_rate / 16` +/// at the container's channel count — by parsing just its header. Cheap +/// (a few reads, no decode). `None` for a non-DSD path or an unreadable +/// header (the caller then stays on the PCM path). +fn dop_format_for(path: &Path) -> Option { + let ext = path.extension().and_then(|s| s.to_str())?.to_ascii_lowercase(); + let mut file = File::open(path).ok()?; + let layout = match ext.as_str() { + "dsf" => parse_dsf(&mut file).ok()?, + "dff" => parse_dff(&mut file).ok()?, + _ => return None, + }; + Some(DopFormat { + sample_rate: layout.sample_rate_hz / 16, + channels: layout.channels.count() as u16, + }) +} + +/// Reconcile the output format with the track about to play (#495) and +/// report whether DoP engaged. +/// +/// - DSD file + `audio.dsd_dop` on → ask the engine to re-open the +/// exclusive output at the track's DoP rate. On success we swap in the +/// fresh ring producer and return `true`; if the DAC refused the DoP +/// format the engine transparently rebuilt a normal PCM output and we +/// return `false` (the caller opens the DSD → PCM path). +/// - Any other track → ask the engine to restore the normal PCM output +/// if the previous track left it in DoP mode; returns `false`. +/// +/// The engine only rebuilds (and only then hands back a producer) when +/// the format actually changes, so an ordinary PCM-to-PCM track pays +/// nothing here. +fn maybe_switch_dop_output( + app: &AppHandle, + shared: &SharedPlayback, + path: &Path, + producer: &mut Producer, +) -> bool { + let want = if shared.dsd_dop_enabled.load(Ordering::Acquire) { + dop_format_for(path) + } else { + None + }; + let Some(engine) = app.try_state::>() else { + // No engine in managed state (shouldn't happen outside teardown) + // — stay on the current output and the PCM path. + return false; + }; + match engine.switch_output_for_track(want) { + Ok((new_producer, engaged)) => { + if let Some(p) = new_producer { + *producer = p; + } + engaged + } + Err(err) => { + tracing::warn!(%err, "DoP output switch failed; staying on current output (PCM)"); + false + } + } +} + +/// DoP playback loop (#495). The bit-perfect twin of [`play_track`]: +/// no crossfade, no gapless, no EQ / ReplayGain / speed / A-B loop — +/// every one of those would mutate the 24-bit DoP words and corrupt the +/// embedded DSD. It just decodes DoP blocks and pushes them straight to +/// the ring, reusing [`drain_commands`] (pause / stop / seek / next) and +/// [`push_samples`] (backpressure) so transport control stays identical. +fn play_dop_track( + mut stream: ActiveStream, + initial_start_ms: u64, + producer: &mut Producer, + shared: &SharedPlayback, + cmd_rx: &Receiver, + app: &AppHandle, + pending_cmd: &mut Option, + _analytics_tx: &UnboundedSender, +) -> Result<(PlaybackEnd, u64, FinishedTrack), String> { + if shared.sample_rate.load(Ordering::Relaxed) == 0 { + return Err("dop output not initialized (sample_rate=0)".into()); + } + if initial_start_ms > 0 { + stream.seek_ms(initial_start_ms); + stream.reset_decoder(); + } + + tracing::info!( + rate = shared.sample_rate.load(Ordering::Relaxed), + channels = shared.channels.load(Ordering::Relaxed), + track_id = stream.track_id, + "dop decoding start" + ); + + // DoP never crossfades, so no prefetch is ever requested — this stays + // `None`. It only exists to satisfy the shared helpers' signatures; + // anything they store is dropped when this function returns. + let mut no_prefetch: Option = None; + let mut dop_words: Vec = Vec::with_capacity(16 * 1024); + let mut ring_scratch: Vec = Vec::with_capacity(16 * 1024); + let mut last_position_emit = Instant::now(); + let mut ended_naturally = false; + + transition_state(shared, app, PlayerState::Playing, Some(stream.track_id)); + + 'pkt: loop { + match drain_commands(cmd_rx, shared, app, stream.track_id, pending_cmd, &mut no_prefetch) { + ControlFlow::Continue => {} + ControlFlow::Break => break 'pkt, + ControlFlow::Shutdown => { + transition_state(shared, app, PlayerState::Idle, Some(stream.track_id)); + return Ok(( + PlaybackEnd::Interrupted, + shared.session_listened_ms(), + finished_from(&stream), + )); + } + ControlFlow::LoadNext => { + return Ok(( + PlaybackEnd::Interrupted, + shared.session_listened_ms(), + finished_from(&stream), + )); + } + ControlFlow::Seek(ms) => { + stream.seek_ms(ms); + stream.reset_decoder(); + reset_clock(shared, ms); + drain_ring_silent(producer, shared); + let _ = app.emit(EVENT_POSITION, PositionPayload { ms }); + last_position_emit = Instant::now(); + continue; + } + } + no_prefetch = None; + + dop_words.clear(); + match stream.decode_dop_block(&mut dop_words) { + Ok(true) => { + ended_naturally = true; + break 'pkt; + } + Ok(false) => {} + Err(err) => { + let _ = app.emit(EVENT_ERROR, ErrorPayload { message: err.clone() }); + return Err(err); + } + } + if dop_words.is_empty() { + continue; + } + + // Reinterpret each 24-bit DoP word as an f32 bit pattern. The ring + // is a pure byte pipe — `from_bits` here and `to_bits` in the DoP + // event loop round-trip exactly, with no arithmetic in between. + ring_scratch.clear(); + ring_scratch.extend(dop_words.iter().map(|&w| f32::from_bits(w))); + + match push_samples( + &ring_scratch, + producer, + cmd_rx, + shared, + app, + stream.track_id, + pending_cmd, + &mut no_prefetch, + ) { + PushOutcome::Ok => {} + PushOutcome::Stop => break 'pkt, + PushOutcome::Shutdown => { + transition_state(shared, app, PlayerState::Idle, Some(stream.track_id)); + return Ok(( + PlaybackEnd::Interrupted, + shared.session_listened_ms(), + finished_from(&stream), + )); + } + PushOutcome::LoadNext => { + return Ok(( + PlaybackEnd::Interrupted, + shared.session_listened_ms(), + finished_from(&stream), + )); + } + PushOutcome::Seek(ms) => { + stream.seek_ms(ms); + stream.reset_decoder(); + reset_clock(shared, ms); + drain_ring_silent(producer, shared); + let _ = app.emit(EVENT_POSITION, PositionPayload { ms }); + last_position_emit = Instant::now(); + continue; + } + } + no_prefetch = None; + + if last_position_emit.elapsed() >= POSITION_EMIT_INTERVAL + && shared.state() == PlayerState::Playing + { + let _ = app.emit( + EVENT_POSITION, + PositionPayload { + ms: shared.current_position_ms(), + }, + ); + last_position_emit = Instant::now(); + } + } + + let listened_ms = shared.session_listened_ms(); + let finished = finished_from(&stream); + if ended_naturally { + let completed = listened_ms + 2000 >= stream.duration_ms && stream.duration_ms > 0; + let _ = app.emit( + EVENT_TRACK_ENDED, + TrackEndedPayload { + track_id: stream.track_id, + completed, + listened_ms, + }, + ); + transition_state(shared, app, PlayerState::Ended, Some(stream.track_id)); + Ok((PlaybackEnd::Natural, listened_ms, finished)) + } else { + Ok((PlaybackEnd::Interrupted, listened_ms, finished)) + } +} + fn play_track( initial_stream: ActiveStream, initial_start_ms: u64, @@ -1499,6 +1764,11 @@ fn store_next( source_id, replay_gain_db, dsd_taps, + // Prefetch is a crossfade / gapless concern; DoP never fades, so + // a prefetched stream is always opened on the PCM path. A DSD + // track that should play as DoP reaches that mode through the + // cold LoadAndPlay in `decoder_loop`, not here. + false, ) { Ok(mut s) => { // Prefetched stream needs the active speed too so its diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index 80a350c8..084ea14a 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -19,7 +19,7 @@ use crate::error::{AppError, AppResult}; use super::analytics::{analytics_task, AnalyticsMsg}; use super::decoder::spawn_decoder_thread; -use super::output::{spawn_output_with_mode, OutputHandle}; +use super::output::{spawn_output_with_mode, DopFormat, OutputHandle}; use super::state::SharedPlayback; /// Commands accepted by the decoder thread. @@ -724,6 +724,110 @@ impl AudioEngine { } } + /// Reconcile the output format with the track the decoder is about to + /// play, for native DSD via DoP (#495). Unlike [`Self::force_rebuild_output`] + /// this hands the fresh ring producer **directly back to the caller** + /// (the decoder thread, mid-load) instead of pushing it through the + /// `SwapProducer` channel — the decoder is about to write the new + /// track's samples to it and there's no old stream to keep feeding. + /// + /// - `dop = Some(fmt)`: open the exclusive output at that exact DoP + /// rate / channels. If the DAC refuses, transparently fall back to a + /// normal PCM output so the caller can play the DSD → PCM path. + /// - `dop = None`: restore the normal (preference-driven) PCM output + /// if the previous track left the device in DoP mode. + /// + /// Returns `(producer, dop_engaged)`. `producer` is `Some` only when a + /// rebuild actually happened — an unchanged format returns `None` so + /// an ordinary PCM-to-PCM track costs nothing. `dop_engaged` tells the + /// caller whether to open the stream as DoP or DSD → PCM. + pub(crate) fn switch_output_for_track( + &self, + dop: Option, + ) -> AppResult<(Option>, bool)> { + use std::sync::atomic::Ordering; + + let mut guard = self + .output + .lock() + .map_err(|_| AppError::Audio("output mutex poisoned".into()))?; + + let has_output = guard.is_some(); + let current_dop = guard.as_ref().and_then(|h| h.dop_rate); + let want_dop = dop.map(|d| d.sample_rate); + + // Already in the right shape: nothing to do. An ordinary PCM track + // following another PCM track lands here and pays nothing. + if has_output && current_dop == want_dop { + return Ok((None, dop.is_some())); + } + + // Capture the pinned device + exclusive preference before dropping + // the old handle. A DoP (exclusive) open can't proceed while the + // previous exclusive client still holds the device, so release it + // first (#322 reasoning) — this path always replaces the stream. + let device = guard.as_ref().and_then(|h| h.device_name.clone()); + let pref_exclusive = self.wasapi_exclusive.load(Ordering::Relaxed); + if let Some(old) = guard.take() { + old.stop(); + } + + // Try the requested DoP format first. + if let Some(dop_fmt) = dop { + match spawn_output_with_mode( + self.shared.clone(), + self.app.clone(), + device.clone(), + pref_exclusive, + Some(dop_fmt), + ) { + Ok((producer, handle)) => { + self.wasapi_exclusive_active + .store(handle.wasapi_exclusive, Ordering::Release); + *guard = Some(handle); + let _ = self.app.emit("player:audio-mode-changed", ()); + tracing::info!( + rate = dop_fmt.sample_rate, + channels = dop_fmt.channels, + "DoP output engaged" + ); + return Ok((Some(producer), true)); + } + Err(err) => { + tracing::warn!( + %err, + rate = dop_fmt.sample_rate, + "DoP output refused by device; falling back to DSD -> PCM" + ); + // Fall through to a normal PCM open. + } + } + } + + // Normal PCM output: DoP wasn't requested, or was refused. + match spawn_output_with_mode( + self.shared.clone(), + self.app.clone(), + device, + pref_exclusive, + None, + ) { + Ok((producer, handle)) => { + self.wasapi_exclusive_active + .store(handle.wasapi_exclusive, Ordering::Release); + *guard = Some(handle); + let _ = self.app.emit("player:audio-mode-changed", ()); + Ok((Some(producer), false)) + } + Err(err) => { + // No output at all now — surface the loss like the other + // rebuild paths so the UI doesn't think playback is live. + self.publish_output_lost_if_gone(&guard); + Err(err) + } + } + } + /// Internal helper: rebuild the output stream against the given /// (device_name, exclusive) tuple, bypassing the same-device /// no-op check. Shared by [`Self::try_rebuild_after_device_error`] From 46aae7090fe7ed53936a318e99296294997d2123 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:01:40 +0200 Subject: [PATCH 04/12] feat(audio): add audio.dsd_dop setting command + hydration (#495) `player_set_dsd_dop` persists the opt-in to `profile_setting['audio.dsd_dop']` and mirrors it into `SharedPlayback.dsd_dop_enabled`, following the DSD precision pattern. `player_get_state` hydration resolves the row to a definite bool on every call (default OFF, reset on profile switch), and `player_get_audio_settings` surfaces `dsd_dop` for the Settings view. Handler registered in lib.rs. Frontend wiring follows. --- src-tauri/crates/app/src/commands/player.rs | 58 +++++++++++++++++++++ src-tauri/crates/app/src/lib.rs | 1 + 2 files changed, 59 insertions(+) diff --git a/src-tauri/crates/app/src/commands/player.rs b/src-tauri/crates/app/src/commands/player.rs index 2bce3315..7b1c5da1 100644 --- a/src-tauri/crates/app/src/commands/player.rs +++ b/src-tauri/crates/app/src/commands/player.rs @@ -455,6 +455,25 @@ pub async fn player_get_state( .shared() .dsd_taps .store(dsd_taps, std::sync::atomic::Ordering::Release); + // Native DSD via DoP defaults OFF (#495) — only override the + // boot-time default when an explicit `true` row is found, and + // reset to false otherwise so a profile switch can't leak the + // previous profile's opt-in. + { + let dop = sqlx::query_scalar::<_, String>( + "SELECT value FROM profile_setting WHERE key = 'audio.dsd_dop'", + ) + .fetch_optional(&*pool) + .await + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false); + engine + .shared() + .dsd_dop_enabled + .store(dop, std::sync::atomic::Ordering::Release); + } if let Ok(Some(v)) = sqlx::query_scalar::<_, String>( "SELECT value FROM profile_setting WHERE key = 'audio.replaygain'", ) @@ -985,6 +1004,39 @@ pub async fn player_set_dsd_precision( Ok(()) } +/// Toggle native DSD output via DoP (DSD over PCM), #495. When on AND the +/// active output is WASAPI Exclusive AND the DAC accepts the DoP format, +/// a `.dsf` / `.dff` track is shipped as raw 1-bit DoP frames instead of +/// being converted to PCM — the DAC decodes the DSD natively. Any of +/// those conditions failing falls back silently to DSD → PCM, so it's +/// safe to leave on. Takes effect on the next track open. Persisted in +/// `profile_setting['audio.dsd_dop']`, default OFF. Windows-only in +/// practice (DoP needs exclusive mode). +#[tauri::command] +pub async fn player_set_dsd_dop( + state: tauri::State<'_, AppState>, + engine: tauri::State<'_, Arc>, + enabled: bool, +) -> AppResult<()> { + engine + .shared() + .dsd_dop_enabled + .store(enabled, std::sync::atomic::Ordering::Release); + if let Ok(pool) = state.require_profile_pool().await { + let now = chrono::Utc::now().timestamp_millis(); + let _ = sqlx::query( + "INSERT INTO profile_setting (key, value, value_type, updated_at) + VALUES ('audio.dsd_dop', ?, 'bool', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(if enabled { "true" } else { "false" }) + .bind(now) + .execute(&*pool) + .await; + } + Ok(()) +} + /// Toggle ReplayGain — multiply each track by its analyzed gain to /// even out perceived loudness across the library. /// Persisted in `profile_setting['audio.replaygain']`. @@ -1402,6 +1454,9 @@ pub async fn player_get_audio_settings( .gapless_enabled .load(std::sync::atomic::Ordering::Relaxed); let dsd_taps = shared.dsd_taps.load(std::sync::atomic::Ordering::Relaxed); + let dsd_dop = shared + .dsd_dop_enabled + .load(std::sync::atomic::Ordering::Relaxed); let mut crossfade_ms: i64 = 0; if let Ok(pool) = state.require_profile_pool().await { @@ -1422,6 +1477,7 @@ pub async fn player_get_audio_settings( replaygain, gapless, dsd_taps, + dsd_dop, }) } @@ -1434,6 +1490,8 @@ pub struct AudioSettingsSnapshot { pub gapless: bool, /// Active DSD → PCM FIR tap count (256 / 1024 / 2048). pub dsd_taps: u32, + /// Native DSD via DoP opt-in (#495), default false. + pub dsd_dop: bool, } /// One row in the output-device picker that powers the PlayerBar diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index d7a345ce..453eec17 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -858,6 +858,7 @@ pub fn run() { commands::player::player_get_dynamic_crossfade, commands::player::player_set_replaygain, commands::player::player_set_dsd_precision, + commands::player::player_set_dsd_dop, commands::player::player_get_eq, commands::player::player_set_eq_enabled, commands::player::player_set_eq_band, From 041a4515ba571ccdc7374c7d7d383434b44109d9 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:09:46 +0200 Subject: [PATCH 05/12] feat(audio): expose dop toggle, pipeline pill and i18n (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings -> Playback: a "Native DSD (DoP)" toggle next to DSD precision, wired through `playerSetDsdDop` / `player_get_audio_settings` (optimistic update + rollback, default OFF). - `player_get_state` now reports `dop_active` — whether DoP *actually* engaged (the DAC accepted it), sourced from `AudioEngine::current_output_is_dop`, not just the opt-in. The AudioPipelinePopover shows a distinct "Native DSD" chip and counts the stream as bit-perfect when it's on, suppressing the spurious resample/downmix flags the nominal DoP rate would otherwise trip. - New i18n keys `settings.dsdDop.*` and `playerBar.pipeline.chip.dopNative` across all 17 locales (DoP / DSD / DAC / WASAPI kept verbatim). --- src-tauri/crates/app/src/audio/engine.rs | 12 ++++++ src-tauri/crates/app/src/commands/player.rs | 14 ++++++- .../player/AudioPipelinePopover.tsx | 23 +++++++++++- src/components/views/SettingsView.tsx | 37 +++++++++++++++++++ src/i18n/locales/ar.json | 7 +++- src/i18n/locales/de.json | 7 +++- src/i18n/locales/en.json | 7 +++- src/i18n/locales/es.json | 7 +++- src/i18n/locales/fr.json | 7 +++- src/i18n/locales/hi.json | 7 +++- src/i18n/locales/id.json | 7 +++- src/i18n/locales/it.json | 7 +++- src/i18n/locales/ja.json | 7 +++- src/i18n/locales/ko.json | 7 +++- src/i18n/locales/nl.json | 7 +++- src/i18n/locales/pt-BR.json | 7 +++- src/i18n/locales/pt.json | 7 +++- src/i18n/locales/ru.json | 7 +++- src/i18n/locales/tr.json | 7 +++- src/i18n/locales/zh-CN.json | 7 +++- src/i18n/locales/zh-TW.json | 7 +++- src/lib/tauri/player.ts | 16 ++++++++ 22 files changed, 200 insertions(+), 21 deletions(-) diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index 084ea14a..9b77d7ef 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -514,6 +514,18 @@ impl AudioEngine { .and_then(|guard| guard.as_ref().and_then(|h| h.device_name.clone())) } + /// True when the active output is currently carrying a native DSD + /// stream via DoP (#495). Reflects what actually engaged — a DAC + /// that refused the DoP format leaves this `false` even with the + /// opt-in on. Drives the "DSD natif" pill in the pipeline popover. + pub fn current_output_is_dop(&self) -> bool { + self.output + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|h| h.dop_rate.is_some())) + .unwrap_or(false) + } + /// Hot-swap the cpal output device without restarting the decoder /// or the analytics task. /// diff --git a/src-tauri/crates/app/src/commands/player.rs b/src-tauri/crates/app/src/commands/player.rs index 7b1c5da1..c2fc5417 100644 --- a/src-tauri/crates/app/src/commands/player.rs +++ b/src-tauri/crates/app/src/commands/player.rs @@ -59,6 +59,9 @@ pub struct PlayerStateSnapshot { pub shuffle: bool, pub repeat_mode: String, pub current_track: Option, + /// True when the active output is shipping native DSD via DoP + /// (#495) — reflects what really engaged, not just the opt-in. + pub dop_active: bool, } /// Subset of [`crate::queue::QueueTrack`] flattened into the shape @@ -96,6 +99,7 @@ impl PlayerStateSnapshot { shuffle: bool, repeat_mode: queue::RepeatMode, current_track: Option, + dop_active: bool, ) -> Self { Self { state: shared.state().as_str().to_string(), @@ -108,6 +112,7 @@ impl PlayerStateSnapshot { shuffle, repeat_mode: repeat_mode.as_str().to_string(), current_track, + dop_active, } } } @@ -620,8 +625,13 @@ pub async fn player_get_state( } Err(_) => (false, queue::RepeatMode::Off, None, 0), }; - let mut snapshot = - PlayerStateSnapshot::from_shared(engine.shared(), shuffle, repeat_mode, current_track); + let mut snapshot = PlayerStateSnapshot::from_shared( + engine.shared(), + shuffle, + repeat_mode, + current_track, + engine.current_output_is_dop(), + ); // When the engine is Idle but we resolved a resume point, use the // persisted position instead of the (zero) live counter. if snapshot.state == "idle" && snapshot.position_ms == 0 { diff --git a/src/components/player/AudioPipelinePopover.tsx b/src/components/player/AudioPipelinePopover.tsx index 7e899072..862a32d4 100644 --- a/src/components/player/AudioPipelinePopover.tsx +++ b/src/components/player/AudioPipelinePopover.tsx @@ -21,6 +21,8 @@ interface PipelineSnapshot { normalize: boolean; replaygain: boolean; mono: boolean; + /** Native DSD via DoP actually engaged for the current track (#495). */ + dopActive: boolean; } /** @@ -105,6 +107,7 @@ export function AudioPipelinePopover({ track }: AudioPipelinePopoverProps) { normalize: audioSettings.normalize, replaygain: audioSettings.replaygain, mono: audioSettings.mono, + dopActive: stateSnap.dop_active, }); } catch (err) { console.error("[AudioPipelinePopover] hydrate failed", err); @@ -130,12 +133,21 @@ export function AudioPipelinePopover({ track }: AudioPipelinePopoverProps) { // isn't bit-perfect — used to decide whether to surface the green // "Bit-perfect" pill at the bottom. const isDsd = (track.codec ?? "").toUpperCase().includes("DSD"); + // Native DSD via DoP (#495): the DAC decodes the 1-bit stream itself, + // so nothing on our side converts it — it counts as bit-perfect and + // shows a distinct pill instead of the "DSD → PCM" convert chip. + const isDopNative = isDsd && (snap?.dopActive ?? false); + // A DoP stream reaches the DAC untouched, so the nominal rate/channel + // comparison below (DoP ships at dsd_rate/16, which never equals the + // stored DSD rate) must not be read as resampling / downmixing. const isResampling = + !isDopNative && snap != null && track.sample_rate != null && snap.outputSampleRate > 0 && snap.outputSampleRate !== track.sample_rate; const isDownmixing = + !isDopNative && snap != null && track.channels != null && snap.outputChannels > 0 && @@ -147,7 +159,8 @@ export function AudioPipelinePopover({ track }: AudioPipelinePopoverProps) { const isMono = snap?.mono ?? false; const isBitPerfect = snap != null && - !isDsd && + // Native DoP is bit-perfect; only DSD → PCM conversion breaks it. + (!isDsd || isDopNative) && !isResampling && !isDownmixing && !isSpeedShifted && @@ -158,7 +171,13 @@ export function AudioPipelinePopover({ track }: AudioPipelinePopoverProps) { const chips: Array<{ key: string; label: string; tone: "dsp" | "convert" }> = []; - if (isDsd) + if (isDopNative) + chips.push({ + key: "dop", + label: t("playerBar.pipeline.chip.dopNative"), + tone: "dsp", + }); + else if (isDsd) chips.push({ key: "dsd", label: t("playerBar.pipeline.chip.dsdToPcm"), diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index afa05432..a86b74e5 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -67,6 +67,7 @@ import { playerSetGapless, playerSetReplayGain, playerSetDsdPrecision, + playerSetDsdDop, DSD_PRECISION_TAPS, type DsdPrecisionTaps, } from "../../lib/tauri/player"; @@ -1221,6 +1222,7 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { const [replayGain, setReplayGain] = useState(false); const [gapless, setGapless] = useState(true); const [dsdTaps, setDsdTaps] = useState(256); + const [dsdDop, setDsdDop] = useState(false); // Integrations const [lastfmKey, setLastfmKey] = useState(""); @@ -1645,6 +1647,7 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { ? (s.dsd_taps as DsdPrecisionTaps) : 256, ); + setDsdDop(s.dsd_dop); }) .catch((err) => console.error("[Settings] audio settings load failed", err), @@ -1684,6 +1687,15 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { [dsdTaps], ); + const handleToggleDsdDop = useCallback(() => { + const next = !dsdDop; + setDsdDop(next); // optimistic + playerSetDsdDop(next).catch((err) => { + console.error("[Settings] set DSD DoP failed", err); + setDsdDop(!next); // rollback + }); + }, [dsdDop]); + // Smart crossfade — skip the fade between two tracks of the same // album so concept records / live sets hand off naturally. Persisted // backend-side; default OFF (opinionated behaviour, opt-in). @@ -2263,6 +2275,31 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { + {/* Native DSD via DoP (DSD over PCM). Windows / WASAPI + Exclusive + DoP-capable DAC only; off by default. */} +
+
+
+ +
+ {/* Normaliser le volume */}
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c736a98e..0e438ab7 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1218,7 +1218,8 @@ "replayGain": "ReplayGain", "normalize": "تسوية", "mono": "أحادي", - "speed": "السرعة {{value}}×" + "speed": "السرعة {{value}}×", + "dopNative": "DSD أصلي" } } }, @@ -2183,6 +2184,10 @@ "usage": "{{size}} مستخدَمة · {{files}} Canvas في التخزين المؤقت", "clear": "مسح الذاكرة المؤقتة", "clearConfirm": "تأكيد؟" + }, + "dsdDop": { + "title": "DSD أصلي (DoP)", + "subtitle": "يرسل تدفق DSD كما هو إلى الـDAC عبر DoP (DSD over PCM) بدلاً من تحويله إلى PCM — فك ترميز أصلي بدقة بتّية تامة. يتطلب مخرج WASAPI Exclusive وDAC متوافقًا مع DoP؛ وإلا يعود إلى DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 910a3af5..ab900cfd 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalisierung", "mono": "Mono", - "speed": "Geschwindigkeit {{value}}×" + "speed": "Geschwindigkeit {{value}}×", + "dopNative": "Natives DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} belegt · {{files}} Canvas im Cache", "clear": "Cache leeren", "clearConfirm": "Bestätigen?" + }, + "dsdDop": { + "title": "Natives DSD (DoP)", + "subtitle": "Sendet den DSD-Stream unverändert per DoP (DSD over PCM) an den DAC, statt ihn in PCM zu wandeln — bitgenaue native Dekodierung. Erfordert WASAPI-Exclusive-Ausgabe und einen DoP-fähigen DAC; andernfalls Rückfall auf DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 816a1fd8..509faa87 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalize", "mono": "Mono", - "speed": "Speed {{value}}×" + "speed": "Speed {{value}}×", + "dopNative": "Native DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} used · {{files}} Canvas cached", "clear": "Clear cache", "clearConfirm": "Confirm?" + }, + "dsdDop": { + "title": "Native DSD (DoP)", + "subtitle": "Send the DSD stream as-is to the DAC via DoP (DSD over PCM) instead of converting it to PCM — bit-perfect native decoding. Requires WASAPI Exclusive output and a DoP-capable DAC; falls back to DSD → PCM otherwise." } }, "spotify": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f48d53f1..6053a7bd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalización", "mono": "Mono", - "speed": "Velocidad {{value}}×" + "speed": "Velocidad {{value}}×", + "dopNative": "DSD nativo" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} usados · {{files}} Canvas en caché", "clear": "Vaciar caché", "clearConfirm": "¿Confirmar?" + }, + "dsdDop": { + "title": "DSD nativo (DoP)", + "subtitle": "Envía el flujo DSD tal cual al DAC mediante DoP (DSD over PCM) en lugar de convertirlo a PCM: decodificación nativa bit-perfect. Requiere salida WASAPI Exclusive y un DAC compatible con DoP; de lo contrario, recurre a DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3a815ac3..22606c14 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalisation", "mono": "Mono", - "speed": "Vitesse {{value}}×" + "speed": "Vitesse {{value}}×", + "dopNative": "DSD natif" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} utilisés · {{files}} Canvas en cache", "clear": "Vider le cache", "clearConfirm": "Confirmer ?" + }, + "dsdDop": { + "title": "DSD natif (DoP)", + "subtitle": "Envoie le flux DSD tel quel au DAC via DoP (DSD over PCM) au lieu de le convertir en PCM — décodage natif bit-perfect. Nécessite la sortie WASAPI Exclusive et un DAC compatible DoP ; repli automatique vers DSD → PCM sinon." } }, "spotify": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 0a5b8f34..82bc5a8b 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "नॉर्मलाइज़", "mono": "मोनो", - "speed": "गति {{value}}×" + "speed": "गति {{value}}×", + "dopNative": "नेटिव DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} उपयोग · {{files}} Canvas कैश किए गए", "clear": "कैश साफ़ करें", "clearConfirm": "पुष्टि करें?" + }, + "dsdDop": { + "title": "नेटिव DSD (DoP)", + "subtitle": "DSD स्ट्रीम को PCM में बदलने के बजाय DoP (DSD over PCM) के माध्यम से DAC को ज्यों-का-त्यों भेजता है — बिट-परफेक्ट नेटिव डिकोडिंग। इसके लिए WASAPI Exclusive आउटपुट और DoP-समर्थित DAC आवश्यक है; अन्यथा DSD → PCM पर वापस चला जाता है।" } }, "spotify": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index d7e9c32f..54c56772 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalisasi", "mono": "Mono", - "speed": "Kecepatan {{value}}×" + "speed": "Kecepatan {{value}}×", + "dopNative": "DSD asli" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} terpakai · {{files}} Canvas di cache", "clear": "Bersihkan cache", "clearConfirm": "Konfirmasi?" + }, + "dsdDop": { + "title": "DSD asli (DoP)", + "subtitle": "Mengirim aliran DSD apa adanya ke DAC melalui DoP (DSD over PCM) alih-alih mengonversinya ke PCM — dekode asli bit-perfect. Memerlukan keluaran WASAPI Exclusive dan DAC yang mendukung DoP; jika tidak, kembali ke DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ef153c20..cf6d125e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalizzazione", "mono": "Mono", - "speed": "Velocità {{value}}×" + "speed": "Velocità {{value}}×", + "dopNative": "DSD nativo" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} usati · {{files}} Canvas in cache", "clear": "Svuota cache", "clearConfirm": "Confermare?" + }, + "dsdDop": { + "title": "DSD nativo (DoP)", + "subtitle": "Invia il flusso DSD così com'è al DAC tramite DoP (DSD over PCM) invece di convertirlo in PCM — decodifica nativa bit-perfect. Richiede l'uscita WASAPI Exclusive e un DAC compatibile con DoP; altrimenti ripiega su DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2dd5d57d..b9f96487 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "正規化", "mono": "モノラル", - "speed": "速度 {{value}}×" + "speed": "速度 {{value}}×", + "dopNative": "ネイティブDSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} 使用中 · {{files}} 件の Canvas をキャッシュ", "clear": "キャッシュを消去", "clearConfirm": "確認しますか?" + }, + "dsdDop": { + "title": "ネイティブDSD(DoP)", + "subtitle": "DSDストリームをPCMに変換せず、DoP(DSD over PCM)でそのままDACに送信します — ビットパーフェクトなネイティブデコード。WASAPI排他出力とDoP対応DACが必要です。満たさない場合はDSD → PCMにフォールバックします。" } }, "scanProgress": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d3e470f8..36161b6d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "음량 평준화", "mono": "모노", - "speed": "속도 {{value}}×" + "speed": "속도 {{value}}×", + "dopNative": "네이티브 DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} 사용 · {{files}}개 Canvas 캐시됨", "clear": "캐시 지우기", "clearConfirm": "확인할까요?" + }, + "dsdDop": { + "title": "네이티브 DSD (DoP)", + "subtitle": "DSD 스트림을 PCM으로 변환하지 않고 DoP(DSD over PCM)로 DAC에 그대로 전송합니다 — 비트 퍼펙트 네이티브 디코딩. WASAPI Exclusive 출력과 DoP 지원 DAC가 필요하며, 그렇지 않으면 DSD → PCM으로 대체됩니다." } }, "spotify": { diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 14f9e968..e946f639 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normaliseren", "mono": "Mono", - "speed": "Snelheid {{value}}×" + "speed": "Snelheid {{value}}×", + "dopNative": "Native DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} gebruikt · {{files}} Canvas in cache", "clear": "Cache wissen", "clearConfirm": "Bevestigen?" + }, + "dsdDop": { + "title": "Native DSD (DoP)", + "subtitle": "Stuurt de DSD-stream ongewijzigd via DoP (DSD over PCM) naar de DAC in plaats van deze naar PCM te converteren — bit-perfect native decodering. Vereist WASAPI Exclusive-uitvoer en een DoP-compatibele DAC; anders terugval naar DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index d87b4c58..84734403 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalização", "mono": "Mono", - "speed": "Velocidade {{value}}×" + "speed": "Velocidade {{value}}×", + "dopNative": "DSD nativo" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} usados · {{files}} Canvas em cache", "clear": "Limpar cache", "clearConfirm": "Confirmar?" + }, + "dsdDop": { + "title": "DSD nativo (DoP)", + "subtitle": "Envia o fluxo DSD como está para o DAC via DoP (DSD over PCM) em vez de convertê-lo para PCM — decodificação nativa bit-perfect. Requer saída WASAPI Exclusive e um DAC compatível com DoP; caso contrário, recorre a DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 77309430..164c0560 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalização", "mono": "Mono", - "speed": "Velocidade {{value}}×" + "speed": "Velocidade {{value}}×", + "dopNative": "DSD nativo" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} usados · {{files}} Canvas em cache", "clear": "Limpar cache", "clearConfirm": "Confirmar?" + }, + "dsdDop": { + "title": "DSD nativo (DoP)", + "subtitle": "Envia o fluxo DSD tal como está para o DAC via DoP (DSD over PCM) em vez de o converter para PCM — descodificação nativa bit-perfect. Requer saída WASAPI Exclusive e um DAC compatível com DoP; caso contrário, recorre a DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 467cc907..636e5120 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1173,7 +1173,8 @@ "replayGain": "ReplayGain", "normalize": "Нормализация", "mono": "Моно", - "speed": "Скорость {{value}}×" + "speed": "Скорость {{value}}×", + "dopNative": "Нативный DSD" } } }, @@ -2124,6 +2125,10 @@ "usage": "{{size}} использовано · {{files}} Canvas в кэше", "clear": "Очистить кэш", "clearConfirm": "Подтвердить?" + }, + "dsdDop": { + "title": "Нативный DSD (DoP)", + "subtitle": "Отправляет поток DSD на ЦАП без изменений через DoP (DSD over PCM) вместо преобразования в PCM — побитово точное нативное декодирование. Требуется вывод WASAPI Exclusive и ЦАП с поддержкой DoP; иначе выполняется откат к DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 55262572..bdc80344 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "Normalleştirme", "mono": "Mono", - "speed": "Hız {{value}}×" + "speed": "Hız {{value}}×", + "dopNative": "Yerel DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "{{size}} kullanıldı · {{files}} Canvas önbellekte", "clear": "Önbelleği temizle", "clearConfirm": "Onaylıyor musunuz?" + }, + "dsdDop": { + "title": "Yerel DSD (DoP)", + "subtitle": "DSD akışını PCM'ye dönüştürmek yerine DoP (DSD over PCM) ile olduğu gibi DAC'a gönderir — bit-perfect yerel çözme. WASAPI Exclusive çıkışı ve DoP uyumlu bir DAC gerektirir; aksi hâlde DSD → PCM'ye geri döner." } }, "spotify": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1361b190..a0cf047f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "归一化", "mono": "单声道", - "speed": "速度 {{value}}×" + "speed": "速度 {{value}}×", + "dopNative": "原生 DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "已用 {{size}} · 已缓存 {{files}} 个 Canvas", "clear": "清除缓存", "clearConfirm": "确认?" + }, + "dsdDop": { + "title": "原生 DSD(DoP)", + "subtitle": "通过 DoP(DSD over PCM)将 DSD 流原样发送到 DAC,而不转换为 PCM——比特完美原生解码。需要 WASAPI 独占输出和支持 DoP 的 DAC;否则回退到 DSD → PCM。" } }, "scanProgress": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 94c7bd90..71e692ff 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1093,7 +1093,8 @@ "replayGain": "ReplayGain", "normalize": "正規化", "mono": "單聲道", - "speed": "速度 {{value}}×" + "speed": "速度 {{value}}×", + "dopNative": "原生 DSD" } } }, @@ -2028,6 +2029,10 @@ "usage": "已用 {{size}} · 已快取 {{files}} 個 Canvas", "clear": "清除快取", "clearConfirm": "確認?" + }, + "dsdDop": { + "title": "原生 DSD(DoP)", + "subtitle": "透過 DoP(DSD over PCM)將 DSD 串流原樣傳送到 DAC,而非轉換為 PCM——位元完美原生解碼。需要 WASAPI 獨佔輸出與支援 DoP 的 DAC;否則回退到 DSD → PCM。" } }, "scanProgress": { diff --git a/src/lib/tauri/player.ts b/src/lib/tauri/player.ts index 0525c767..3c49bfe9 100644 --- a/src/lib/tauri/player.ts +++ b/src/lib/tauri/player.ts @@ -38,6 +38,8 @@ export interface PlayerStateSnapshot { shuffle: boolean; repeat_mode: "off" | "all" | "one"; current_track: QueueTrackPayload | null; + /** True when the output is shipping native DSD via DoP (#495). */ + dop_active: boolean; } /** Event payloads emitted by the Rust decoder thread. */ @@ -303,6 +305,8 @@ export interface AudioSettingsSnapshot { gapless: boolean; /** Active DSD → PCM FIR tap count (256 / 1024 / 2048). */ dsd_taps: number; + /** Native DSD via DoP opt-in (#495), default false. */ + dsd_dop: boolean; } /** Allowed DSD → PCM precision tiers (FIR tap counts). */ @@ -343,6 +347,18 @@ export function playerSetDsdPrecision(taps: DsdPrecisionTaps): Promise { return invoke("player_set_dsd_precision", { taps }); } +/** + * Toggle native DSD output via DoP (DSD over PCM), #495. When on AND the + * output is WASAPI Exclusive AND the DAC accepts the DoP format, `.dsf` / + * `.dff` tracks are shipped as raw 1-bit DoP frames the DAC decodes + * natively (bit-perfect) instead of being converted to PCM. Any condition + * failing falls back silently to DSD → PCM, so it's safe to leave on. + * Windows-only in practice. Persisted in `profile_setting['audio.dsd_dop']`. + */ +export function playerSetDsdDop(enabled: boolean): Promise { + return invoke("player_set_dsd_dop", { enabled }); +} + /** * Update playback speed. Clamped to `[0.5, 2.0]` on the engine side; * out-of-range values are saturated. Pitch is NOT preserved — 1.5× From 56051242b965fe27c5acf7d03699191b1fe6762d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:11:00 +0200 Subject: [PATCH 06/12] docs(audio): document native DSD via DoP (#495) Update the DSD pipeline sections in CLAUDE.md and docs/features/playback.md: the StreamBackend enum now has a Dop variant, the opt-in setting, the per-track exclusive re-open + fallback, the bit-exact event loop and idle frames, the bypass of every DSP stage, and the fail-soft behaviour (refused format / non-exclusive / non-Windows all fall back to DSD -> PCM). --- CLAUDE.md | 2 +- docs/features/playback.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5c9495f..8f4814d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ One-liners + doc pointer. For everything else read the actual file in `commands/ ### Playback ([`docs/features/playback.md`](docs/features/playback.md)) -A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · gapless · ReplayGain · normalize · mono · 6-band peaking EQ (RBJ biquads, ±12 dB, 20 presets) · playback speed 0.5×–2× (resampler-shift, pitch follows) · DSD → PCM (Blackman-Harris FIR, user-selectable precision: 256-tap default / 1024 / 2048 via Settings → Playback, persisted in `profile_setting['audio.dsd_precision']` → `SharedPlayback.dsd_taps` atomic, read at stream-open by [`DsdToPcm::new_with_taps`](src-tauri/crates/core/src/audio_format/dsd/pcm.rs); DSD-only, symphonia formats ignore it, more taps = sharper transition band at linear CPU cost) · network pre-load (files on a Windows UNC / mapped `DRIVE_REMOTE` drive or a Linux gvfs/SMB mount are read fully into RAM under a 512 MiB cap and decoded from a `Cursor` instead of streamed, avoiding mid-playback stutter on high-latency links — [`ActiveStream::open`](src-tauri/crates/app/src/audio/crossfade.rs); DSD streams as usual) · WASAPI Exclusive opt-in (Windows) with transparent fallback to cpal shared · spectrum visualizer (2048-pt FFT, opt-in; user-selectable bar colour White/Emerald/Orange/Aqua/Magenta/Rainbow cycled from an immersive button, `profile_setting['ui.visualizer_color']`, issue #468) · output device persistence + cpal 0.17 friendly-name disambiguation · radio (seed + similar artists + BPM filter) · mood radio (focus/chill/workout/party/sleep) · sleep timer · embedded lyrics tag read prefers a synced `SYNCEDLYRICS` tag (TXXX / Vorbis comment, e.g. Antra rips) over the plain `USLT`/`LYRICS` keys, with a `TXXX:UNSYNCEDLYRICS` fallback for MP3 K-Pop/J-Pop rips ([`read_embedded_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs)); saving synced (LRC/Enhanced) lyrics re-stamps the canonical `SYNCEDLYRICS` tag via the concrete tag ([`write_synced_lyrics_tag`](src-tauri/crates/app/src/commands/lyrics.rs)) because lofty's generic save drops unmapped frames — otherwise an edit would wipe the original synced tag · sidecar `.lrc` / `.txt` auto-discovery next to the audio file or inside a sibling `Lyrics/` folder (case-insensitive, `.lrc` wins over `.txt`, runs before LRCLIB so no network hit when the rip ships its own lyrics) · **prefer-LRCLIB toggle** (`profile_setting['lyrics.prefer_lrclib']`, default off, Settings → Playback — issue #378): flips the on-demand [`fetch_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) order so the online providers run before the embedded + sidecar tiers, which become the fallback used only when the network has nothing (a track LRCLIB doesn't carry still shows its own embedded lyrics); the bulk `run_prefetch` gap-filler stays local-first · word-level karaoke lyrics (Enhanced LRC + TTML parse, mot-à-mot capture in the editor) with a **progressive fill** that sweeps across the word being sung (issue #491): two stacked copies of the word, the sung one clipped to a `--kw-fill` percentage written by [`useKaraokeWordFill`](src/hooks/useKaraokeWordFill.ts). Two things make it work — `player:position` only fires at **4 Hz** ([`POSITION_EMIT_INTERVAL`](src-tauri/crates/app/src/audio/decoder.rs)), so each event is an anchor the RAF loop extrapolates from (scaled by `playbackSpeed`, frozen while paused) rather than a value to paint directly; and the loop writes the CSS variable **straight to the DOM element**, never through React state, because `useTrackLyrics` is shared with the side panel and the column renders every line — a per-frame `setState` would re-render both trees 60×/s. Falls back to the plain discrete highlight under `prefers-reduced-motion`, when a word has no forward-going `endMs` (last word of a track, zero-duration stamps), and in the side [`LyricsPanel`](src/components/layout/LyricsPanel.tsx), which deliberately keeps the cheap version · Web Radio now-playing lyrics ([`fetch_radio_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in app.db by blake3(artist+title) from the ICY title — a radio session has no library row / file-hash to use the normal `lyrics` cache; queries LRCLIB + the query fallback chain, caches misses as empty rows; the LyricsPanel re-fetches per song (the sentinel track id stays constant across the session, so the effect keys on title+artist) and renders **statically** — synced LRC is timestamp-stripped because the live stream position can't align to a song joined mid-play; library-row mutation actions edit/import/refetch/clear are hidden for radio). +A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · gapless · ReplayGain · normalize · mono · 6-band peaking EQ (RBJ biquads, ±12 dB, 20 presets) · playback speed 0.5×–2× (resampler-shift, pitch follows) · DSD → PCM (Blackman-Harris FIR, user-selectable precision: 256-tap default / 1024 / 2048 via Settings → Playback, persisted in `profile_setting['audio.dsd_precision']` → `SharedPlayback.dsd_taps` atomic, read at stream-open by [`DsdToPcm::new_with_taps`](src-tauri/crates/core/src/audio_format/dsd/pcm.rs); DSD-only, symphonia formats ignore it, more taps = sharper transition band at linear CPU cost) · **native DSD via DoP** (DSD over PCM, #495, Windows-only, opt-in `profile_setting['audio.dsd_dop']` default OFF, `SharedPlayback.dsd_dop_enabled`): when on AND the output is WASAPI Exclusive AND the DAC accepts the format, a `.dsf`/`.dff` track skips the FIR — [`DsdToDop`](src-tauri/crates/core/src/audio_format/dsd/dop.rs) repackages the raw 1-bit stream into 24-bit DoP frames (marker `0x05`/`0xFA` per frame, payload MSB-first: DFF verbatim, DSF bit-reversed) at `dsd_rate/16`, the DAC decodes the DSD in hardware (bit-perfect). On cold `LoadAndPlay`, [`maybe_switch_dop_output`](src-tauri/crates/app/src/audio/decoder.rs) re-opens the exclusive output at the DoP rate via [`AudioEngine::switch_output_for_track`](src-tauri/crates/app/src/audio/engine.rs) (hands the fresh ring producer back directly, not through `SwapProducer`), then [`play_dop_track`](src-tauri/crates/app/src/audio/decoder.rs) pushes words straight to the ring and [`run_dop_event_loop`](src-tauri/crates/app/src/audio/wasapi_exclusive.rs) ships them bit-exact with marker-carrying idle frames on pause/underrun. **Fail-soft**: refused format / non-exclusive / non-Windows all fall back to DSD → PCM. DoP tracks bypass crossfade/gapless/EQ/RG/normalize/mono/speed (bit-perfect, volume is the DAC's) and always transition via a cold re-open; `player_get_state.dop_active` (from `AudioEngine::current_output_is_dop`) drives the "Native DSD" pipeline pill. Playing DoP to a non-DoP DAC is white noise, hence opt-in/default-OFF · network pre-load (files on a Windows UNC / mapped `DRIVE_REMOTE` drive or a Linux gvfs/SMB mount are read fully into RAM under a 512 MiB cap and decoded from a `Cursor` instead of streamed, avoiding mid-playback stutter on high-latency links — [`ActiveStream::open`](src-tauri/crates/app/src/audio/crossfade.rs); DSD streams as usual) · WASAPI Exclusive opt-in (Windows) with transparent fallback to cpal shared · spectrum visualizer (2048-pt FFT, opt-in; user-selectable bar colour White/Emerald/Orange/Aqua/Magenta/Rainbow cycled from an immersive button, `profile_setting['ui.visualizer_color']`, issue #468) · output device persistence + cpal 0.17 friendly-name disambiguation · radio (seed + similar artists + BPM filter) · mood radio (focus/chill/workout/party/sleep) · sleep timer · embedded lyrics tag read prefers a synced `SYNCEDLYRICS` tag (TXXX / Vorbis comment, e.g. Antra rips) over the plain `USLT`/`LYRICS` keys, with a `TXXX:UNSYNCEDLYRICS` fallback for MP3 K-Pop/J-Pop rips ([`read_embedded_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs)); saving synced (LRC/Enhanced) lyrics re-stamps the canonical `SYNCEDLYRICS` tag via the concrete tag ([`write_synced_lyrics_tag`](src-tauri/crates/app/src/commands/lyrics.rs)) because lofty's generic save drops unmapped frames — otherwise an edit would wipe the original synced tag · sidecar `.lrc` / `.txt` auto-discovery next to the audio file or inside a sibling `Lyrics/` folder (case-insensitive, `.lrc` wins over `.txt`, runs before LRCLIB so no network hit when the rip ships its own lyrics) · **prefer-LRCLIB toggle** (`profile_setting['lyrics.prefer_lrclib']`, default off, Settings → Playback — issue #378): flips the on-demand [`fetch_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) order so the online providers run before the embedded + sidecar tiers, which become the fallback used only when the network has nothing (a track LRCLIB doesn't carry still shows its own embedded lyrics); the bulk `run_prefetch` gap-filler stays local-first · word-level karaoke lyrics (Enhanced LRC + TTML parse, mot-à-mot capture in the editor) with a **progressive fill** that sweeps across the word being sung (issue #491): two stacked copies of the word, the sung one clipped to a `--kw-fill` percentage written by [`useKaraokeWordFill`](src/hooks/useKaraokeWordFill.ts). Two things make it work — `player:position` only fires at **4 Hz** ([`POSITION_EMIT_INTERVAL`](src-tauri/crates/app/src/audio/decoder.rs)), so each event is an anchor the RAF loop extrapolates from (scaled by `playbackSpeed`, frozen while paused) rather than a value to paint directly; and the loop writes the CSS variable **straight to the DOM element**, never through React state, because `useTrackLyrics` is shared with the side panel and the column renders every line — a per-frame `setState` would re-render both trees 60×/s. Falls back to the plain discrete highlight under `prefers-reduced-motion`, when a word has no forward-going `endMs` (last word of a track, zero-duration stamps), and in the side [`LyricsPanel`](src/components/layout/LyricsPanel.tsx), which deliberately keeps the cheap version · Web Radio now-playing lyrics ([`fetch_radio_lyrics`](src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in app.db by blake3(artist+title) from the ICY title — a radio session has no library row / file-hash to use the normal `lyrics` cache; queries LRCLIB + the query fallback chain, caches misses as empty rows; the LyricsPanel re-fetches per song (the sentinel track id stays constant across the session, so the effect keys on title+artist) and renders **statically** — synced LRC is timestamp-stripped because the live stream position can't align to a song joined mid-play; library-row mutation actions edit/import/refetch/clear are hidden for radio). ### Library ([`docs/features/library.md`](docs/features/library.md)) diff --git a/docs/features/playback.md b/docs/features/playback.md index 1b1258e4..2e4d531e 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -5,7 +5,8 @@ The audio path lives in [`src-tauri/crates/app/src/audio/`](../../src-tauri/crat ## Decoding & output - **Decoder** — [`symphonia 0.6`](https://crates.io/crates/symphonia) over MP3, FLAC, WAV, OGG Vorbis, AAC, ALAC (M4A). Source samples are converted to interleaved `f32`, channel-mapped (mono ↔ stereo, and any multichannel source — 3.0 / quad / 5.0 / 5.1 / 6.1 / 7.1 — folded to stereo Lo/Ro per ITU-R BS.775, centre + surrounds at −3 dB, LFE dropped), then resampled to the device rate by [`rubato 2.0`](https://crates.io/crates/rubato) (`Fft` + `FixedSync::Input`, with a fast `Passthrough` variant when source rate already matches the device). **Network pre-load**: when the source lives on a network share (Windows UNC / mapped `DRIVE_REMOTE` drive, or a Linux gvfs / SMB mount), [`ActiveStream::open`](../../src-tauri/crates/app/src/audio/crossfade.rs) reads the whole file into RAM (under a 512 MiB cap) and decodes from an in-memory `Cursor` instead of streaming — high-latency per-packet reads over the link would otherwise stutter mid-playback. Best-effort: oversize / unreadable files fall back to ordinary streaming. DSD keeps streaming (multi-GB files would blow the cap). -- **DSD pipeline** — symphonia doesn't decode 1-bit DSD, so DSF (Sony) and DFF (Philips) containers route through [`audio/dsd/`](../../src-tauri/crates/core/src/audio_format/dsd/): a custom container parser reads the layout (DSD64 → DSD1024, mono / stereo / multichannel), and a windowed-sinc FIR with a Blackman-Harris envelope (256 taps by default, user-selectable up to 1024 / 2048 via Settings → Playback) decimates the bitstream by 64 to land DSD64 at 44.1 kHz, DSD128 at 88.2 kHz, etc. The resulting PCM joins the same channel-convert + resample + ring-buffer pipeline as symphonia output. `ActiveStream` carries a `StreamBackend` enum (Symphonia / Dsd) so seeking and decoder reset stay uniform from the engine's perspective. **Limitation**: real audiophile players use multi-stage halfband cascades for lower CPU at the same SNR; ours prioritises code clarity. DoP (DSD-over-PCM) is not yet wired — the converter always produces PCM. +- **DSD pipeline** — symphonia doesn't decode 1-bit DSD, so DSF (Sony) and DFF (Philips) containers route through [`audio/dsd/`](../../src-tauri/crates/core/src/audio_format/dsd/): a custom container parser reads the layout (DSD64 → DSD1024, mono / stereo / multichannel), and a windowed-sinc FIR with a Blackman-Harris envelope (256 taps by default, user-selectable up to 1024 / 2048 via Settings → Playback) decimates the bitstream by 64 to land DSD64 at 44.1 kHz, DSD128 at 88.2 kHz, etc. The resulting PCM joins the same channel-convert + resample + ring-buffer pipeline as symphonia output. `ActiveStream` carries a `StreamBackend` enum (Symphonia / Dsd / Dop) so seeking and decoder reset stay uniform from the engine's perspective. **Limitation**: real audiophile players use multi-stage halfband cascades for lower CPU at the same SNR; ours prioritises code clarity. +- **Native DSD via DoP** (DSD over PCM, #495, Windows-only, opt-in `profile_setting['audio.dsd_dop']` default OFF) — when the toggle is on AND the active output is WASAPI Exclusive AND the DAC accepts the format, a DSD track skips the FIR entirely: [`DsdToDop`](../../src-tauri/crates/core/src/audio_format/dsd/dop.rs) repackages the raw 1-bit stream into 24-bit DoP frames (marker `0x05`/`0xFA` alternating per frame, payload MSB-first — DFF verbatim, DSF bit-reversed) at `dsd_rate / 16` (DSD64 → 176.4 kHz, DSD128 → 352.8, DSD256 → 705.6), and the DAC reconstructs the 1-bit stream in hardware (truly bit-perfect, nothing on our side filters / resamples / gains it). On a cold `LoadAndPlay`, [`maybe_switch_dop_output`](../../src-tauri/crates/app/src/audio/decoder.rs) parses the DSD header for the DoP rate and asks the engine to re-open the exclusive output at that exact format ([`AudioEngine::switch_output_for_track`](../../src-tauri/crates/app/src/audio/engine.rs), which hands the fresh ring producer straight back rather than via the `SwapProducer` channel); a dedicated [`run_dop_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) ships the words bit-exact and emits marker-carrying DoP idle frames (`0x69` payload) on pause/underrun so the DAC keeps DoP lock. **Fully fail-soft**: a DAC that refuses the DoP rate, a non-exclusive output, or a non-Windows platform all fall back transparently to the DSD → PCM path above. DoP tracks never crossfade / gaplessly prefetch (the words can't be mixed), so they always transition through a cold load + output re-open; EQ / ReplayGain / normalize / mono / speed are bypassed (bit-perfect, volume is the DAC's job). The pipeline popover shows a "Native DSD" pill sourced from `player_get_state.dop_active` — what actually engaged, not just the opt-in. **Playing DoP to a non-DoP DAC produces white noise**, so the toggle is opt-in and default OFF for users who know their DAC supports it. - **Output** — [`cpal 0.17`](https://crates.io/crates/cpal) on a dedicated thread because `cpal::Stream` is `!Send` on Windows. Samples cross the thread via an [`rtrb 0.3`](https://crates.io/crates/rtrb) SPSC ring (`RING_CAPACITY = 96 000` `f32`s ≈ 1 s @ 48 kHz stereo). - **Hot-path rules** — the cpal callback never allocates, locks or logs. It only reads the `rtrb::Consumer` and `Atomic*` fields in `SharedPlayback`. From 92f66ca4248cc446428f0525b9251572fc5839a3 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:21:57 +0200 Subject: [PATCH 07/12] fix(audio): gate dop strictly to windows / wasapi exclusive (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoP can only ride WASAPI Exclusive, so: - `switch_output_for_track` now drops a DoP request when the exclusive preference isn't active (always the case on Linux / macOS, and on Windows without the opt-in) BEFORE tearing the output down. Previously a DSD track with the toggle on outside exclusive mode rebuilt the output on every load just to fall back to PCM — now it's a clean no-op. Also covers a synced profile carrying `audio.dsd_dop = true` from a Windows machine. - The Settings toggle is hidden on non-Windows platforms (same UA sniff as the WASAPI Exclusive card), since DoP can't engage there. Behaviour is unchanged where it mattered: Linux / macOS keep playing DSD via DSD -> PCM exactly as before. --- src-tauri/crates/app/src/audio/engine.rs | 13 +++++++ src/components/views/SettingsView.tsx | 49 ++++++++++++++---------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index 9b77d7ef..3abb70b7 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -759,6 +759,19 @@ impl AudioEngine { ) -> AppResult<(Option>, bool)> { use std::sync::atomic::Ordering; + // DoP can only ride WASAPI Exclusive (Windows). If exclusive isn't + // the active preference — always the case on Linux / macOS, and on + // Windows when the user hasn't opted into exclusive — drop the DoP + // request up-front so we don't tear the output down and rebuild it + // just to fall back to PCM on every DSD track. A synced profile + // carrying `audio.dsd_dop = true` from a Windows machine is handled + // here too: it becomes a no-op rather than a churn. + let dop = if dop.is_some() && self.wasapi_exclusive.load(Ordering::Relaxed) { + dop + } else { + None + }; + let mut guard = self .output .lock() diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index a86b74e5..759f64a0 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -1223,6 +1223,12 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { const [gapless, setGapless] = useState(true); const [dsdTaps, setDsdTaps] = useState(256); const [dsdDop, setDsdDop] = useState(false); + // DoP only works over WASAPI Exclusive, so the toggle is Windows-only + // (#495) — same UA sniff the ExclusiveModeCard uses. The WebView is + // platform-pinned, so the result is stable for the session. + const isWindows = + typeof navigator !== "undefined" && + navigator.userAgent.toLowerCase().includes("windows"); // Integrations const [lastfmKey, setLastfmKey] = useState(""); @@ -2276,29 +2282,32 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {
{/* Native DSD via DoP (DSD over PCM). Windows / WASAPI - Exclusive + DoP-capable DAC only; off by default. */} -
-
-