diff --git a/CLAUDE.md b/CLAUDE.md index 1d0722e..6829595 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Rust / Tauri 2. Entry: `src-tauri/src/main.rs` → `lib.rs`. - **Commands** (`src-tauri/src/commands/`): organized by domain — `library.rs`, `playlist.rs`, `smart_playlists.rs`, `track.rs`, `browse.rs`, `player.rs`, `scan.rs`, `edit.rs`, `profile.rs`, `analysis.rs`, `deezer.rs`, `similar.rs`, `lyrics.rs`, `stats.rs`, `wrapped.rs`, `integration.rs`, `maintenance.rs`, `app_info.rs`, `radio.rs`, `mood_radio.rs`, `duplicates.rs`, `preferences.rs`, `share.rs`, `changelog.rs`, etc. All registered in `lib.rs` via `generate_handler![]`. - **External API clients** (crate-root modules): `deezer.rs` (public Deezer, no auth), `lastfm.rs` (`artist.getInfo`, user API key required). Both use `reqwest` with `rustls-tls`. -- **Audio engine** (`src-tauri/src/audio/`): 3-thread lock-free architecture — `decoder.rs` (symphonia + rubato), `output.rs` (cpal callback on a dedicated thread, SPSC `rtrb` ring buffer), `state.rs` (`SharedPlayback` with atomics, no locks in hot path), `analytics.rs` (tokio task for `play_event` writes + auto-advance), `crossfade.rs`, `eq.rs`, `spectrum.rs`, `wasapi_exclusive.rs` (Windows-only opt-in), and `dsd/` (in-house DSF/DFF parser + DSD→PCM converter, Symphonia 0.5 doesn't decode DSD). Deep dive: [`docs/features/playback.md`](docs/features/playback.md). +- **Audio engine** (`src-tauri/src/audio/`): 3-thread lock-free architecture — `decoder.rs` (symphonia + rubato), `output.rs` (cpal callback on a dedicated thread, SPSC `rtrb` ring buffer), `state.rs` (`SharedPlayback` with atomics, no locks in hot path), `analytics.rs` (tokio task for `play_event` writes + auto-advance), `crossfade.rs`, `eq.rs`, `spectrum.rs`, `wasapi_exclusive.rs` (Windows-only opt-in), and `dsd/` (in-house DSF/DFF parser + DSD→PCM converter, Symphonia doesn't decode DSD). Deep dive: [`docs/features/playback.md`](docs/features/playback.md). - **DLNA / UPnP MediaServer** (`src-tauri/src/dlna/`): worker thread → axum HTTP server + SSDP announcer. Opt-in (`app_setting['dlna.enabled']`, default OFF). See [`docs/features/dlna.md`](docs/features/dlna.md). - **OS media controls** (`media_controls.rs`): souvlaki bridge → SMTC / MPRIS / MediaRemote. Initialized post-window (needs HWND on Windows). - **Discord Rich Presence** (`discord_presence.rs`): named-pipe IPC, opt-in `app_setting['integrations.discord_rpc']` (default ON). See [`docs/features/integrations.md`](docs/features/integrations.md#discord-rich-presence). @@ -68,7 +68,7 @@ These bite you if you ignore them — they're the contract the rest of the codeb - **Persistence**: per-profile settings live in `profile_setting` (key-value, typed). Pattern: `INSERT ... ON CONFLICT DO UPDATE`. App-wide settings live in `app_setting` with the same shape. - **Events**: backend emits Tauri events (`player:state`, `player:position`, `player:track-changed`, `player:queue-changed`, `player:error`, `player:ab-loop`, `player:spectrum`, `track:updated`, `library:rescanned`, `scan:progress`, `lyrics:updated`, …). Frontend listens via `listen()` from `@tauri-apps/api/event`. - **Audio callback is hot**: the cpal callback (and the WASAPI exclusive thread) MUST NOT allocate, lock, or log. Only `rtrb::Consumer` reads + `Atomic*` loads. All heavy work (EQ, ReplayGain, resampling, FFT, BLAKE3) runs on the decoder thread before samples reach the SPSC ring. -- **Migrations are immutable once merged**: sqlx records a SHA-384 checksum in `_sqlx_migrations.checksum` at apply time, so editing a merged migration crashes every existing install at boot with `"migration was previously applied but has been modified"` (no auto-recovery — user has to wipe their data dir). For any schema evolution, **create a new dated migration** `YYYYMMDDhhmmss_.sql`. Same rule for `migrations/app/`. +- **Migrations are immutable once merged**: sqlx records a SHA-384 checksum in `_sqlx_migrations.checksum` at apply time, so editing a merged migration crashes every existing install at boot with `"migration was previously applied but has been modified"`. For any schema evolution, **create a new dated migration** `YYYYMMDDhhmmss_.sql`. Same rule for `migrations/app/`. **Line-ending drift is a non-event** — [`db::migration_heal`](src-tauri/src/db/migration_heal.rs) reconciles stored checksums against the compiled-in migrator before each `Migrator::run`: when the stored hash matches the LF or CRLF variant of the same SQL (Windows `core.autocrlf=true` regression), it silently rewrites the row to the canonical hash and logs a warning. A real SQL change still panics, because neither LF nor CRLF normalization will rescue it. - **Virtual scroll everywhere**: TrackTable uses `@tanstack/react-virtual` for 6000+ track performance. Virtualized tables consume `usePageScroll()` for the scroll element instead of nesting their own `overflow-y-auto` — drives a single Spotify-style scrollbar. - **Multi-artist queries**: the scanner splits `"A, B"` on `", " / "; "` into individual `artist` rows linked via `track_artist`. Queries rebuild the display string via `GROUP_CONCAT` over `track_artist` ordered by `position`. `ArtistLink` accepts parallel `artist_name` + `artist_ids` strings so every contributor is individually clickable. New track queries must follow the same join pattern. - **Album grouping = `(canonical_title, album_artist_id)`**: [`scan.rs::upsert_album`](src-tauri/src/commands/scan.rs) keys on the album artist (Album Artist tag → `is_compilation` → primary artist fallback). `album.is_compilation` is sticky and `merge_implicit_compilations` collapses ≥ 3 distinct-artist same-title rows into "Various Artists" after every scan. `edit.rs` re-runs `upsert_album` with the OLD album's Album Artist / compilation flags so renames don't re-split. Deep dive: [`docs/features/library.md`](docs/features/library.md#album-grouping). diff --git a/README.md b/README.md index 967160a..c1b19d7 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Once installed (any of the above), the in-app updater fetches future versions au | **Discord Rich Presence** | discord-rich-presence 1.1 (local IPC named pipe, no auth) | | **Frontend** | React 19, TypeScript, Vite 8, Tailwind CSS 4, Lucide icons, `@dnd-kit` (drag-and-drop), `@tanstack/react-virtual` (virtualization) | | **Backend** | Rust, SQLite (sqlx), FTS5 contentless full-text search | -| **Audio** | symphonia 0.5 (decode), cpal 0.17 (output), rubato 2.0 (resample), rtrb 0.3 (SPSC ring) | +| **Audio** | symphonia 0.6 (decode), cpal 0.17 (output), rubato 2.0 (resample), rtrb 0.3 (SPSC ring) | | **Metadata extraction** | lofty 0.24 (tags, embedded art, POPM, INITIALKEY) | | **Imaging** | image 0.25 + fast_image_resize 6 (SIMD thumbnails) | | **Filesystem watcher** | notify 8 (debounced rescans of watched folders) | diff --git a/docs/features/playback.md b/docs/features/playback.md index 4999599..c69ee1d 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -4,8 +4,8 @@ The audio path lives in [`src-tauri/src/audio/`](../../src-tauri/src/audio). It ## Decoding & output -- **Decoder** — [`symphonia 0.5`](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, 5.1 → stereo Lo/Ro per ITU BS.775), 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). -- **DSD pipeline** — symphonia 0.5 doesn't decode 1-bit DSD, so DSF (Sony) and DFF (Philips) containers route through [`audio/dsd/`](../../src-tauri/src/audio/dsd/): a custom container parser reads the layout (DSD64 → DSD1024, mono / stereo / multichannel), and a 256-tap windowed-sinc FIR with a Blackman-Harris envelope 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. +- **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, 5.1 → stereo Lo/Ro per ITU BS.775), 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). +- **DSD pipeline** — symphonia doesn't decode 1-bit DSD, so DSF (Sony) and DFF (Philips) containers route through [`audio/dsd/`](../../src-tauri/src/audio/dsd/): a custom container parser reads the layout (DSD64 → DSD1024, mono / stereo / multichannel), and a 256-tap windowed-sinc FIR with a Blackman-Harris envelope 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. - **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 09a0ce2..1ba8ca4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1371,15 +1371,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -4164,6 +4155,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -5245,9 +5242,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "symphonia" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" dependencies = [ "lazy_static", "symphonia-bundle-flac", @@ -5265,54 +5262,55 @@ dependencies = [ [[package]] name = "symphonia-bundle-flac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-bundle-mp3" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" dependencies = [ "lazy_static", "log", "symphonia-core", - "symphonia-metadata", ] [[package]] name = "symphonia-codec-aac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" dependencies = [ "lazy_static", "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-alac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8" dependencies = [ "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-pcm" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" dependencies = [ "log", "symphonia-core", @@ -5320,58 +5318,69 @@ dependencies = [ [[package]] name = "symphonia-codec-vorbis" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" dependencies = [ "log", + "symphonia-common", "symphonia-core", - "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", ] [[package]] name = "symphonia-core" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" dependencies = [ - "arrayvec", - "bitflags 1.3.2", + "bitflags 2.11.1", "bytemuck", "lazy_static", "log", + "num-complex", + "smallvec", ] [[package]] name = "symphonia-format-isomp4" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" dependencies = [ - "encoding_rs", "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-ogg" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-riff" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" dependencies = [ "extended", "log", @@ -5381,26 +5390,17 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" dependencies = [ - "encoding_rs", "lazy_static", "log", + "regex-lite", + "smallvec", "symphonia-core", ] -[[package]] -name = "symphonia-utils-xiph" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" -dependencies = [ - "symphonia-core", - "symphonia-metadata", -] - [[package]] name = "syn" version = "1.0.109" @@ -6234,9 +6234,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5977eec..9dd0498 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -94,7 +94,7 @@ blake3 = "1" # Audio playback: symphonia decodes, cpal outputs, rubato resamples, # rtrb is the SPSC ring between decoder thread and audio callback, # crossbeam-channel carries AudioCmd from tokio commands to the decoder. -symphonia = { version = "0.5", default-features = false, features = [ +symphonia = { version = "0.6", default-features = false, features = [ "mp3", "flac", "wav", @@ -104,6 +104,10 @@ symphonia = { version = "0.5", default-features = false, features = [ "alac", "isomp4", "pcm", + # Symphonia 0.6 split metadata behind feature flags; `all-meta` keeps + # ID3/APE/Vorbis tag detection during probe scoring (the scanner uses + # lofty for the actual tag read, so this is purely for probe accuracy). + "all-meta", ] } cpal = "0.17" rubato = "2.0" diff --git a/src-tauri/src/analysis.rs b/src-tauri/src/analysis.rs index e41ee18..041d28a 100644 --- a/src-tauri/src/analysis.rs +++ b/src-tauri/src/analysis.rs @@ -21,13 +21,12 @@ use std::fs::File; use std::path::Path; -use symphonia::core::audio::SampleBuffer; -use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::codecs::audio::AudioDecoderOptions; use symphonia::core::errors::Error as SymphoniaError; -use symphonia::core::formats::FormatOptions; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, TrackType}; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; const REPLAY_GAIN_TARGET_DB: f64 = -18.0; /// Energy-envelope hop in samples at the analysis sample rate. @@ -67,28 +66,30 @@ pub fn analyze_file(path: &Path) -> Result { hint.with_extension(ext); } - let probed = symphonia::default::get_probe() - .format( + let mut format = symphonia::default::get_probe() + .probe( &hint, mss, - &FormatOptions::default(), - &MetadataOptions::default(), + FormatOptions::default(), + MetadataOptions::default(), ) .map_err(|e| format!("probe: {e}"))?; - let mut format = probed.format; let track = format - .tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .default_track(TrackType::Audio) .ok_or_else(|| "no decodable track".to_string())?; let track_id = track.id; - let codec_params = track.codec_params.clone(); + let audio_params = track + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or_else(|| "track has no audio codec params".to_string())?; let mut decoder = symphonia::default::get_codecs() - .make(&codec_params, &DecoderOptions::default()) + .make_audio_decoder(audio_params, &AudioDecoderOptions::default()) .map_err(|e| format!("codec init: {e}"))?; - let mut sample_buf: Option> = None; + let mut interleaved: Vec = Vec::new(); + let mut spec_captured = false; let mut src_channels: usize = 0; let mut src_rate: u32 = 0; @@ -112,14 +113,12 @@ pub fn analyze_file(path: &Path) -> Result { loop { let packet = match format.next_packet() { - Ok(p) => p, - Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - break; - } + Ok(Some(p)) => p, + Ok(None) => break, Err(SymphoniaError::ResetRequired) => break, Err(e) => return Err(format!("next_packet: {e}")), }; - if packet.track_id() != track_id { + if packet.track_id != track_id { continue; } @@ -132,20 +131,18 @@ pub fn analyze_file(path: &Path) -> Result { Err(e) => return Err(format!("decode: {e}")), }; - if sample_buf.is_none() { - let spec = *decoded.spec(); - let capacity = decoded.capacity() as u64; - sample_buf = Some(SampleBuffer::::new(capacity, spec)); - src_channels = spec.channels.count().max(1); - src_rate = spec.rate.max(1); + if !spec_captured { + let spec = decoded.spec(); + src_channels = spec.channels().count().max(1); + src_rate = spec.rate().max(1); + spec_captured = true; // Pre-decimate to ~11 kHz mono envelope for BPM. Feeding // the autocorrelation a 44.1 kHz envelope would balloon // memory and add no useful resolution. decimate_stride = (src_rate / BPM_TARGET_RATE_HZ).max(1) as usize; } - let sb = sample_buf.as_mut().unwrap(); - sb.copy_interleaved_ref(decoded); - let samples = sb.samples(); + decoded.copy_to_vec_interleaved(&mut interleaved); + let samples = &interleaved[..]; // Walk frames: average channels, accumulate loudness + peak, // feed every Nth mono sum to the envelope. diff --git a/src-tauri/src/audio/crossfade.rs b/src-tauri/src/audio/crossfade.rs index 0709700..dc3a669 100644 --- a/src-tauri/src/audio/crossfade.rs +++ b/src-tauri/src/audio/crossfade.rs @@ -10,13 +10,12 @@ use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -use symphonia::core::audio::SampleBuffer; -use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions}; use symphonia::core::errors::Error as SymphoniaError; -use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType}; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; use symphonia::core::units::Time; use super::dsd::parser::{parse_dff, parse_dsf, DsdLayout}; @@ -25,13 +24,20 @@ use super::resampler::Resampler; /// Per-stream decoder backend. Symphonia handles the FLAC / MP3 / /// AAC / WAV / OGG / ALAC family; DSD is a native pipeline because -/// symphonia 0.5 doesn't decode it. +/// symphonia doesn't decode it. pub enum StreamBackend { Symphonia { format: Box, - decoder: Box, - sample_buf: Option>, + decoder: Box, + /// Reusable interleaved f32 destination for + /// `GenericAudioBufferRef::copy_to_vec_interleaved`. Replaces the + /// old `SampleBuffer` field — symphonia 0.6 removed + /// `SampleBuffer`; the equivalent is now a plain Vec that + /// the decoded buffer fills via the conversion helpers on the + /// `Audio` trait. + decoded_interleaved: Vec, symphonia_track_id: u32, + spec_captured: bool, }, Dsd { file: File, @@ -100,7 +106,7 @@ impl ActiveStream { .and_then(|s| s.to_str()) .map(|s| s.to_ascii_lowercase()); - // DSD has its own pipeline — symphonia 0.5 doesn't decode it. + // DSD has its own pipeline — symphonia doesn't decode it. // Branch up-front so we never hand a DSF / DFF file to the // probe (which would fail with a confusing "unknown format" // even though the file is valid). @@ -122,32 +128,34 @@ impl ActiveStream { if let Some(ext) = ext.as_deref() { hint.with_extension(ext); } - let probed = symphonia::default::get_probe() - .format( + let format = symphonia::default::get_probe() + .probe( &hint, mss, - &FormatOptions::default(), - &MetadataOptions::default(), + FormatOptions::default(), + MetadataOptions::default(), ) .map_err(|e| format!("probe: {e}"))?; - let format = probed.format; let track_symphonia = format - .tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .default_track(TrackType::Audio) .ok_or_else(|| "no decodable track".to_string())?; let symphonia_track_id = track_symphonia.id; - let codec_params = track_symphonia.codec_params.clone(); + let audio_params = track_symphonia + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or_else(|| "track has no audio codec params".to_string())?; let decoder = symphonia::default::get_codecs() - .make(&codec_params, &DecoderOptions::default()) + .make_audio_decoder(audio_params, &AudioDecoderOptions::default()) .map_err(|e| format!("codec init: {e}"))?; Ok(Self { backend: StreamBackend::Symphonia { format, decoder, - sample_buf: None, + decoded_interleaved: Vec::new(), symphonia_track_id, + spec_captured: false, }, // Real resampler is built after the first packet (we need // the actual source rate, which AAC/M4A only reveals then). @@ -287,7 +295,14 @@ impl ActiveStream { symphonia_track_id, .. } => { - let time = Time::from(std::time::Duration::from_millis(ms)); + // Symphonia 0.6 dropped `Time::from(Duration)` / + // `Time::new(secs, frac)`; the closest replacement is + // `Time::try_from_secs_f64`, which constructs a + // (seconds, nanoseconds) pair from a fractional-seconds + // f64. A negative/non-finite ms is impossible here (u64 + // input), so the option is always `Some`; we fall back + // to t=0 just to keep the call infallible. + let time = Time::try_from_secs_f64(ms as f64 / 1000.0).unwrap_or_default(); if let Err(err) = format.seek( SeekMode::Accurate, SeekTo::Time { @@ -357,20 +372,17 @@ impl ActiveStream { StreamBackend::Symphonia { format, decoder, - sample_buf, + decoded_interleaved, symphonia_track_id, + spec_captured, } => loop { let packet = match format.next_packet() { - Ok(p) => p, - Err(SymphoniaError::IoError(e)) - if e.kind() == std::io::ErrorKind::UnexpectedEof => - { - return Ok(true); - } + Ok(Some(p)) => p, + Ok(None) => return Ok(true), Err(SymphoniaError::ResetRequired) => return Ok(true), Err(e) => return Err(format!("next_packet: {e}")), }; - if packet.track_id() != *symphonia_track_id { + if packet.track_id != *symphonia_track_id { continue; } let decoded = match decoder.decode(&packet) { @@ -381,21 +393,20 @@ impl ActiveStream { } Err(e) => return Err(format!("decode fatal: {e}")), }; - if sample_buf.is_none() { - let spec = *decoded.spec(); - let capacity = decoded.capacity() as u64; - *sample_buf = Some(SampleBuffer::::new(capacity, spec)); - self.src_channels = spec.channels.count(); - self.src_sample_rate = spec.rate; - let effective_rate = ((spec.rate as f32) * self.playback_speed).max(1.0) as u32; + if !*spec_captured { + let spec = decoded.spec(); + self.src_channels = spec.channels().count(); + self.src_sample_rate = spec.rate(); + let effective_rate = + ((spec.rate() as f32) * self.playback_speed).max(1.0) as u32; self.resampler = Resampler::new(effective_rate, dst_sample_rate, dst_channels) .map_err(|e| format!("resampler init: {e}"))?; + *spec_captured = true; } - let sb = sample_buf.as_mut().unwrap(); - sb.copy_interleaved_ref(decoded); + decoded.copy_to_vec_interleaved(decoded_interleaved); interleaved_scratch.clear(); super::decoder::convert_channels( - sb.samples(), + decoded_interleaved, self.src_channels, dst_channels, interleaved_scratch, diff --git a/src-tauri/src/audio/dsd/mod.rs b/src-tauri/src/audio/dsd/mod.rs index 1efba07..e136168 100644 --- a/src-tauri/src/audio/dsd/mod.rs +++ b/src-tauri/src/audio/dsd/mod.rs @@ -1,6 +1,6 @@ //! Direct Stream Digital (DSD) support. //! -//! Symphonia 0.5 doesn't decode DSD natively (1-bit @ 2.8 / 5.6 / 11.2 +//! Symphonia doesn't decode DSD natively (1-bit @ 2.8 / 5.6 / 11.2 //! / 22.5 MHz), so this module owns the whole pipeline: //! //! - [`parser`] reads the two on-disk container formats: **DSF** diff --git a/src-tauri/src/db/app_db.rs b/src-tauri/src/db/app_db.rs index a93c976..cb9e15b 100644 --- a/src-tauri/src/db/app_db.rs +++ b/src-tauri/src/db/app_db.rs @@ -30,7 +30,14 @@ pub async fn open(path: &Path) -> AppResult { .connect_with(opts) .await?; - sqlx::migrate!("./migrations/app").run(&pool).await?; + // Reconcile any line-ending drift in `_sqlx_migrations` BEFORE + // running the migrator — without this, a Windows working tree that + // briefly held CRLF when a new migration was first applied will + // panic at every subsequent boot once the file is restored to LF. + // See [`crate::db::migration_heal`] for the full backstory. + let migrator = sqlx::migrate!("./migrations/app"); + super::migration_heal::heal_line_ending_drift(&pool, &migrator).await?; + migrator.run(&pool).await?; Ok(pool) } diff --git a/src-tauri/src/db/migration_heal.rs b/src-tauri/src/db/migration_heal.rs new file mode 100644 index 0000000..ad5c353 --- /dev/null +++ b/src-tauri/src/db/migration_heal.rs @@ -0,0 +1,202 @@ +//! Self-healing for sqlx migration checksum mismatches caused by +//! line-ending drift on Windows. +//! +//! ## The problem +//! +//! `sqlx::migrate!` reads each `.sql` file at compile time, computes a +//! SHA-384, and embeds it in the binary. On first apply, sqlx writes +//! that hash into `_sqlx_migrations.checksum`. On every subsequent boot +//! it re-reads the embedded hash and panics if the stored row differs: +//! +//! > `migration was previously applied but has been modified` +//! +//! The intent — protect users from a maintainer silently editing a +//! merged migration — is correct. The trouble is the hash is computed +//! over **raw bytes**, so a file that round-trips between LF and CRLF +//! changes its checksum even though the SQL is byte-for-byte identical +//! after newline normalization. +//! +//! On Windows with `core.autocrlf=true` (Git for Windows installer +//! default), a fresh checkout of a new `.sql` file can land as CRLF for +//! a brief window — long enough to be applied to the user's DB — before +//! `.gitattributes` (which forces `*.sql text eol=lf` for us) or +//! `git add --renormalize` restores LF. The stored checksum now points +//! at the CRLF variant; the next boot reads the LF file and panics. +//! +//! ## What this module does +//! +//! Before each `sqlx::migrate!().run()`, for every row in +//! `_sqlx_migrations` whose stored checksum doesn't match the +//! compiled-in migration's checksum: +//! +//! 1. Recompute SHA-384 of the migration SQL after normalizing line +//! endings to LF, and again after normalizing to CRLF. +//! 2. If either matches the stored row, the divergence is +//! line-ending-only. Overwrite the stored row with the canonical +//! (compiled-in) hash and emit a `tracing::warn!`. +//! 3. Otherwise, leave the row alone — sqlx will panic, as it should, +//! because that's a real SQL change. +//! +//! This is safe: it never accepts a SQL mutation, only confirms the +//! same statements would still parse if the developer happened to be +//! on the other newline platform. A maliciously edited migration whose +//! LF-or-CRLF hash happens to collide with the previous checksum is a +//! SHA-384 second-preimage attack — not a realistic risk. + +use sqlx::{migrate::Migrator, SqlitePool}; + +use crate::error::AppResult; + +/// Reconcile `_sqlx_migrations.checksum` rows against the compiled-in +/// migrator. Returns the number of rows healed (0 on a fresh DB or when +/// nothing needed fixing). Always safe to call before +/// [`Migrator::run`] — it short-circuits when `_sqlx_migrations` +/// doesn't exist yet. +pub async fn heal_line_ending_drift( + pool: &SqlitePool, + migrator: &Migrator, +) -> AppResult { + // Fresh database: `_sqlx_migrations` is created by `Migrator::run` + // itself, so on first boot there's nothing to reconcile. + let table_present: Option = sqlx::query_scalar( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'", + ) + .fetch_optional(pool) + .await?; + if table_present.is_none() { + return Ok(0); + } + + let stored: Vec<(i64, Vec)> = + sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations") + .fetch_all(pool) + .await?; + + let mut healed = 0usize; + for (version, stored_ck) in stored { + let Some(migration) = migrator.iter().find(|m| m.version == version) else { + // Stored row with no matching compiled-in migration. Don't + // touch it — sqlx will surface it through its own error + // path if it matters. + continue; + }; + + let canonical = migration.checksum.as_ref(); + if stored_ck.as_slice() == canonical { + continue; + } + + let sql_bytes = migration.sql.as_bytes(); + let lf = normalize_to_lf(sql_bytes); + let crlf = lf_to_crlf(&lf); + + let lf_hash = sha384(&lf); + let crlf_hash = sha384(&crlf); + + if stored_ck.as_slice() == lf_hash.as_slice() + || stored_ck.as_slice() == crlf_hash.as_slice() + { + sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") + .bind(canonical) + .bind(version) + .execute(pool) + .await?; + healed += 1; + tracing::warn!( + version, + "self-healed migration checksum drift (line-ending mismatch — stored hash matched LF or CRLF variant of the same SQL)" + ); + } + // else: real divergence. Falls through to sqlx::migrate! which + // will panic with the usual "previously applied but modified" + // message, which is the correct behavior — a merged migration + // was edited. + } + + if healed > 0 { + tracing::info!(healed, "reconciled migration checksums after line-ending drift"); + } + Ok(healed) +} + +/// Collapse any CRLF in `input` to LF, leaving lone CR and lone LF +/// alone. Mirrors what `.gitattributes`' `text eol=lf` does on +/// checkout. +fn normalize_to_lf(input: &[u8]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + let mut i = 0; + while i < input.len() { + if input[i] == b'\r' && i + 1 < input.len() && input[i + 1] == b'\n' { + // skip the \r, the upcoming \n will land on its own + i += 1; + continue; + } + out.push(input[i]); + i += 1; + } + out +} + +/// Expand every LF in `lf` into CRLF, leaving anything else (notably +/// pre-existing CR) alone. Inverse partner of [`normalize_to_lf`] for +/// the second hash we compare against. +fn lf_to_crlf(lf: &[u8]) -> Vec { + // Pre-count newlines so the destination Vec lands at exact capacity. + // Typical SQL is 5–10 % `\n`; the previous +1/32 margin tripped + // reallocations on every realistic migration, while a blanket ×2 + // would waste roughly half the allocation. A linear pre-pass over + // a few KB of bytes is free. + let lf_count = lf.iter().filter(|&&b| b == b'\n').count(); + let mut out = Vec::with_capacity(lf.len() + lf_count); + for &b in lf { + if b == b'\n' { + out.push(b'\r'); + } + out.push(b); + } + out +} + +fn sha384(data: &[u8]) -> [u8; 48] { + use sha2::{Digest, Sha384}; + let mut hasher = Sha384::new(); + hasher.update(data); + hasher.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lf_to_crlf_round_trips_through_normalize() { + let lf = b"a\nb\nc\n"; + let crlf = lf_to_crlf(lf); + assert_eq!(crlf, b"a\r\nb\r\nc\r\n"); + assert_eq!(normalize_to_lf(&crlf), lf); + } + + #[test] + fn normalize_preserves_lone_cr() { + // An old Mac classic line ending or a literal embedded \r in a + // string should NOT be collapsed — only the CRLF pair. + let mixed = b"a\rb\r\nc"; + assert_eq!(normalize_to_lf(mixed), b"a\rb\nc"); + } + + #[test] + fn normalize_is_idempotent_on_lf() { + let lf = b"line1\nline2\n"; + assert_eq!(normalize_to_lf(lf), lf); + } + + #[test] + fn lf_and_crlf_variants_hash_differently() { + // Sanity: if these collided the whole heuristic would be a + // no-op. SHA-384 over distinct inputs must give distinct + // outputs (modulo cosmic-ray collisions). + let lf = b"create table foo (id int);\n"; + let crlf = lf_to_crlf(lf); + assert_ne!(sha384(lf), sha384(&crlf)); + } +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index dae1c1f..eacda6a 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -6,4 +6,5 @@ //! * [`profile_db`] — per-profile library, playlists, history, analytics. pub mod app_db; +pub mod migration_heal; pub mod profile_db; diff --git a/src-tauri/src/db/profile_db.rs b/src-tauri/src/db/profile_db.rs index 85840f4..b13b596 100644 --- a/src-tauri/src/db/profile_db.rs +++ b/src-tauri/src/db/profile_db.rs @@ -54,7 +54,12 @@ pub async fn open(path: &Path, app_db_path: &Path) -> AppResult { .connect_with(opts) .await?; - sqlx::migrate!("./migrations/profile").run(&pool).await?; + // Same self-healing dance as `app_db::open` — line-ending drift + // in the working tree gets reconciled before sqlx's strict + // checksum check fires. Details in [`crate::db::migration_heal`]. + let migrator = sqlx::migrate!("./migrations/profile"); + super::migration_heal::heal_line_ending_drift(&pool, &migrator).await?; + migrator.run(&pool).await?; Ok(pool) } diff --git a/src/components/player/VolumeControl.tsx b/src/components/player/VolumeControl.tsx index 8e198a6..cebf726 100644 --- a/src/components/player/VolumeControl.tsx +++ b/src/components/player/VolumeControl.tsx @@ -22,6 +22,12 @@ export function VolumeControl() { }; const handlePointerDown = (e: ReactPointerEvent) => { + // Stop the browser from interpreting the gesture as a text / + // image drag — when that happens the pointer-event stream gets + // hijacked, the cursor flips to "no-drop", and the slider stops + // tracking the mouse. `preventDefault` on pointerdown reliably + // suppresses that fallback path inside Tauri's WebView. + e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); updateFromClientX(e.clientX); }; @@ -31,6 +37,12 @@ export function VolumeControl() { updateFromClientX(e.clientX); }; + const handlePointerUp = (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }; + const handleKeyDown = (e: ReactKeyboardEvent) => { switch (e.key) { case "ArrowLeft": @@ -79,8 +91,11 @@ export function VolumeControl() { aria-valuenow={volume} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onDragStart={(e) => e.preventDefault()} onKeyDown={handleKeyDown} - className="flex-1 flex items-center h-6 cursor-pointer touch-none group focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-full" + className="flex-1 flex items-center h-6 cursor-pointer touch-none select-none group focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-full" >
- +
@@ -343,7 +343,7 @@ export function AboutView({ onNavigate }: AboutViewProps) { {t("about.sections.audio")}
- } name="Symphonia" version="0.5" /> + } name="Symphonia" version="0.6" /> } name="CPAL" version="0.17" /> } name="Rubato" version="1.0" />