Skip to content
Merged
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 docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src
- **Bulk list endpoints wire format** — `list_tracks` / `list_playlist_tracks` / `list_liked_tracks` (track-shaped) and `list_albums` / `list_artists` (browse-shaped) all return `{ artwork_base, items: <Slim>[] }` instead of the full row shape (artists additionally include `metadata_artwork_base` for the Deezer cache). Each slim row carries `artwork_hash` + `artwork_format` + `artwork_has_1x` + `artwork_has_2x` (artists also `picture_hash` + `picture_has_*`) instead of three full path strings; the ~70-char per-profile prefix only appears once in the response. Frontend wrappers ([`expandTrackResponse`](../../src/lib/tauri/track.ts), `expandAlbumRow` / `expandArtistRow` in [`browse.ts`](../../src/lib/tauri/browse.ts)) stitch the absolute paths back together so every UI consumer keeps the full `Track` / `AlbumRow` / `ArtistRow` shape unchanged. Cuts ~30 % off each payload (e.g. ≈ 1.0 MB → ≈ 700 kB on a 1k-track `list_tracks`, ≈ 650 kB → ≈ 250 kB on a 900-artist `list_artists`), proportionally shrinking JSON parse + IPC transfer time. Any future bulk endpoint shipping artworks for hundreds of rows should adopt the same `{ artwork_base, items }` shape.
- **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly.
- **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport.
- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button.
- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second.
- **POPM ratings** — 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline `StarRating` in the library track list, integer-star submenu in the right-click `TrackContextMenu` (any view), full half-star widget in the `TrackPropertiesModal`. The backend command `set_track_rating` writes the POPM frame back to the file (binary `<email>\0<rating><counter>` for ID3v2, text `RATING=0-100` for Vorbis / MP4 / APE), updates `track.rating` in the DB, then emits `track:updated` so every open view re-fetches without polling. The write goes through `edit::patch_file` like every other tag edit (#644), so a `.dsf` gets its POPM frame through the DSF writer rather than stopping at the database. **ID3v2 ratings are written as the POPM frame itself**, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag — it arrives as `<provider>|<stars>|<counter>` through a whole-star `StarRating` rather than as the raw frame body its own documentation still describes — so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. `.dff` is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file — the DSD extractor lifts title / artist / album / year and no more — so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the `rating_min` rule — see [smart-playlists.md](smart-playlists.md#custom-smart-playlists-recursive-boolean-rule-tree).
- **Lightbox** — double-click any cover or artist photo to view full-size with keyboard navigation.

Expand Down
6 changes: 3 additions & 3 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ Real-time FFT curves surfaced in the immersive Now Playing overlay. Implementati

- Backend: [`audio/spectrum.rs`](../../src-tauri/crates/app/src/audio/spectrum.rs) runs on the decoder thread (NOT in the cpal callback — too constrained). Post-EQ samples go through `SpectrumAnalyzer::feed`, which mono-mixes, applies a Hann window, runs a 4096-pt real FFT via `realfft`, then buckets the magnitudes into 48 log-spaced bands (30 Hz → 16 kHz). The window slides by a fixed 1024 samples (75% overlap), so the longer window does not slow the update. Throttled to ~30 Hz via a manual `Instant` clock.
- **Bass resolution (#715).** At 2048 points a bin is 21.5 Hz wide at 44.1 kHz, and the twelve bands between 30 and 144 Hz were fed by six distinct bins: they moved in identical pairs. At 4096 a bin is 10.8 Hz, and a band still narrower than a bin reads the spectrum at its centre frequency, interpolated between the two nearest bins, so no two bands report the same number.
- **Level (#715).** Bands are scaled against a running reference that follows the loudest band — up in ~250 ms, down over ~3 s, with a floor so silence is never amplified into noise — plus 15% headroom. It replaced a fixed reference of 250, which a quietly mastered track never approached. The goal is the right movement, not more of it: no smoothing is added here, the React side keeps its own attack / release, and the reference resets on track change.
- Output is a `player:spectrum` Tauri event carrying a `Vec<f32>` of normalised band magnitudes (0..1, peaks may briefly overshoot).
- **Level (#715).** Bands are scaled against a running reference that follows the loudest band — up in ~250 ms, down over ~3 s, with a floor so silence is never amplified into noise — plus 15% headroom. It replaced a fixed reference of 250, which a quietly mastered track never approached. **Above 0.8 the scale bends instead of clipping** (`soft_limit`, hyperbolic): the reference takes a fraction of a second to catch a sudden hit, and clipping it to 1 put every band of the hit on the same flat plateau at the top — compressed, they approach the top without reaching it and the loudest still stands above its neighbours. Hyperbolic rather than exponential because an exponential is within `f32` rounding of 1 a few times past the reference, which is the very transient it is for. The goal is the right movement, not more of it: no smoothing is added here, the React side keeps its own attack / release, and the reference resets on track change.
- Output is a `player:spectrum` Tauri event carrying a `Vec<f32>` of normalised band magnitudes (0..1, never reaching 1 — see the soft limit above).
- A `SharedPlayback::visualizer_enabled` atomic gates the entire path: when off, `feed` returns at the first atomic load — zero allocations, zero FFT cost. Persisted in `profile_setting['ui.visualizer']`, default OFF.
- Frontend: [`SpectrumVisualizer`](../../src/components/player/SpectrumVisualizer.tsx) subscribes to the event and drives a `<canvas>` with `requestAnimationFrame`. Asymmetric decay (jump up fast, fall slow) so transients pop without looking glitchy. Auto-fades to zero on pause so the drawing doesn't freeze mid-pose — a curve settles onto a flat line.
- Drawing style (issue #699): per profile via [`useVisualizerStyle`](../../src/hooks/useVisualizerStyle.ts) — `Wave` (**default**) is one Catmull-Rom curve through the 48 band tops, emitted as cubic Béziers and filled underneath with a gradient that fades out; `Mirrored` reflects it around a centre line as a single closed shape, so it reads as a waveform rather than a histogram; `Bars` keeps the original rectangles, with rounded caps. Stored in `profile_setting['ui.visualizer_style']` and cycled from a [`VisualizerStyleButton`](../../src/components/player/VisualizerStyleButton.tsx) beside the colour one, under the same "only when the visualizer is on, only once the stored value has loaded" gate. **The default changes the look for existing installs**, unlike the colour choice below: the complaint in #699 is the bars themselves, so defaulting to them would ship the fix switched off — and they are one click away on the same button. The analysis is untouched; only the drawing changed.
- Drawing style (issue #699): per profile via [`useVisualizerStyle`](../../src/hooks/useVisualizerStyle.ts) — `Wave` (**default**) is one Catmull-Rom curve through the 48 band tops, emitted as cubic Béziers and filled underneath with a gradient that fades out — its control points are held inside the drawing band, because Catmull-Rom overshoots between two tall bands and the canvas edge used to cut the curve flat there (a Bézier segment never leaves the hull of its control points); `Mirrored` reflects it around a centre line as a single closed shape, so it reads as a waveform rather than a histogram; `Bars` keeps the original rectangles, with rounded caps. Stored in `profile_setting['ui.visualizer_style']` and cycled from a [`VisualizerStyleButton`](../../src/components/player/VisualizerStyleButton.tsx) beside the colour one, under the same "only when the visualizer is on, only once the stored value has loaded" gate. **The default changes the look for existing installs**, unlike the colour choice below: the complaint in #699 is the bars themselves, so defaulting to them would ship the fix switched off — and they are one click away on the same button. The analysis is untouched; only the drawing changed.
- Render cost: it runs at display refresh rate for as long as the immersive view is open, so **the frame loop allocates nothing**. A canvas gradient is bound to the coordinates it was built with, so it is rebuilt on resize / colour change and cached otherwise; the curve is emitted by index straight into one path rather than through an array of points; and the mirror's lower edge flips a sign the helpers read instead of taking a fresh closure sixty times a second. `prefers-reduced-motion` damps both halves of the smoothing rather than animating harder, and in [high contrast](ui.md#high-contrast) the stroke doubles and the fade-to-transparent fill becomes a flat wash — a fill that fades out is exactly the low-contrast edge that mode exists to remove. The contrast state is read off the `data-contrast` attribute the rest of the window is painted from, not through the profile-setting hook: a decorative canvas has no business holding one open.
- Bar colour (issue #468): user-selectable per profile via [`useVisualizerColor`](../../src/hooks/useVisualizerColor.ts) — `White` (default, the historical `rgba(255,255,255,0.85)` so existing installs are unchanged) → `Emerald` → `Orange` → `Aqua` → `Magenta` → `Rainbow` (per-bar 0–300° hue sweep), stored in `profile_setting['ui.visualizer_color']`. A [`VisualizerColorButton`](../../src/components/player/VisualizerColorButton.tsx) next to the like/★ in [`ImmersiveNowPlaying`](../../src/components/player/ImmersiveNowPlaying.tsx) cycles through them (loops back to `White`); it only appears when the visualizer toggle is on. Rationale: the immersive backdrop is derived from album art, so no single fixed colour reads well over every cover — the user picks one that contrasts.

Expand Down
53 changes: 51 additions & 2 deletions src-tauri/crates/app/src/audio/spectrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ fn band_magnitudes(spectrum: &[Complex<f32>], sample_rate: f32, bands: &mut [f32
/// against the track's own recent level (with [`HEADROOM`] above it)
/// makes the peaks of the music reach the top whatever the master.
///
/// Above [`KNEE`] the scale bends instead of stopping: a transient
/// louder than the reference — the reference takes a fraction of a
/// second to catch up with it — used to be clipped to 1, so every band
/// of the hit landed on the same flat plateau at the top of the display.
/// Compressed, they approach the top without reaching it, and the loudest
/// of them still stands above its neighbours.
///
/// A small floor then treats the bottom as silence, so quantisation and
/// decoder rounding show as zero rather than a constant haze, and a
/// `sqrt` curve expands the low end, where the ear is most sensitive to
Expand All @@ -313,12 +320,33 @@ fn scale_bands(raw: &[f32], level: f32, bands: &mut [f32]) {
const FLOOR: f32 = 0.02;
let reference = level.max(MIN_LEVEL) * HEADROOM;
for (band, &mag) in bands.iter_mut().zip(raw) {
let normalised = (mag / reference).clamp(0.0, 1.0);
let normalised = soft_limit((mag / reference).max(0.0));
let cut = (normalised - FLOOR).max(0.0) / (1.0 - FLOOR);
*band = cut.sqrt();
}
}

/// Where the scale starts to bend. Below it a band is linear in its
/// magnitude, as before.
const KNEE: f32 = 0.8;

/// Identity up to [`KNEE`], then a hyperbolic approach to 1: continuous
/// and with the same slope at the knee, so nothing jumps where the
/// compression begins, and never reaching 1 however loud the band is.
///
/// Hyperbolic rather than exponential: an exponential is within `f32`
/// rounding of 1 by a few times the reference, which is exactly the
/// transient this is for, and two bands would land on the same value
/// again.
fn soft_limit(x: f32) -> f32 {
if x <= KNEE {
return x;
}
let room = 1.0 - KNEE;
let over = (x - KNEE) / room;
KNEE + room * over / (1.0 + over)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -389,11 +417,32 @@ mod tests {
#[test]
fn a_single_beat_does_not_reset_the_scale() {
// Rising takes a fraction of a second: one loud frame moves the
// reference only part of the way, so the beat itself pegs.
// reference only part of the way, so the beat itself reaches the
// top of the scale.
let level = follow_level(100.0, 1000.0, EMIT_INTERVAL);
assert!(level < 300.0, "got {level}");
}

/// A hit several times louder than the reference used to clip every
/// band of it to 1 — a flat plateau at the top of the display. The
/// louder band now still stands above the other, and neither reaches
/// the top.
#[test]
fn bands_louder_than_the_reference_stay_apart() {
let mut bands = [0.0; 2];
scale_bands(&[200.0, 400.0], MIN_LEVEL, &mut bands);
assert!(bands[0] < bands[1], "got {bands:?}");
assert!(bands[1] < 1.0, "got {bands:?}");
assert!(bands[0] > 0.9, "got {bands:?}");
}

#[test]
fn the_soft_limit_is_continuous_at_the_knee() {
assert_eq!(soft_limit(KNEE), KNEE);
assert!((soft_limit(KNEE + 1e-3) - (KNEE + 1e-3)).abs() < 1e-5);
assert_eq!(soft_limit(0.3), 0.3);
}

#[test]
fn analyzer_starts_idle_and_resets_clean() {
let mut a = SpectrumAnalyzer::new();
Expand Down
Loading
Loading