mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
+96
-28
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user