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:
Psychotoxical
2026-04-26 16:58:09 +02:00
committed by Maxim Isaev
parent faa931ff68
commit 2d222e2691
2 changed files with 163 additions and 37 deletions
+67 -9
View File
@@ -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,
});
},
}),
{