From 85b6bf4abd117fc992fbb0dd69b63d0d197df6be Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 19:04:20 +0200 Subject: [PATCH 1/4] chore(deps): bump symphonia from 0.5 to 0.6 symphonia 0.6 redesigned all audio primitives, requiring a manual migration of the two files that touch the decoder API. Closes the dependabot bump that failed CI. API changes: - core::probe::Hint -> core::formats::probe::Hint - core::codecs::Decoder -> core::codecs::audio::AudioDecoder - DecoderOptions -> AudioDecoderOptions - Probe::format() -> Probe::probe() (returns FormatReader directly) - CODEC_TYPE_NULL find -> format.default_track(TrackType::Audio) - track.codec_params (struct) -> codec_params.as_ref().unwrap().audio().unwrap() - CodecRegistry::make -> make_audio_decoder - next_packet EOF moved from IoError(UnexpectedEof) to Ok(None) - packet.track_id() -> packet.track_id (field) - SampleBuffer -> Vec + decoded.copy_to_vec_interleaved - spec().channels.count() -> .channels().count() (methods now) - Time::from(Duration) -> Time::try_from_secs_f64 Added the `all-meta` feature so probe scoring keeps ID3/APE/Vorbis tag detection. Tag reading still goes through lofty. Validated: 97 unit tests pass + manual playback of MP3/FLAC/M4A AAC, crossfade, ReplayGain, spectrum visualizer. --- CLAUDE.md | 2 +- README.md | 2 +- docs/features/playback.md | 4 +- src-tauri/Cargo.lock | 108 ++++++++++++++--------------- src-tauri/Cargo.toml | 6 +- src-tauri/src/analysis.rs | 55 +++++++-------- src-tauri/src/audio/crossfade.rs | 87 +++++++++++++---------- src-tauri/src/audio/dsd/mod.rs | 2 +- src/components/views/AboutView.tsx | 4 +- 9 files changed, 141 insertions(+), 129 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d0722e..032bece 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). 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/components/views/AboutView.tsx b/src/components/views/AboutView.tsx index aedd96e..3a53253 100644 --- a/src/components/views/AboutView.tsx +++ b/src/components/views/AboutView.tsx @@ -312,7 +312,7 @@ export function AboutView({ onNavigate }: AboutViewProps) {
- +
@@ -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" />
From e1773f6f464b20dafb47bd3c77f22e7359eb8545 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 19:04:55 +0200 Subject: [PATCH 2/4] feat(db): self-heal migration checksum drift from line-ending changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows users with core.autocrlf=true (Git for Windows installer default) can land a new .sql migration with CRLF endings in their working tree for the brief window between checkout and .gitattributes-driven renormalize. sqlx computes the SHA-384 of whatever bytes are on disk at apply time and stores it in _sqlx_migrations.checksum. A later `git pull` or `git add --renormalize` restores LF, but the stored checksum still points at the CRLF variant, and every subsequent boot panics with: migration was previously applied but has been modified (no auto-recovery — user has to wipe their data dir). This module runs before every Migrator::run. For each stored row whose checksum doesn't match the compiled-in migration, it recomputes SHA-384 over the LF-only and CRLF-only variants of the embedded SQL. If either matches, the divergence is line-ending-only and the stored row is silently rewritten to the canonical hash with a tracing::warn. A real SQL change still panics, because neither normalization will rescue it. Confirmed live: on a machine where 4 of 5 stored checksums matched LF and 1 (the newest) matched CRLF, the heal hook fixed the lone row at next boot, logged the warning, and let Migrator::run proceed without further intervention. Tests: 4 new unit tests covering LF/CRLF round-trip, lone-CR preservation, idempotence on LF, and distinct hashes per variant. CLAUDE.md cross-cutting rule updated. --- CLAUDE.md | 2 +- src-tauri/src/db/app_db.rs | 9 +- src-tauri/src/db/migration_heal.rs | 196 +++++++++++++++++++++++++++++ src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/profile_db.rs | 7 +- 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/db/migration_heal.rs diff --git a/CLAUDE.md b/CLAUDE.md index 032bece..6829595 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/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..3fc32b2 --- /dev/null +++ b/src-tauri/src/db/migration_heal.rs @@ -0,0 +1,196 @@ +//! 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 { + let mut out = Vec::with_capacity(lf.len() + lf.len() / 32); + 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) } From bf9b750b05cdf34309dee27a5dd07142749200d9 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 19:05:09 +0200 Subject: [PATCH 3/4] fix(player): prevent native drag from hijacking the volume slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without preventDefault on pointerdown plus select-none on the track, the WebView interprets the gesture as a text/image drag, steals the pointer-event stream mid-drag, flips the cursor to "no-drop", and freezes the slider. Adds: - e.preventDefault() in handlePointerDown to suppress the drag fallback - onDragStart={e => e.preventDefault()} as belt-and-suspenders - select-none on the track to remove the competing text-selection gesture - onPointerUp / onPointerCancel that releasePointerCapture so the capture isn't leaked when the user drops outside the track ProgressBar has the same structure and the same latent vulnerability, but no bug has been reported there — leaving it for now, will apply the same idiom if it surfaces. --- src/components/player/VolumeControl.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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" >
Date: Mon, 18 May 2026 19:13:55 +0200 Subject: [PATCH 4/4] fix(db): pre-count newlines for lf_to_crlf exact capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit review on #53: the previous +1/32 margin tripped reallocations on every realistic migration (typical SQL is 5–10 % `\n`), while a blanket ×2 would waste roughly half the allocation. A linear pre-pass over a few KB of bytes is free. --- src-tauri/src/db/migration_heal.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/db/migration_heal.rs b/src-tauri/src/db/migration_heal.rs index 3fc32b2..ad5c353 100644 --- a/src-tauri/src/db/migration_heal.rs +++ b/src-tauri/src/db/migration_heal.rs @@ -141,7 +141,13 @@ fn normalize_to_lf(input: &[u8]) -> Vec { /// pre-existing CR) alone. Inverse partner of [`normalize_to_lf`] for /// the second hash we compare against. fn lf_to_crlf(lf: &[u8]) -> Vec { - let mut out = Vec::with_capacity(lf.len() + lf.len() / 32); + // 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');