fix(preview): keep preview sink volume in sync with player slider (#498) (#502)

* 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".
This commit is contained in:
Frank Stellmacher
2026-05-07 19:10:18 +02:00
committed by GitHub
parent 3cabb64dbc
commit d7ff1d3113
4 changed files with 52 additions and 13 deletions
+8
View File
@@ -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)**
+12
View File
@@ -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();
+1
View File
@@ -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,
+31 -13
View File
@@ -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<PreviewState>((set, get) => ({
previewingId: null,
previewingTrack: null,
@@ -70,18 +89,6 @@ export const usePreviewStore = create<PreviewState>((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<PreviewState>((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<PreviewState>((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(() => {});
});