From dcec30166a19c2106ba592d97cfdd2355abda395 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 3 May 2026 13:55:56 +0200 Subject: [PATCH] fix(audio): frame-align gapless-off track-separation silence (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(audio): frame-align gapless-off track-separation silence The 500 ms silence prepended between tracks when gapless playback is disabled and the previous track ended naturally was built with `Zero::new(ch, sr).take_duration(500ms)`. Rodio's `TakeDuration` computes its sample count via integer-nanosecond division (`1_000_000_000 / (sr * ch)`), which truncates: at 44.1 kHz / 2 ch this emits 44103 samples = 22051.5 frames, half a frame short. That half-frame leak shifts the next source's L/R parity in the device frame stream. Multiple users have reported the next track playing only on the right channel — exactly when gapless is OFF and the previous track ended naturally (manual skip and album-first-play bypass the silence prepend, which matches the reproducer report). Replace with `SamplesBuffer::new(ch, sr, vec![0; frames * ch])` where `frames = sr / 2`. Frame-aligned by construction, same audible effect. * docs(changelog): add #439 mono-channel fix entry * chore(credits): add #439 to Psychotoxical contributions * docs(changelog): strip @ from non-contributor mention in #435 entry Plain-text 'zunoz on Discord' instead of '@zunoz' so GitHub does not attribute the requester as a contributor on subsequent merges. --- CHANGELOG.md | 4 +++- src-tauri/src/audio/commands.rs | 18 ++++++++++++++---- src/pages/Settings.tsx | 1 + 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cabcac..5f21a2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,7 +137,7 @@ The currently playing row in the queue list is now indicated by **animated equal Shortcut, keyboard, global-hotkey, mini-window and CLI inputs are now all routed through one TypeScript action registry — a single source of truth for what an action does, how it's labelled, and which input transports can fire it. CLI `--player help` is generated dynamically from the registry, so command coverage stays in sync with the action set automatically. -Nine new input actions were added (requested by @zunoz on Discord): start search, start advanced search, toggle sidebar, mute, open / toggle equalizer, toggle repeat, open Now Playing, show lyrics, favorite current track. Help is bound to **F1** by default and hidden from the Settings input list; existing users get the F1 binding back-filled into their persisted keybindings on next launch. +Nine new input actions were added (requested by zunoz on Discord): start search, start advanced search, toggle sidebar, mute, open / toggle equalizer, toggle repeat, open Now Playing, show lyrics, favorite current track. Help is bound to **F1** by default and hidden from the Settings input list; existing users get the F1 binding back-filled into their persisted keybindings on next launch. Translations for the new action labels follow in a separate i18n nachhol-PR (de, fr, nl, zh, nb, ru, es). @@ -154,6 +154,8 @@ Translations for the new action labels follow in a separate i18n nachhol-PR (de, - **Linux dev — sidebar and main content invisible after HMR** *(PR [#434](https://github.com/Psychotoxical/psysonic/pull/434), by [@Psychotoxical](https://github.com/Psychotoxical))*: The `data-app-blurred="true"` CSS rule introduced in #426 used a `*` selector to pause every animation while the window was unfocused. On WebKitGTK + no-compositing this triggered a stale rendering bug after Vite hot-reloads — the sidebar and main content stayed unpainted until any user interaction nudged a re-render. The rule now targets only the concrete heaviest infinite animations (eq bars, marquees, now-playing dot pulse, fullscreen mesh blob / portrait, `.spin`); release builds are unchanged in behaviour. +- **Mono playback (right channel only) after natural track end with gapless OFF** *(PR [#439](https://github.com/Psychotoxical/psysonic/pull/439), by [@Psychotoxical](https://github.com/Psychotoxical))*: When gapless playback was disabled and a track ended naturally, the next track could play only on the right channel for the rest of its duration. The 500 ms track-separation silence prepended in this exact transition was built with `Zero + take_duration`, whose integer-nanosecond math at 44.1 kHz / 2 ch leaks half a frame (44103 samples instead of 44100), shifting the next source's L/R parity in the device frame stream. Replaced with a frame-aligned `SamplesBuffer`. Manual skip and album-first-play were unaffected because they bypass the silence prepend. Independently identified by xrexy on Discord while the diagnosis was landing here. + ## [1.44.0] - 2026-04-29 diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs index 94475fbb..538c14e8 100644 --- a/src-tauri/src/audio/commands.rs +++ b/src-tauri/src/audio/commands.rs @@ -614,6 +614,14 @@ pub async fn audio_play( // Gapless OFF: prepend a short silence so tracks are clearly separated. // Only when this is an auto-advance (near end), not on manual skip. + // + // Use a frame-aligned `SamplesBuffer` rather than `Zero + take_duration` — + // the latter computes its sample count via integer-nanosecond division + // (1_000_000_000 / (sr * ch)), which at common rates leaks an odd number + // of samples (e.g. 44103 at 44.1 kHz / 2 ch / 500 ms = 22051.5 frames). + // The half-frame leak shifts the next source's L/R parity in the device + // stream and can manifest as a dead channel for the rest of the track + // (reported by users for natural-end-without-gapless transitions only). if !gapless { let cur_pos = { let cur = state.current.lock().unwrap(); @@ -625,10 +633,12 @@ pub async fn audio_play( }; let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0; if is_auto_advance { - let silence = rodio::source::Zero::::new( - source.channels(), - source.sample_rate(), - ).take_duration(Duration::from_millis(500)); + let ch = source.channels(); + let sr = source.sample_rate(); + // 500 ms in whole frames, then expand to interleaved samples. + let frames = (sr / 2) as usize; + let total_samples = frames.saturating_mul(ch as usize); + let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]); sink.append(silence); } } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d53089c4..fab3d17a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -348,6 +348,7 @@ const CONTRIBUTORS = [ 'Preview: audio start sync, ring animation, download timeout (PR #423)', 'Statistics: shareable Top-Albums card export (PR #425)', 'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)', + 'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)', ], }, ] as const;