mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(loudness): break feedback loop that froze Windows UI when LUFS active
When loudness normalization is on, every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS
(900 ms per active stream task) the backend emits analysis:loudness-partial.
The frontend listener wrote cachedLoudnessGainByTrackId and unconditionally
called updateReplayGainForCurrentTrack, which invoked audio_update_replay_gain,
which in turn emitted audio:normalization-state back to the renderer — closing
a loop the UI had to drain on every tick. WebKitGTK absorbed it; WebView2 did
not, and after a few minutes the renderer thread stalled while Rust analysis
tasks kept progressing (visible in logs).
Five gates close the loop, smallest first:
Frontend (src/store/playerStore.ts):
- analysis:loudness-partial listener now short-circuits when the new gainDb is
within 0.05 dB of the cached value.
- audio_update_replay_gain calls go through invokeAudioUpdateReplayGainDeduped
(250 ms key-based dedupe), mirroring the audio_set_normalization helper from
#320.
- refreshLoudnessForTrack now coalesces concurrent calls per (trackId, mode)
via loudnessRefreshInflight, mirroring waveformRefreshInflight.
Backend (src-tauri/src/audio.rs):
- audio:normalization-state emits go through maybe_emit_normalization_state,
which keeps the last-emitted payload behind a Mutex and skips when engine
matches and current_gain_db (±0.05 dB) / target_lufs (±0.02) are unchanged.
Applied at all three emit sites: audio_play, audio_update_replay_gain,
audio_set_normalization.
- analysis:loudness-partial emits are gated by partial_loudness_should_emit:
per-track-identity last-emitted-gain map; an emit is suppressed when the new
gain is within PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB (0.1 dB) of the previous
one. Applied to both emit_partial_loudness_from_bytes (legacy path) and the
inline ranged_download_task progress emit.
Drive-by: split estimated_output_latency_secs into two #[cfg]-gated
definitions so the sample_rate_hz parameter is no longer flagged as unused on
Windows / macOS builds. Function originally added in c6fc3ec.
Net effect: when loudness is steady (the common case), zero IPC traffic on
this path. UI stays responsive on Windows. Tested on Windows + Linux.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Maxim Isaev
parent
faa931ff68
commit
2d222e2691
@@ -611,6 +611,12 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
/** Coalesce concurrent `analysis_get_waveform_for_track` for one id (StrictMode double mount, init + queue restore). */
|
||||
const waveformRefreshInflight = new Map<string, Promise<void>>();
|
||||
|
||||
/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The
|
||||
* analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in
|
||||
* parallel for every full-track analysis completion; without coalescing, gapless
|
||||
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
||||
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
||||
|
||||
/** Skip redundant `audio_set_normalization` IPC when the same payload is sent twice within a short window (e.g. StrictMode). */
|
||||
let lastNormAudioInvokeKey = '';
|
||||
let lastNormAudioInvokeAtMs = 0;
|
||||
@@ -630,6 +636,42 @@ function invokeAudioSetNormalizationDeduped(payload: {
|
||||
void invoke('audio_set_normalization', payload).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip redundant `audio_update_replay_gain` IPC when the same payload was sent
|
||||
* recently. updateReplayGainForCurrentTrack runs from the analysis:loudness-partial
|
||||
* listener (~every 900 ms while LUFS is on); without dedupe each tick triggers a
|
||||
* full IPC roundtrip + backend audio:normalization-state echo + frontend setState,
|
||||
* which saturates the WebView2 renderer thread on Windows after a few minutes.
|
||||
*/
|
||||
let lastRgInvokeKey = '';
|
||||
let lastRgInvokeAtMs = 0;
|
||||
|
||||
function invokeAudioUpdateReplayGainDeduped(payload: {
|
||||
volume: number;
|
||||
replayGainDb: number | null;
|
||||
replayGainPeak: number | null;
|
||||
loudnessGainDb: number | null;
|
||||
preGainDb: number;
|
||||
fallbackDb: number;
|
||||
}) {
|
||||
const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3));
|
||||
const key = [
|
||||
payload.volume.toFixed(4),
|
||||
fmt(payload.replayGainDb),
|
||||
fmt(payload.replayGainPeak),
|
||||
fmt(payload.loudnessGainDb),
|
||||
payload.preGainDb.toFixed(2),
|
||||
payload.fallbackDb.toFixed(2),
|
||||
].join('|');
|
||||
const now = Date.now();
|
||||
if (key === lastRgInvokeKey && now - lastRgInvokeAtMs < 250) {
|
||||
return;
|
||||
}
|
||||
lastRgInvokeKey = key;
|
||||
lastRgInvokeAtMs = now;
|
||||
invoke('audio_update_replay_gain', payload).catch(console.error);
|
||||
}
|
||||
|
||||
function isReplayGainActive() {
|
||||
const a = useAuthStore.getState();
|
||||
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
|
||||
@@ -731,9 +773,19 @@ async function refreshWaveformForTrack(trackId: string) {
|
||||
async function refreshLoudnessForTrack(
|
||||
trackId: string,
|
||||
opts?: { syncPlayingEngine?: boolean },
|
||||
) {
|
||||
): Promise<void> {
|
||||
if (!trackId) return;
|
||||
const syncEngine = opts?.syncPlayingEngine !== false;
|
||||
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}`;
|
||||
const existing = loudnessRefreshInflight.get(inflightKey);
|
||||
if (existing) return existing;
|
||||
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
|
||||
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
|
||||
loudnessRefreshInflight.set(inflightKey, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
|
||||
emitNormalizationDebug('refresh:start', { trackId });
|
||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
||||
try {
|
||||
@@ -1227,6 +1279,12 @@ export function initAudioListeners(): () => void {
|
||||
if (payloadTrackId && payloadTrackId !== current.id) return;
|
||||
if (!Number.isFinite(payload.gainDb)) return;
|
||||
if (stableLoudnessGainByTrackId[current.id]) return;
|
||||
// Skip when the cached gain is already within ~0.05 dB of the new payload —
|
||||
// float jitter from the partial-loudness heuristic would otherwise re-trigger
|
||||
// updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo
|
||||
// every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed.
|
||||
const existing = cachedLoudnessGainByTrackId[current.id];
|
||||
if (Number.isFinite(existing) && Math.abs(existing - payload.gainDb) < 0.05) return;
|
||||
cachedLoudnessGainByTrackId[current.id] = payload.gainDb;
|
||||
emitNormalizationDebug('partial-loudness:apply', {
|
||||
trackId: current.id,
|
||||
@@ -2620,14 +2678,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
normalizationTargetLufs: normalization.normalizationTargetLufs,
|
||||
normalizationEngineLive: normalization.normalizationEngineLive,
|
||||
}));
|
||||
invoke('audio_update_replay_gain', {
|
||||
volume,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
}).catch(console.error);
|
||||
invokeAudioUpdateReplayGainDeduped({
|
||||
volume,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user