fix(audio): frame-align gapless-off track-separation silence (#439)

* 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<f32>::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.
This commit is contained in:
Frank Stellmacher
2026-05-03 13:55:56 +02:00
committed by GitHub
parent 6019a253cd
commit dcec30166a
3 changed files with 18 additions and 5 deletions
+3 -1
View File
@@ -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. 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). 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. - **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 ## [1.44.0] - 2026-04-29
+14 -4
View File
@@ -614,6 +614,14 @@ pub async fn audio_play(
// Gapless OFF: prepend a short silence so tracks are clearly separated. // Gapless OFF: prepend a short silence so tracks are clearly separated.
// Only when this is an auto-advance (near end), not on manual skip. // 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 { if !gapless {
let cur_pos = { let cur_pos = {
let cur = state.current.lock().unwrap(); 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; let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
if is_auto_advance { if is_auto_advance {
let silence = rodio::source::Zero::<f32>::new( let ch = source.channels();
source.channels(), let sr = source.sample_rate();
source.sample_rate(), // 500 ms in whole frames, then expand to interleaved samples.
).take_duration(Duration::from_millis(500)); 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); sink.append(silence);
} }
} }
+1
View File
@@ -348,6 +348,7 @@ const CONTRIBUTORS = [
'Preview: audio start sync, ring animation, download timeout (PR #423)', 'Preview: audio start sync, ring animation, download timeout (PR #423)',
'Statistics: shareable Top-Albums card export (PR #425)', '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)', '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; ] as const;