From d7ff1d3113508bcbf3b7e1f5c87ca98ca1c59045 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 7 May 2026 19:10:18 +0200 Subject: [PATCH] fix(preview): keep preview sink volume in sync with player slider (#498) (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(preview): keep preview sink volume in sync with player slider (#498) The Rust preview sink had its volume set once at audio_preview_play and then never updated. audio_set_volume only ramps the main sink, so slider movements during a preview had zero effect on the preview level. With the default loudness normalization (-4.5 dB pre-analysis attenuation) applied at start, even a 100% slider gives 1.0 × 0.596 × MASTER_HEADROOM ≈ 53% — matching the user-visible "fixed at around 50%" symptom. - Add audio_preview_set_volume Rust command that updates the preview sink if one is active (clamp + master headroom mirror the path used in audio_preview_play). - Extract the preview-volume calculation in previewStore into computePreviewVolume() so startPreview and the new sync path share one formula (slider value, plus the LUFS pre-analysis attenuation the engine already applies to the main sink). - Subscribe to playerStore at module level: when volume changes and a preview is active, push the recomputed value to Rust. Auth / normalization tweaks during preview are intentionally not synced — preview is short and that case is rare. Reported by netherguy4. * docs: changelog entry for PR #502 Logs the preview-volume-slider sync fix in v1.46.0 "## Fixed". --- CHANGELOG.md | 8 +++++++ src-tauri/src/audio/preview.rs | 12 ++++++++++ src-tauri/src/lib.rs | 1 + src/store/previewStore.ts | 44 ++++++++++++++++++++++++---------- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7f6881..657cb783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The currently-playing track in any tracklist (**AlbumDetail**, **ArtistDetail**, **PlaylistDetail**, **Favorites**, **RandomMix**) ran an **`opacity` pulse** on the entire row plus three **`transform` keyframe** EQ-bar siblings — both compositor properties, but on **WebKitGTK without compositing** (Linux + NVIDIA proprietary + `WEBKIT_DISABLE_COMPOSITING_MODE=1`) every animated row falls back to a full **software repaint** of the subtree per frame. On AlbumDetail the combined cost held the WebProcess at **~80 % CPU** for the duration of playback; CPU dropped immediately on pause/stop. * `.track-row.active` keeps the **accent-tinted background** but no longer pulses. The "now playing" indicator becomes a single Lucide **`AudioLines` icon** (one SVG per active row instead of three animated spans). Cleanup: dead `track-pulse` + `eq-bounce` keyframes and a duplicate, shadowed `.eq-bar` block in `theme.css`. +### Track preview — volume slider ignored during preview + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by netherguy4, PR [#502](https://github.com/Psychotoxical/psysonic/pull/502)** + +* The Rust **preview sink** had its volume set **once at preview start** and was never updated afterwards — `audio_set_volume` only ramps the **main sink**. Slider drags during preview therefore had no audible effect on the preview level. +* With **loudness normalization** on (default `-4.5 dB` pre-analysis attenuation), even a 100 % slider produced `1.0 × 0.596 × 0.891 ≈ 53 %` at the speaker, matching the reporter's "fixed at around 50 %" observation. +* New `audio_preview_set_volume` command and a `playerStore` subscription in the frontend keep the preview sink in lock-step with the slider while a preview is in flight (settings tweaks during preview are intentionally not synced — preview windows are short). + ### Tray — broken navigation after restoring via desktop / start-menu shortcut **By [@Psychotoxical](https://github.com/Psychotoxical), reported by netherguy4, PR [#501](https://github.com/Psychotoxical/psysonic/pull/501)** diff --git a/src-tauri/src/audio/preview.rs b/src-tauri/src/audio/preview.rs index 85ebb23b..82ea29b9 100644 --- a/src-tauri/src/audio/preview.rs +++ b/src-tauri/src/audio/preview.rs @@ -286,6 +286,18 @@ pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) preview_stop_inner(&app, &state, false); } +/// Update the preview sink volume while a preview is in flight. Mirrors +/// `audio_set_volume` for the main sink. The frontend already folds in any +/// LUFS pre-analysis attenuation before calling, just like it does at preview +/// start, so the engine just clamps and applies the master headroom. No-op +/// when no preview is active. +#[tauri::command] +pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) { + if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() { + sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); + } +} + pub(crate) fn preview_stop_inner(app: &AppHandle, state: &AudioEngine, resume_main: bool) { state.preview_gen.fetch_add(1, Ordering::SeqCst); let sink = state.preview_sink.lock().unwrap().take(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 115d6646..3c08e50e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -915,6 +915,7 @@ pub fn run() { audio::preview::audio_preview_play, audio::preview::audio_preview_stop, audio::preview::audio_preview_stop_silent, + audio::preview::audio_preview_set_volume, audio::commands::audio_set_crossfade, audio::commands::audio_set_gapless, audio::commands::audio_set_normalization, diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts index 076c0035..42a5c174 100644 --- a/src/store/previewStore.ts +++ b/src/store/previewStore.ts @@ -44,6 +44,25 @@ interface PreviewState { const PREVIEW_VOLUME_MATCH = true; +/** + * Effective preview volume to send to the Rust engine. + * + * Mirrors the main sink's audible level: takes the player's slider value and, + * when loudness normalization is active, folds in the LUFS pre-analysis + * attenuation the engine applies to the main sink (the engine has no view of + * preview-specific gain, so we pre-multiply here). Master headroom is added on + * the Rust side. + */ +function computePreviewVolume(): number { + const auth = useAuthStore.getState(); + let volume = usePlayerStore.getState().volume; + if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') { + const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5); + volume = volume * Math.pow(10, preDbAtt / 20); + } + return Math.max(0, Math.min(1, volume)); +} + export const usePreviewStore = create((set, get) => ({ previewingId: null, previewingTrack: null, @@ -70,18 +89,6 @@ export const usePreviewStore = create((set, get) => ({ ? trackDuration * startRatio : 0; - // Match the main player's effective volume so preview doesn't blast at - // unattenuated level. LUFS pre-analysis attenuation is folded into base - // volume by the audio engine for the main sink; we mirror by reading the - // player volume + applying the same headroom multiplier conceptually. - let volume = usePlayerStore.getState().volume; - if (PREVIEW_VOLUME_MATCH) { - if (auth.normalizationEngine === 'loudness') { - const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5); - volume = volume * Math.pow(10, preDbAtt / 20); - } - } - set({ previewingId: song.id, previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt }, @@ -96,7 +103,7 @@ export const usePreviewStore = create((set, get) => ({ url, startSec, durationSec: previewDuration, - volume: Math.max(0, Math.min(1, volume)), + volume: computePreviewVolume(), }); } catch (e) { // Roll back optimistic state on failure. @@ -139,3 +146,14 @@ export const usePreviewStore = create((set, get) => ({ set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); }, })); + +// Keep the preview sink in sync with player volume slider movements while a +// preview is in flight. Without this the Rust preview Sink stays at whatever +// level was set at `audio_preview_play` — slider drags only ramp the main +// sink. Auth/normalization changes during preview are intentionally ignored +// (preview is short and the case is rare). +usePlayerStore.subscribe((state, prev) => { + if (state.volume === prev.volume) return; + if (!usePreviewStore.getState().previewingId) return; + invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {}); +});