Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
3 changes: 2 additions & 1 deletion docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>` + `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`.

Expand Down
17 changes: 16 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src-tauri/crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading