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
+96 -28
View File
@@ -46,6 +46,61 @@ struct NormalizationStatePayload {
target_lufs: f32,
}
/// Last `audio:normalization-state` emit, kept so we can suppress duplicate
/// payloads. The frontend already debounces this event, but on Windows
/// (WebView2) the IPC pipe is the bottleneck — every echo we skip here is
/// renderer-thread time we don't pay.
static LAST_NORM_STATE_EMIT: OnceLock<Mutex<Option<NormalizationStatePayload>>> = OnceLock::new();
fn norm_state_lock() -> &'static Mutex<Option<NormalizationStatePayload>> {
LAST_NORM_STATE_EMIT.get_or_init(|| Mutex::new(None))
}
fn norm_state_changed(prev: &NormalizationStatePayload, next: &NormalizationStatePayload) -> bool {
if prev.engine != next.engine { return true; }
if (prev.target_lufs - next.target_lufs).abs() >= 0.02 { return true; }
match (prev.current_gain_db, next.current_gain_db) {
(None, None) => false,
(Some(a), Some(b)) => (a - b).abs() >= 0.05,
_ => true, // None ↔ Some transition is significant
}
}
fn maybe_emit_normalization_state(app: &AppHandle, payload: NormalizationStatePayload) {
let mut guard = norm_state_lock().lock().unwrap();
let should_emit = match guard.as_ref() {
Some(prev) => norm_state_changed(prev, &payload),
None => true,
};
if !should_emit { return; }
*guard = Some(payload.clone());
drop(guard);
let _ = app.emit("audio:normalization-state", payload);
}
/// Last `analysis:loudness-partial` gain emitted per track-identity, used to
/// suppress emits whose gain hasn't moved meaningfully (≥ 0.1 dB). The partial
/// heuristic in `emit_partial_loudness_from_bytes` and the ranged-progress curve
/// both produce values that drift by hundredths of a dB even on identical input,
/// so the time-based throttle alone is not enough to keep the loop quiet.
static LAST_PARTIAL_LOUDNESS_EMIT: OnceLock<Mutex<std::collections::HashMap<String, f32>>> = OnceLock::new();
const PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB: f32 = 0.1;
fn partial_loudness_should_emit(track_key: &str, gain_db: f32) -> bool {
let mut guard = LAST_PARTIAL_LOUDNESS_EMIT
.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
.lock()
.unwrap();
let prev = guard.get(track_key).copied();
if let Some(p) = prev {
if (p - gain_db).abs() < PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB {
return false;
}
}
guard.insert(track_key.to_string(), gain_db);
true
}
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
@@ -1313,15 +1368,18 @@ async fn ranged_download_task(
if let Some(provisional_db) =
provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db)
{
let _ = app.emit(
"analysis:loudness-partial",
PartialLoudnessPayload {
track_id: playback_identity(&url),
gain_db: provisional_db,
target_lufs,
is_partial: true,
},
);
let track_key = playback_identity(&url).unwrap_or_else(|| url.clone());
if partial_loudness_should_emit(&track_key, provisional_db) {
let _ = app.emit(
"analysis:loudness-partial",
PartialLoudnessPayload {
track_id: playback_identity(&url),
gain_db: provisional_db,
target_lufs,
is_partial: true,
},
);
}
}
}
}
@@ -1420,6 +1478,16 @@ fn emit_partial_loudness_from_bytes(
pre_floor.max(heuristic_floor)
};
let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0);
let track_key = playback_identity(url).unwrap_or_else(|| url.to_string());
if !partial_loudness_should_emit(&track_key, gain_db as f32) {
crate::app_deprintln!(
"[normalization] partial-loudness skip reason=delta-below-threshold gain_db={:.2} threshold_db={:.2} track_id={:?}",
gain_db,
PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
playback_identity(url)
);
return;
}
crate::app_deprintln!(
"[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}",
bytes.len(),
@@ -3324,8 +3392,8 @@ pub async fn audio_play(
volume,
effective_volume
);
let _ = app.emit(
"audio:normalization-state",
maybe_emit_normalization_state(
&app,
NormalizationStatePayload {
engine: normalization_engine_name(norm_mode).to_string(),
current_gain_db,
@@ -3820,20 +3888,19 @@ fn spawn_progress_task(
gapless_switch_at: Arc<AtomicU64>,
current_playback_url: Arc<Mutex<Option<String>>>,
) {
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
// Linux; mirrors the quantum policy used for stream open/reopen plus a small
// scheduler/mixer cushion so the UI doesn't run ahead. Other platforms have
// their own latency reporting paths and don't need the compensation here.
#[cfg(target_os = "linux")]
fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 {
#[cfg(target_os = "linux")]
{
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse
// queue). We mirror the quantum policy used for stream open/reopen.
let rate = sample_rate_hz.max(1.0);
let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 };
// Add a small scheduler/mixer cushion so UI doesn't run ahead.
return (frames / rate) + 0.012;
}
#[cfg(not(target_os = "linux"))]
{
0.0
}
let rate = sample_rate_hz.max(1.0);
let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 };
(frames / rate) + 0.012
}
#[cfg(not(target_os = "linux"))]
fn estimated_output_latency_secs(_sample_rate_hz: f64) -> f64 {
0.0
}
tokio::spawn(async move {
@@ -4273,8 +4340,9 @@ pub fn audio_update_replay_gain(
if let Some(sink) = &cur.sink {
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
let _ = app.emit(
"audio:normalization-state",
drop(cur);
maybe_emit_normalization_state(
&app,
NormalizationStatePayload {
engine: normalization_engine_name(norm_mode).to_string(),
current_gain_db,
@@ -4771,8 +4839,8 @@ pub fn audio_set_normalization(
target,
pre
);
let _ = app.emit(
"audio:normalization-state",
maybe_emit_normalization_state(
&app,
NormalizationStatePayload {
engine: normalization_engine_name(mode).to_string(),
// At mode-switch time the effective track gain may not be recalculated yet.
+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,
});
},
}),
{