diff --git a/CLAUDE.md b/CLAUDE.md index e5c9495f..763d4dee 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, opt-in `profile_setting['audio.dsd_dop']` default OFF, `SharedPlayback.dsd_dop_enabled`): when on AND the output has exclusive device access 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). **Per-OS exclusive backend, shared DoP packing** in [`audio/dop_pack.rs`](src-tauri/crates/app/src/audio/dop_pack.rs): Windows = WASAPI Exclusive [`run_dop_event_loop`](src-tauri/crates/app/src/audio/wasapi_exclusive.rs); Linux = raw `hw:` ALSA `S32_LE` marker-MSB-justified [`alsa_exclusive`](src-tauri/crates/app/src/audio/alsa_exclusive.rs); macOS = CoreAudio hog mode + forced physical format via `AudioUnit` render callback [`coreaudio_exclusive`](src-tauri/crates/app/src/audio/coreaudio_exclusive.rs) (`coreaudio-rs`, macOS-gated dep). 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. **Fail-soft**: refused format / non-exclusive / no-backend platform all fall back to DSD → PCM. Windows gates DoP on the WASAPI Exclusive opt-in; Linux's DoP toggle engages the `hw:` path itself. 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..39850aa0 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, opt-in `profile_setting['audio.dsd_dop']` default OFF) — when the toggle is on AND the active output has exclusive device access 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). **Per-platform exclusive backend** — DoP needs a mixer-free path to the DAC, so each OS has its own: **Windows** = WASAPI Exclusive ([`run_dop_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs)); **Linux** = a raw `hw:` ALSA device at `S32_LE`, marker MSB-justified ([`alsa_exclusive`](../../src-tauri/crates/app/src/audio/alsa_exclusive.rs)); **macOS** = CoreAudio **hog mode** + a forced physical stream format at the DoP rate in 32-bit int, fed through an `AudioUnit` render callback ([`coreaudio_exclusive`](../../src-tauri/crates/app/src/audio/coreaudio_exclusive.rs)). All three ship the DoP words MSB-justified (marker in the top byte) and share the byte/idle packing in [`audio/dop_pack`](../../src-tauri/crates/app/src/audio/dop_pack.rs). 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); the backend 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 platform without a DoP backend all fall back transparently to the DSD → PCM path above. On Windows DoP rides the separate WASAPI Exclusive opt-in; on Linux the DoP toggle itself engages the exclusive `hw:` path. 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`. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a6dd8726..6c2bd118 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -941,6 +941,20 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "cpal" version = "0.17.1" @@ -948,7 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ "alsa", - "coreaudio-rs", + "coreaudio-rs 0.13.0", "dasp_sample", "jni 0.21.1", "js-sys", @@ -7595,6 +7609,7 @@ dependencies = [ "base64 0.23.1", "blake3", "chrono", + "coreaudio-rs 0.14.2", "cpal", "crossbeam-channel", "dirs 6.0.0", diff --git a/src-tauri/crates/app/Cargo.toml b/src-tauri/crates/app/Cargo.toml index ac501c78..209a943b 100644 --- a/src-tauri/crates/app/Cargo.toml +++ b/src-tauri/crates/app/Cargo.toml @@ -289,3 +289,11 @@ windows = { version = "0.62", features = [ "Win32_System_Threading", "Win32_Storage_FileSystem", ] } + +# macOS-only: opt-in bit-perfect native DSD via DoP (#495 / #497). +# `coreaudio-rs` (lib name `coreaudio`) wraps Apple's CoreAudio HAL and +# re-exports the raw `coreaudio-sys` bindings as `coreaudio::sys`, which +# we need for the parts its safe layer doesn't cover (device hog mode). +# Target-gated, so the Windows + Linux bundles are untouched. +[target.'cfg(target_os = "macos")'.dependencies] +coreaudio-rs = "0.14" diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs new file mode 100644 index 00000000..4a10ea51 --- /dev/null +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -0,0 +1,270 @@ +//! Linux-only ALSA hardware-exclusive DoP output backend (#495). +//! +//! The Linux equivalent of [`super::wasapi_exclusive`]: it opens the DAC +//! as a **raw `hw:` device** (never `default` / `plughw:` / a Pulse or +//! PipeWire alias), which bypasses the system mixer + resampler and gives +//! us exclusive, bit-perfect access — the only way a DoP marker cadence +//! survives to the DAC. The stream is opened at the exact DoP rate +//! (`dsd_rate / 16`) in **`S32_LE`**, and each 24-bit DoP word is placed +//! MSB-justified in the 32-bit sample (marker in the top byte) via +//! [`super::dop_pack::fill_dop_period_i32`]. +//! +//! If the DAC won't accept `S32_LE` at the DoP rate, or another client +//! already holds the `hw:` device, the open fails and the engine falls +//! back to the ordinary DSD → PCM path (through cpal shared). +//! +//! Same SPSC ring contract as the other backends (`Producer` → +//! `Consumer`, words carried as `f32` bit patterns), so the decoder +//! doesn't know which backend is draining the ring. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::thread::JoinHandle; + +use alsa::pcm::{Access, Format, HwParams, State, PCM}; +use alsa::{Direction, ValueOr}; +use crossbeam_channel::{bounded, Receiver, Sender}; +use rtrb::{Consumer, Producer, RingBuffer}; +use tauri::AppHandle; + +use super::output::{DopFormat, OutputHandle, RING_CAPACITY}; +use super::state::SharedPlayback; +use crate::error::{AppError, AppResult}; + +/// Spawn the ALSA DoP output thread. Mirrors +/// [`super::wasapi_exclusive::spawn_exclusive_output_thread`]'s contract: +/// returns the decoder-side `Producer` and an [`OutputHandle`], or an +/// error (device busy / format unsupported / no such device) surfaced +/// synchronously so the caller can fall back to DSD → PCM. +pub fn spawn_alsa_dop_output_thread( + shared: Arc, + app: AppHandle, + device_name: Option, + dop: DopFormat, +) -> AppResult<(Producer, OutputHandle)> { + let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); + let (shutdown_tx, shutdown_rx) = bounded::<()>(1); + let (init_tx, init_rx) = bounded::>(1); + + let thread_shared = shared.clone(); + let thread_app = app.clone(); + let thread_device = device_name.clone(); + let join: JoinHandle<()> = std::thread::Builder::new() + .name("waveflow-alsa-dop".into()) + .spawn(move || { + output_thread_main( + thread_shared, + consumer, + shutdown_rx, + init_tx, + thread_app, + thread_device, + dop, + ) + }) + .map_err(|e| AppError::Audio(format!("spawn alsa dop thread: {e}")))?; + + match init_rx.recv() { + Ok(Ok(())) => Ok(( + producer, + OutputHandle { + shutdown_tx, + join, + device_name, + wasapi_exclusive: false, + dop_rate: Some(dop.sample_rate), + }, + )), + Ok(Err(err)) => { + let _ = join.join(); + Err(err) + } + Err(_) => Err(AppError::Audio( + "alsa dop thread died before reporting init result".into(), + )), + } +} + +/// Why the render loop returned — same distinction as the WASAPI backend: +/// a clean shutdown must NOT trigger a recovery, a device loss must. +enum ExitReason { + Shutdown, + DeviceLost(String), +} + +fn output_thread_main( + shared: Arc, + mut consumer: Consumer, + shutdown_rx: Receiver<()>, + init_tx: Sender>, + app: AppHandle, + device_name: Option, + dop: DopFormat, +) { + let dev = resolve_hw_device(&device_name); + let channels = dop.channels as usize; + + let (pcm, period_frames) = match open_pcm(&dev, dop) { + Ok(v) => v, + Err(err) => { + tracing::warn!(%err, device = %dev, "alsa dop init failed"); + let _ = init_tx.send(Err(err)); + return; + } + }; + + // `io_i32` borrows the PCM, so it lives in this frame alongside it. + let io = match pcm.io_i32() { + Ok(io) => io, + Err(err) => { + let _ = init_tx.send(Err(AppError::Audio(format!("alsa io_i32: {err}")))); + return; + } + }; + + shared.sample_rate.store(dop.sample_rate, Ordering::Release); + shared.channels.store(dop.channels, Ordering::Release); + let _ = init_tx.send(Ok(())); + + tracing::info!( + device = %dev, + sample_rate = dop.sample_rate, + channels = dop.channels, + period_frames, + "alsa dop stream opened" + ); + + let mut buf: Vec = vec![0; period_frames * channels]; + let mut silence_phase: u64 = 0; + + let exit = loop { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + + if shared.paused_output.load(Ordering::Acquire) { + super::dop_pack::render_dop_silence_i32(channels, period_frames, &mut silence_phase, &mut buf); + } else if shared.drain_silent.load(Ordering::Acquire) { + while consumer.pop().is_ok() {} + super::dop_pack::render_dop_silence_i32(channels, period_frames, &mut silence_phase, &mut buf); + } else { + let written = super::dop_pack::fill_dop_period_i32( + channels, + period_frames, + &mut consumer, + &mut silence_phase, + &mut buf, + ); + if written > 0 { + shared.samples_played.fetch_add(written, Ordering::Relaxed); + } + } + + // Blocking write (the PCM was opened blocking). On XRUN / suspend + // `try_recover` re-prepares the device; a hard failure is a device + // loss. + if let Err(err) = io.writei(&buf) { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + if let Err(rec) = pcm.try_recover(err, true) { + tracing::warn!(?rec, "alsa dop write failed and recovery failed"); + break ExitReason::DeviceLost(format!("alsa write failed: {rec}")); + } + // Recovered — re-prepare if needed and continue. + if pcm.state() == State::Setup { + let _ = pcm.prepare(); + } + } + + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + }; + + // `io` borrows `pcm`; both drop here at end of scope. Dropping the + // PCM calls `snd_pcm_close`, which stops the stream and releases the + // exclusive `hw:` handle for the next opener. + drop(io); + + match exit { + ExitReason::Shutdown => { + tracing::debug!("alsa dop output thread exiting"); + } + ExitReason::DeviceLost(reason) => { + tracing::warn!(%reason, "alsa dop output thread lost the device; requesting rebuild"); + super::output::notify_device_lost(&app, &shared, format!("audio device error: {reason}")); + super::output::schedule_device_rebuild(&app, super::output::RebuildTarget::Resolve); + } + } +} + +/// Map the persisted output name to a raw `hw:` device so we get +/// exclusive hardware access. A `default` / `plughw:` / `sysdefault:` / +/// Pulse alias would route through the mixer and resample the DoP marker +/// into noise, so we rewrite to `hw:` (keeping the `CARD=` selector when +/// present). An empty / unknown name falls back to the first card. +fn resolve_hw_device(name: &Option) -> String { + match name.as_deref().filter(|n| !n.is_empty()) { + Some(n) if n.starts_with("hw:") => n.to_string(), + Some(n) => { + if let Some(idx) = n.find("CARD=") { + format!("hw:{}", &n[idx..]) + } else { + "hw:0,0".to_string() + } + } + None => "hw:0,0".to_string(), + } +} + +/// Open the `hw:` device at the exact DoP format. Returns the PCM plus its +/// negotiated period size (frames). Any rejection (busy device, rate / +/// format unsupported) is an error → the caller falls back to DSD → PCM. +fn open_pcm(dev: &str, dop: DopFormat) -> AppResult<(PCM, usize)> { + let pcm = PCM::new(dev, Direction::Playback, false) + .map_err(|e| AppError::Audio(format!("alsa open {dev}: {e}")))?; + + { + let hwp = HwParams::any(&pcm).map_err(|e| AppError::Audio(format!("alsa hwparams: {e}")))?; + hwp.set_channels(dop.channels as u32) + .map_err(|e| AppError::Audio(format!("alsa set_channels: {e}")))?; + // DoP demands the exact rate — no resampling. `Nearest` lets ALSA + // pick, then we verify below and bail if it deviated. + hwp.set_rate(dop.sample_rate, ValueOr::Nearest) + .map_err(|e| AppError::Audio(format!("alsa set_rate: {e}")))?; + hwp.set_format(Format::S32LE) + .map_err(|e| AppError::Audio(format!("alsa set_format S32_LE: {e}")))?; + hwp.set_access(Access::RWInterleaved) + .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + pcm.hw_params(&hwp) + .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; + } + + // Scoped: `hw_params_current` borrows the PCM, and the borrow would + // otherwise still be live at the `Ok((pcm, …))` move below. + let period_frames = { + let hwp = pcm + .hw_params_current() + .map_err(|e| AppError::Audio(format!("alsa hw_params_current: {e}")))?; + let actual_rate = hwp + .get_rate() + .map_err(|e| AppError::Audio(format!("alsa get_rate: {e}")))?; + if actual_rate != dop.sample_rate { + return Err(AppError::Audio(format!( + "alsa gave {actual_rate} Hz, DoP needs exactly {} Hz — device can't do this DoP rate", + dop.sample_rate + ))); + } + hwp.get_period_size() + .map_err(|e| AppError::Audio(format!("alsa get_period_size: {e}")))? as usize + }; + if period_frames == 0 { + return Err(AppError::Audio("alsa reported a zero period size".into())); + } + + pcm.prepare() + .map_err(|e| AppError::Audio(format!("alsa prepare: {e}")))?; + + Ok((pcm, period_frames)) +} diff --git a/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs new file mode 100644 index 00000000..1ece2a06 --- /dev/null +++ b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs @@ -0,0 +1,334 @@ +//! macOS-only CoreAudio hog-mode DoP output backend (#495 / #497). +//! +//! The macOS equivalent of [`super::wasapi_exclusive`] / [`super::alsa_exclusive`]: +//! it takes **hog mode** on the output device (exclusive access — the +//! system stops mixing anything else into it), forces the device's +//! **physical stream format** to the exact DoP rate (`dsd_rate / 16`) in +//! 32-bit signed integer, and feeds an `AudioUnit` render callback with +//! DoP words MSB-justified in each 32-bit sample (marker in the top byte) +//! via [`super::dop_pack::fill_dop_period_i32`] — the same wire shape as +//! the Linux `S32_LE` path. +//! +//! Bit-perfect note: because we pin the device's *physical* rate to the +//! DoP rate, CoreAudio never resamples. The AudioUnit may still adjust +//! endianness between our native-LE client buffer and the device format, +//! but that conversion is **value-preserving** (it's samples, not raw +//! bytes), so the marker stays in the sample's most-significant byte and +//! the DoP stream survives intact. +//! +//! If hog mode is unavailable (another app holds the device) or the DAC +//! won't accept the DoP rate in 32-bit, the open fails and the engine +//! falls back to the ordinary DSD → PCM path. +//! +//! Same SPSC ring contract as the other backends (`Producer` → +//! `Consumer`, words carried as `f32` bit patterns). + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::thread::JoinHandle; + +use coreaudio::audio_unit::audio_format::LinearPcmFlags; +use coreaudio::audio_unit::macos_helpers::{ + audio_unit_from_device_id_uninitialized, find_matching_physical_format, get_default_device_id, + get_device_id_from_name, get_hogging_pid, set_device_physical_stream_format, toggle_hog_mode, +}; +use coreaudio::audio_unit::render_callback::{data, Args}; +use coreaudio::audio_unit::{Element, SampleFormat, Scope, StreamFormat}; +use crossbeam_channel::{bounded, Receiver, Sender}; +use rtrb::{Consumer, Producer, RingBuffer}; +use tauri::AppHandle; + +use super::output::{DopFormat, OutputHandle, RING_CAPACITY}; +use super::state::SharedPlayback; +use crate::error::{AppError, AppResult}; + +/// CoreAudio device handle. `coreaudio-rs` takes/returns the +/// `objc2_core_audio::AudioDeviceID` type, which is a transparent alias +/// for `u32` (`AudioObjectID`), so we alias it locally rather than pull +/// in `objc2-core-audio` just for the name. +type AudioDeviceID = u32; + +/// Spawn the CoreAudio DoP output thread. Mirrors the other exclusive +/// backends' contract: returns the decoder-side `Producer` and an +/// [`OutputHandle`], or an error (device busy / hog denied / format +/// unsupported) surfaced synchronously so the caller can fall back to +/// DSD → PCM. +pub fn spawn_coreaudio_dop_output_thread( + shared: Arc, + app: AppHandle, + device_name: Option, + dop: DopFormat, +) -> AppResult<(Producer, OutputHandle)> { + let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); + let (shutdown_tx, shutdown_rx) = bounded::<()>(1); + let (init_tx, init_rx) = bounded::>(1); + + let thread_shared = shared.clone(); + let thread_app = app.clone(); + let thread_device = device_name.clone(); + let join: JoinHandle<()> = std::thread::Builder::new() + .name("waveflow-coreaudio-dop".into()) + .spawn(move || { + output_thread_main( + thread_shared, + consumer, + shutdown_rx, + init_tx, + thread_app, + thread_device, + dop, + ) + }) + .map_err(|e| AppError::Audio(format!("spawn coreaudio dop thread: {e}")))?; + + match init_rx.recv() { + Ok(Ok(())) => Ok(( + producer, + OutputHandle { + shutdown_tx, + join, + device_name, + wasapi_exclusive: false, + dop_rate: Some(dop.sample_rate), + }, + )), + Ok(Err(err)) => { + let _ = join.join(); + Err(err) + } + Err(_) => Err(AppError::Audio( + "coreaudio dop thread died before reporting init result".into(), + )), + } +} + +fn output_thread_main( + shared: Arc, + consumer: Consumer, + shutdown_rx: Receiver<()>, + init_tx: Sender>, + _app: AppHandle, + device_name: Option, + dop: DopFormat, +) { + let device_id = match resolve_device(&device_name) { + Some(id) => id, + None => { + let _ = init_tx.send(Err(AppError::Audio( + "coreaudio: no output device found for DoP".into(), + ))); + return; + } + }; + + // Take exclusive (hog) access before touching the format, so the + // physical-format change sticks and nothing else gets mixed in. + if let Err(err) = acquire_hog(device_id) { + let _ = init_tx.send(Err(err)); + return; + } + + // Everything past hog acquisition must release it on the way out. + let result = open_and_run(&shared, consumer, &shutdown_rx, &init_tx, device_id, dop); + + release_hog(device_id); + + match result { + Ok(()) => tracing::debug!("coreaudio dop output thread exiting"), + Err(err) => tracing::warn!(%err, "coreaudio dop output thread stopped on error"), + } +} + +/// The stateful body, factored out so `output_thread_main` can always run +/// [`release_hog`] afterwards regardless of how this returns. If it errors +/// before `init_tx` was signalled, it reports the failure so the spawn +/// call can fall back; after a successful start it parks until shutdown. +fn open_and_run( + shared: &Arc, + mut consumer: Consumer, + shutdown_rx: &Receiver<()>, + init_tx: &Sender>, + device_id: AudioDeviceID, + dop: DopFormat, +) -> AppResult<()> { + let channels = dop.channels as usize; + + // The DoP client format: interleaved 32-bit signed int at the exact + // DoP rate. Marker rides MSB-justified inside each sample. + let stream_format = StreamFormat { + sample_rate: dop.sample_rate as f64, + sample_format: SampleFormat::I32, + flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED, + channels: dop.channels as u32, + }; + + // Pin the device's physical format to a supported one matching the + // DoP rate + 32-bit int — this is what prevents any resampling. + let asbd = find_matching_physical_format(device_id, stream_format).ok_or_else(|| { + AppError::Audio(format!( + "coreaudio: DAC has no 32-bit physical format at {} Hz — can't do this DoP rate", + dop.sample_rate + )) + })?; + set_device_physical_stream_format(device_id, asbd) + .map_err(|e| AppError::Audio(format!("coreaudio set physical format: {e}")))?; + + // Build the output AudioUnit bound to the device (uninitialized so we + // can set the client format before init). + let mut audio_unit = audio_unit_from_device_id_uninitialized(device_id, false) + .map_err(|e| AppError::Audio(format!("coreaudio audio unit: {e}")))?; + audio_unit + .set_stream_format(stream_format, Scope::Input, Element::Output) + .map_err(|e| AppError::Audio(format!("coreaudio set stream format: {e}")))?; + + // Render callback: bit-exact DoP, no DSP. Runs on CoreAudio's + // realtime thread — only ring pops + atomic ops, no alloc/lock. + let mut silence_phase: u64 = 0; + let cb_shared = shared.clone(); + audio_unit + .set_render_callback(move |args: Args>| { + let Args { + data: data::Interleaved { buffer, channels, .. }, + num_frames, + .. + } = args; + let paused = cb_shared.paused_output.load(Ordering::Acquire); + let draining = cb_shared.drain_silent.load(Ordering::Acquire); + if paused || draining { + if draining { + while consumer.pop().is_ok() {} + } + super::dop_pack::render_dop_silence_i32( + channels, + num_frames, + &mut silence_phase, + buffer, + ); + } else { + let written = super::dop_pack::fill_dop_period_i32( + channels, + num_frames, + &mut consumer, + &mut silence_phase, + buffer, + ); + if written > 0 { + cb_shared + .samples_played + .fetch_add(written, Ordering::Relaxed); + } + } + Ok(()) + }) + .map_err(|e| AppError::Audio(format!("coreaudio set render callback: {e}")))?; + + audio_unit + .initialize() + .map_err(|e| AppError::Audio(format!("coreaudio initialize: {e}")))?; + + shared.sample_rate.store(dop.sample_rate, Ordering::Release); + shared.channels.store(dop.channels, Ordering::Release); + + audio_unit + .start() + .map_err(|e| AppError::Audio(format!("coreaudio start: {e}")))?; + + tracing::info!( + device_id, + sample_rate = dop.sample_rate, + channels, + "coreaudio dop stream opened" + ); + + // Init succeeded — tell the spawn call, then park until teardown. + let _ = init_tx.send(Ok(())); + let _ = shutdown_rx.recv(); + + // Stop + drop the unit here (frees the render callback + its captured + // ring consumer) before the caller releases hog mode. + let _ = audio_unit.stop(); + drop(audio_unit); + Ok(()) +} + +/// Resolve the persisted device name to a CoreAudio device id, or the +/// default output when unset / not found. +fn resolve_device(device_name: &Option) -> Option { + if let Some(name) = device_name.as_deref().filter(|n| !n.is_empty()) { + if let Some(id) = get_device_id_from_name(name, false) { + return Some(id); + } + tracing::warn!( + requested = name, + "coreaudio: requested device not found, using default output" + ); + } + get_default_device_id(false) +} + +/// Take hog mode (exclusive access). Errors if another process already +/// owns it or the toggle didn't land on us — the caller then falls back +/// to DSD → PCM instead of fighting for the device. +fn acquire_hog(device_id: AudioDeviceID) -> AppResult<()> { + let our_pid = std::process::id() as i32; + // If it's already ours (shouldn't happen on a fresh open), don't + // toggle it off. Otherwise take it and verify we won. + match get_hogging_pid(device_id) { + Ok(pid) if pid == our_pid => Ok(()), + _ => { + let owner = toggle_hog_mode(device_id) + .map_err(|e| AppError::Audio(format!("coreaudio hog mode: {e}")))?; + if owner == our_pid { + Ok(()) + } else { + Err(AppError::Audio(format!( + "coreaudio: device is held exclusively by another process (pid {owner})" + ))) + } + } + } +} + +/// Release hog mode if we still own it. Best-effort — logged, never fatal. +fn release_hog(device_id: AudioDeviceID) { + let our_pid = std::process::id() as i32; + if let Ok(pid) = get_hogging_pid(device_id) { + if pid == our_pid { + if let Err(err) = toggle_hog_mode(device_id) { + tracing::warn!(%err, "coreaudio: failed to release hog mode"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Runtime smoke probe against the real default output device: proves + /// the CoreAudio FFI actually executes (compile ≠ runs) and reports + /// which DoP rates the device can negotiate in 32-bit. Read-only — it + /// never takes hog mode. Ignored by default (needs macOS + a device); + /// run with: + /// cargo test -p waveflow coreaudio_dop_probe -- --ignored --nocapture + #[test] + #[ignore] + fn coreaudio_dop_probe() { + let dev = get_default_device_id(false).expect("a default output device"); + let hog = get_hogging_pid(dev).unwrap_or(-99); + println!("default output device id = {dev}, current hog pid = {hog}"); + for rate in [176_400u32, 352_800, 705_600] { + let sf = StreamFormat { + sample_rate: rate as f64, + sample_format: SampleFormat::I32, + flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED, + channels: 2, + }; + let ok = find_matching_physical_format(dev, sf).is_some(); + println!( + "DoP {rate} Hz / S32 int / 2ch: {}", + if ok { "SUPPORTED" } else { "not available" } + ); + } + } +} 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/dop_pack.rs b/src-tauri/crates/app/src/audio/dop_pack.rs new file mode 100644 index 00000000..7c3a5381 --- /dev/null +++ b/src-tauri/crates/app/src/audio/dop_pack.rs @@ -0,0 +1,309 @@ +//! Shared DoP (DSD over PCM) byte packing, #495. +//! +//! The DoP *words* — 24-bit values carrying an 8-bit marker plus two DSD +//! bytes — are produced by the decoder ([`waveflow_core::audio_format::dsd::dop::DsdToDop`]) +//! and carried through the SPSC ring as `f32` bit patterns (a pure byte +//! pipe: `from_bits` on the way in, `to_bits` here, no arithmetic in +//! between). Every exclusive output backend — WASAPI (Windows), ALSA +//! (Linux), CoreAudio (macOS) — turns those words into the little-endian +//! device bytes and generates marker-carrying idle frames for pause / +//! drain / underrun the same way, so that logic lives here once instead +//! of being re-derived per platform. +//! +//! `padded` selects the 24-in-32 container (4 bytes, high byte 0) over +//! the compact 3-byte packing: WASAPI `Pcm24Padded`, ALSA `S32_LE`, and +//! CoreAudio's 32-bit path all want the former; WASAPI `Pcm24Packed` and +//! ALSA `S24_3LE` want the latter. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use rtrb::Consumer; + +/// 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; + +/// The DSD "digital silence" payload byte (0x69). Two of them under a +/// live marker decode to analog silence while keeping the DAC in DoP +/// lock — the correct thing to feed on pause / underrun. +const DOP_SILENCE_PAYLOAD: u32 = 0x6969; + +/// Bytes per DoP sample on the wire for the two container shapes. +#[inline] +pub fn sample_bytes(padded: bool) -> usize { + if padded { + 4 + } else { + 3 + } +} + +/// 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] +pub 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] +pub fn dop_silence_word(phase: u64) -> u32 { + let marker: u32 = if phase & 1 == 0 { + DOP_MARKER_A + } else { + DOP_MARKER_B + }; + (marker << 16) | DOP_SILENCE_PAYLOAD +} + +/// 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. +pub 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); + } + } +} + +/// Pull whole DoP frames from the ring into `bytes` for one device +/// period, silence-filling the remainder on the first short frame so the +/// marker cadence never breaks mid-period. Returns the number of real +/// samples pulled (for `samples_played` crediting — underrun silence is +/// deliberately not counted, matching the PCM backends). +/// +/// This is the entire hot-loop body every backend runs each period; the +/// backends own only the device-specific wait + write around it. +pub fn fill_dop_period( + padded: bool, + channels: usize, + need_frames: usize, + consumer: &mut Consumer, + silence_phase: &mut u64, + bytes: &mut [u8], +) -> u64 { + 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, bytes, 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, bytes, f * channels + ch); + } + written += channels as u64; + f += 1; + } + written +} + +/// A tiny helper so backends don't hand-roll `AtomicU64` juggling for the +/// silence phase when they keep it on the heap; most keep it as a plain +/// `u64` local instead. Provided for completeness / future use. +#[allow(dead_code)] +pub fn advance_phase(phase: &AtomicU64) -> u32 { + let p = phase.fetch_add(1, Ordering::Relaxed); + dop_silence_word(p) >> 16 +} + +// ── 32-bit MSB-justified path (ALSA S32_LE, CoreAudio 32-bit int) ───── +// +// Some transports present a full 32-bit sample to the DAC rather than a +// packed 24-bit one. There the DoP marker must land in the *most* +// significant byte (bits 31..24) for the DAC to detect the cadence, so +// the 24-bit word is left-shifted by 8. This is a different justification +// from WASAPI's `Pcm24Padded` (24 valid bits in the LOW position), hence +// a separate path rather than a flag on the byte packer. + +/// Place a 24-bit DoP word MSB-justified in a 32-bit sample: marker in +/// bits 31..24, the two DSD bytes in 23..8, low byte zero. +#[inline] +pub fn dop_word_to_i32(word: u32) -> i32 { + ((word & 0x00FF_FFFF) << 8) as i32 +} + +/// i32 / MSB-justified twin of [`fill_dop_period`]. Writes into an `i32` +/// sample buffer (as `io_i32` / a CoreAudio 32-bit render buffer expect) +/// instead of raw bytes. Returns real samples pulled. +pub fn fill_dop_period_i32( + channels: usize, + need_frames: usize, + consumer: &mut Consumer, + silence_phase: &mut u64, + out: &mut [i32], +) -> u64 { + let mut written: u64 = 0; + let mut f = 0usize; + while f < need_frames { + if consumer.slots() < channels { + for g in f..need_frames { + let s = dop_word_to_i32(dop_silence_word(*silence_phase)); + *silence_phase = silence_phase.wrapping_add(1); + for ch in 0..channels { + out[g * channels + ch] = s; + } + } + break; + } + for ch in 0..channels { + let word = consumer.pop().map(|s| s.to_bits()).unwrap_or(0); + out[f * channels + ch] = dop_word_to_i32(word); + } + written += channels as u64; + f += 1; + } + written +} + +/// i32 / MSB-justified twin of [`render_dop_silence`]. +pub fn render_dop_silence_i32( + channels: usize, + need_frames: usize, + phase: &mut u64, + out: &mut [i32], +) { + for f in 0..need_frames { + let s = dop_word_to_i32(dop_silence_word(*phase)); + *phase = phase.wrapping_add(1); + for ch in 0..channels { + out[f * channels + ch] = s; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packed_word_is_three_le_bytes_marker_high() { + // 0x05_12_34 → LE bytes [0x34, 0x12, 0x05]. + let mut b = vec![0u8; 3]; + write_dop_word(false, (0x05 << 16) | 0x1234, &mut b, 0); + assert_eq!(b, vec![0x34, 0x12, 0x05]); + } + + #[test] + fn padded_word_is_four_le_bytes_high_zero() { + // 0xFA_AB_CD → LE bytes [0xCD, 0xAB, 0xFA, 0x00]. + let mut b = vec![0u8; 4]; + write_dop_word(true, (0xFA << 16) | 0xABCD, &mut b, 0); + assert_eq!(b, vec![0xCD, 0xAB, 0xFA, 0x00]); + } + + #[test] + fn silence_word_alternates_marker_and_carries_idle_payload() { + assert_eq!(dop_silence_word(0), (0x05 << 16) | 0x6969); + assert_eq!(dop_silence_word(1), (0xFA << 16) | 0x6969); + assert_eq!(dop_silence_word(2), (0x05 << 16) | 0x6969); + } + + #[test] + fn render_silence_shares_marker_across_channels_and_advances_per_frame() { + // 3 stereo frames → phases 0,1,2 → markers A,B,A on both channels. + let mut phase = 0u64; + let mut b = vec![0u8; 3 * 2 * 3]; // packed, stereo + render_dop_silence(false, 2, 3, &mut phase, &mut b); + let marker_at = |sample_idx: usize| b[sample_idx * 3 + 2]; + // Frame 0 (samples 0,1) → 0x05; frame 1 (2,3) → 0xFA; frame 2 → 0x05. + assert_eq!(marker_at(0), 0x05); + assert_eq!(marker_at(1), 0x05); + assert_eq!(marker_at(2), 0xFA); + assert_eq!(marker_at(3), 0xFA); + assert_eq!(marker_at(4), 0x05); + assert_eq!(marker_at(5), 0x05); + assert_eq!(phase, 3); + } + + #[test] + fn fill_period_underrun_silence_fills_the_rest() { + // Empty ring → all frames become idle silence, zero real samples. + let (_p, mut c) = rtrb::RingBuffer::::new(16); + let mut phase = 0u64; + let mut b = vec![0u8; 2 * 2 * 3]; + let written = fill_dop_period(false, 2, 2, &mut c, &mut phase, &mut b); + assert_eq!(written, 0, "nothing in the ring → no real samples"); + // Every sample carries the idle payload. + for s in 0..4 { + assert_eq!(b[s * 3], 0x69); + assert_eq!(b[s * 3 + 1], 0x69); + } + assert_eq!(phase, 2, "advanced one phase per silence frame"); + } + + #[test] + fn word_to_i32_puts_marker_in_the_top_byte() { + // 0x05_12_34 → << 8 → 0x05_12_34_00. Marker (0x05) in bits 31..24. + let s = dop_word_to_i32((0x05 << 16) | 0x1234); + assert_eq!(s as u32, 0x0512_3400); + assert_eq!((s as u32) >> 24, 0x05, "marker must be the MSB"); + } + + #[test] + fn fill_period_i32_pulls_then_silences_msb_justified() { + let (mut p, mut c) = rtrb::RingBuffer::::new(16); + p.push(f32::from_bits((0x05 << 16) | 0x1111)).unwrap(); + p.push(f32::from_bits((0x05 << 16) | 0x2222)).unwrap(); + let mut phase = 0u64; + let mut out = vec![0i32; 4]; // 2 stereo frames + let written = fill_dop_period_i32(2, 2, &mut c, &mut phase, &mut out); + assert_eq!(written, 2); + assert_eq!(out[0] as u32, 0x0511_1100); + assert_eq!(out[1] as u32, 0x0522_2200); + // Frame 1 = idle silence, MSB-justified. + assert_eq!(out[2] as u32, 0x0569_6900); + assert_eq!(out[3] as u32, 0x0569_6900); + } + + #[test] + fn fill_period_pulls_whole_frames_then_silence() { + // Ring holds exactly one stereo frame of two DoP words. + let (mut p, mut c) = rtrb::RingBuffer::::new(16); + let l = f32::from_bits((0x05 << 16) | 0x1111); + let r = f32::from_bits((0x05 << 16) | 0x2222); + p.push(l).unwrap(); + p.push(r).unwrap(); + let mut phase = 0u64; + let mut b = vec![0u8; 2 * 2 * 3]; // room for 2 frames + let written = fill_dop_period(false, 2, 2, &mut c, &mut phase, &mut b); + assert_eq!(written, 2, "one full stereo frame pulled"); + // Frame 0 = the real words. + assert_eq!(&b[0..3], &[0x11, 0x11, 0x05]); + assert_eq!(&b[3..6], &[0x22, 0x22, 0x05]); + // Frame 1 = idle silence (ring now empty). + assert_eq!(&b[6..9], &[0x69, 0x69, 0x05]); + assert_eq!(&b[9..12], &[0x69, 0x69, 0x05]); + } +} diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index d902611c..0dd80aa9 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. @@ -383,6 +383,7 @@ impl AudioEngine { app.clone(), device_name, wasapi_exclusive, + None, ) { Ok((producer, handle)) => { let active = handle.wasapi_exclusive; @@ -513,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. /// @@ -723,6 +736,131 @@ 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; + + // DoP needs exclusive device access. On Windows that's the + // separate WASAPI Exclusive opt-in, so DoP rides it — if the user + // hasn't enabled exclusive, drop the DoP request up-front rather + // than tear the output down and rebuild it just to fall back to + // PCM on every DSD track. On Linux / macOS the DoP toggle itself + // engages the exclusive path (raw `hw:` / hog mode), so nothing + // else gates it. On any other platform DoP can't run at all. + #[cfg(target_os = "windows")] + let exclusive_available = self.wasapi_exclusive.load(Ordering::Relaxed); + // Linux (raw `hw:`) and macOS (CoreAudio hog mode) engage the + // exclusive path from the DoP toggle itself — no separate opt-in. + #[cfg(any(target_os = "linux", target_os = "macos"))] + let exclusive_available = true; + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let exclusive_available = false; + let dop = if dop.is_some() && exclusive_available { + dop + } else { + None + }; + + 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`] @@ -772,6 +910,7 @@ impl AudioEngine { self.app.clone(), device_name, exclusive, + None, ) { Ok(pair) => pair, Err(err) => { @@ -935,6 +1074,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 +1274,7 @@ impl AudioEngine { self.app.clone(), active.clone(), enabled, + None, ) { Ok(pair) => pair, Err(err) => { diff --git a/src-tauri/crates/app/src/audio/mod.rs b/src-tauri/crates/app/src/audio/mod.rs index 0a7f9192..d0857f5e 100644 --- a/src-tauri/crates/app/src/audio/mod.rs +++ b/src-tauri/crates/app/src/audio/mod.rs @@ -17,6 +17,7 @@ pub mod analytics; pub mod crossfade; pub mod decoder; +pub mod dop_pack; pub mod engine; pub mod eq; pub mod events; @@ -27,6 +28,10 @@ pub mod spectrum; pub mod state; #[cfg(target_os = "windows")] pub mod wasapi_exclusive; +#[cfg(target_os = "linux")] +pub mod alsa_exclusive; +#[cfg(target_os = "macos")] +pub mod coreaudio_exclusive; pub use engine::{AudioCmd, AudioEngine}; pub use output::list_output_devices; diff --git a/src-tauri/crates/app/src/audio/output.rs b/src-tauri/crates/app/src/audio/output.rs index da994ef6..2a6fd50e 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,53 @@ 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 form must NOT fall back + // to a shared / mixed output (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 + // platform's exclusive backend only, and surface its error verbatim: + // WASAPI Exclusive on Windows, a raw `hw:` device on Linux (ALSA), + // hog mode on macOS (CoreAudio). + if let Some(dop) = dop { + #[cfg(target_os = "windows")] + return super::wasapi_exclusive::spawn_exclusive_output_thread( + shared, + app, + device_name, + Some(dop), + ); + #[cfg(target_os = "linux")] + return super::alsa_exclusive::spawn_alsa_dop_output_thread(shared, app, device_name, dop); + #[cfg(target_os = "macos")] + return super::coreaudio_exclusive::spawn_coreaudio_dop_output_thread( + shared, + app, + device_name, + dop, + ); + #[cfg(not(any( + target_os = "windows", + target_os = "linux", + target_os = "macos" + )))] + { + let _ = (dop, &shared, &app, &device_name); + return Err(AppError::Audio( + "DoP output is not supported on this platform".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 +423,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 +491,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..a1646940 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,116 @@ 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() {} + } + super::dop_pack::render_dop_silence( + padded, + channels, + need_frames, + &mut silence_phase, + &mut bytes_scratch, + ); + } else { + let written = super::dop_pack::fill_dop_period( + padded, + channels, + need_frames, + &mut consumer, + &mut silence_phase, + &mut bytes_scratch, + ); + 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 +} + /// Pack the decoded `f32` sample buffer into the little-endian byte /// layout the negotiated exclusive format expects. /// diff --git a/src-tauri/crates/app/src/commands/player.rs b/src-tauri/crates/app/src/commands/player.rs index 2bce3315..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, } } } @@ -455,6 +460,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'", ) @@ -601,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 { @@ -985,6 +1014,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 +1464,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 +1487,7 @@ pub async fn player_get_audio_settings( replaygain, gapless, dsd_taps, + dsd_dop, }) } @@ -1434,6 +1500,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, 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; 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..ce11d0dc 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,21 @@ 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); + // DoP needs exclusive device access, which WaveFlow has on every + // desktop OS: WASAPI Exclusive (Windows), raw ALSA `hw:` (Linux), + // CoreAudio hog mode (macOS). Hidden on mobile (#495 / #497). UA sniff + // like the ExclusiveModeCard; the WebView is platform-pinned. + const dopPlatformSupported = (() => { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent.toLowerCase(); + return ( + ua.includes("windows") || + ua.includes("macintosh") || + // Exclude Android, whose UA also contains "linux". + (ua.includes("linux") && !ua.includes("android")) + ); + })(); // Integrations const [lastfmKey, setLastfmKey] = useState(""); @@ -1645,6 +1661,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 +1701,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 +2289,35 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { + {/* Native DSD via DoP (DSD over PCM). Windows (WASAPI + Exclusive) + Linux (raw ALSA hw) with a DoP-capable DAC; + off by default. Hidden where DoP can't engage yet (macOS, + mobile). */} + {dopPlatformSupported && ( +
+
+
+ +
+ )} + {/* Normaliser le volume */}
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c736a98e..2d869cfd 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 على Windows، وALSA العتادي على Linux، وCoreAudio hog mode على macOS) وDAC متوافقًا مع DoP؛ وإلا يعود إلى DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 910a3af5..6427718f 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 eine exklusive Ausgabe (WASAPI unter Windows, Hardware-ALSA unter Linux, CoreAudio Hog Mode unter macOS) 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..23e804a6 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 an exclusive output (WASAPI on Windows, hardware ALSA on Linux, CoreAudio hog mode on macOS) 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..3cd38511 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 una salida exclusiva (WASAPI en Windows, ALSA hardware en Linux, CoreAudio hog mode en macOS) 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..16105e63 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 une sortie exclusive (WASAPI sur Windows, ALSA hardware sur Linux, CoreAudio hog mode sur macOS) 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..575e08aa 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 को ज्यों-का-त्यों भेजता है — बिट-परफेक्ट नेटिव डिकोडिंग। इसके लिए एक एक्सक्लूसिव आउटपुट (Windows पर WASAPI, Linux पर हार्डवेयर ALSA, macOS पर CoreAudio hog mode) और DoP-समर्थित DAC आवश्यक है; अन्यथा DSD → PCM पर वापस चला जाता है।" } }, "spotify": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index d7e9c32f..60a6988e 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 eksklusif (WASAPI di Windows, ALSA perangkat keras di Linux, CoreAudio hog mode di macOS) 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..b3424ebf 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 un'uscita esclusiva (WASAPI su Windows, ALSA hardware su Linux, CoreAudio hog mode su macOS) 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..9a26d8d7 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に送信します — ビットパーフェクトなネイティブデコード。排他出力(WindowsではWASAPI、Linuxではハードウェア ALSA、macOSではCoreAudio hog mode)とDoP対応DACが必要です。満たさない場合はDSD → PCMにフォールバックします。" } }, "scanProgress": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d3e470f8..27246695 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에 그대로 전송합니다 — 비트 퍼펙트 네이티브 디코딩. 독점 출력(Windows는 WASAPI, Linux는 하드웨어 ALSA, macOS는 CoreAudio hog mode)과 DoP 지원 DAC가 필요하며, 그렇지 않으면 DSD → PCM으로 대체됩니다." } }, "spotify": { diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 14f9e968..65404cb9 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 een exclusieve uitvoer (WASAPI op Windows, hardware-ALSA op Linux, CoreAudio hog mode op macOS) 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..6affe6b7 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 uma saída exclusiva (WASAPI no Windows, ALSA de hardware no Linux, CoreAudio hog mode no macOS) 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..3dfa6710 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 uma saída exclusiva (WASAPI no Windows, ALSA de hardware no Linux, CoreAudio hog mode no macOS) 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..27fdb9a7 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 в Windows, аппаратный ALSA в Linux, CoreAudio hog mode в macOS) и ЦАП с поддержкой DoP; иначе выполняется откат к DSD → PCM." } }, "spotify": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 55262572..133a79eb 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. Özel bir çıkış (Windows'ta WASAPI, Linux'ta donanım ALSA, macOS'ta CoreAudio hog mode) 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..f3325ee4 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——比特完美原生解码。需要独占输出(Windows 上的 WASAPI,Linux 上的硬件 ALSA,macOS 上的 CoreAudio hog mode)和支持 DoP 的 DAC;否则回退到 DSD → PCM。" } }, "scanProgress": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 94c7bd90..aa5bf40d 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——位元完美原生解碼。需要獨佔輸出(Windows 上的 WASAPI,Linux 上的硬體 ALSA,macOS 上的 CoreAudio hog mode)與支援 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×