fix(loudness): target sync, effective pre-analysis trim, and queue/settings copy (#333)

* fix(loudness): target sync, -14 pre-analysis ref, queue UI, and reseed

- Front: coalesce loudness refresh by target LUFS; replay-gain IPC dedupe keys
  include norm target and effective pre-attenuation so TGT changes apply.
- Rust: placeholder gain before integrated LUFS uses pivot at -14 LUFS; UI gain
  from effective trim; reseed loudness after delete when waveform cache would skip.
- Pre-analysis: store attenuation relative to -14 LUFS; engine and UI use an
  offset for other targets; migrate legacy absolute values on rehydrate.
- Queue/Settings: Loudness/TGT labels vs value buttons; styles; i18n for help.

* fix(i18n): simplify loudness pre-analysis helper copy

Remove reference-target wording from loudness pre-analysis helper text and keep
only the effective adjustment shown for the current LUFS target in all locales.
This commit is contained in:
cucadmuh
2026-04-27 02:54:58 +03:00
committed by GitHub
parent cf09fd4bd3
commit 87373edb17
18 changed files with 299 additions and 69 deletions
+37 -5
View File
@@ -238,6 +238,31 @@ impl AnalysisCache {
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
}
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let exists: i64 = conn
.query_row(
r#"
SELECT EXISTS (
SELECT 1
FROM loudness_cache l
JOIN analysis_track a
ON a.track_id = l.track_id
AND a.md5_16kb = l.md5_16kb
WHERE l.track_id = ?1
AND l.md5_16kb = ?2
AND a.loudness_algo_version = ?3
)
"#,
params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(exists != 0)
}
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
@@ -361,14 +386,21 @@ pub fn seed_from_bytes_execute(
};
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
if cache.loudness_row_exists_for_key(&key)? {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
key.md5_16kb
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
}
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
+43 -22
View File
@@ -2801,25 +2801,43 @@ fn resolve_loudness_gain_from_cache_impl(
}
}
/// Typical integrated LUFS (streaming pivot) when SQLite has no row yet — so target changes
/// still move gain before real analysis completes.
const LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS: f64 = -14.0;
#[inline]
fn loudness_gain_placeholder_until_cache(target_lufs: f32, pre_analysis_attenuation_db: f32) -> f32 {
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
// `true_peak = 0.0` skips the headroom cap until integrated measurement exists.
let pivot = crate::analysis_cache::recommended_gain_for_target(
LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS,
0.0,
f64::from(target_lufs),
) as f32;
(pivot + pre).clamp(-24.0, 24.0)
}
/// LUFS gain after a single `resolve_loudness_gain_from_cache` result (`None` = miss).
/// Keeps `audio_update_replay_gain` / `audio_play` from resolving twice on the same URL.
/// Until a cache row exists, follow current target (see [`loudness_gain_placeholder_until_cache`]).
fn loudness_gain_db_after_resolve(
resolved_from_cache: Option<f32>,
target_lufs: f32,
pre_analysis_attenuation_db: f32,
allow_js_when_uncached: bool,
js_gain_db: Option<f32>,
) -> Option<f32> {
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
let uncached = loudness_gain_placeholder_until_cache(target_lufs, pre_analysis_attenuation_db);
match resolved_from_cache {
Some(g) => Some(g),
None => {
if allow_js_when_uncached {
match js_gain_db {
Some(r) if r.is_finite() => Some(r),
_ => Some(pre),
_ => Some(uncached),
}
} else {
Some(pre)
Some(uncached)
}
}
}
@@ -2846,6 +2864,7 @@ fn loudness_gain_db_or_startup(
);
loudness_gain_db_after_resolve(
resolved,
target_lufs,
pre_analysis_attenuation_db,
allow_js_when_uncached,
js_gain_db,
@@ -3047,19 +3066,10 @@ fn gain_linear_to_db(gain_linear: f32) -> Option<f32> {
}
}
/// `audio:normalization-state` “Now dB” for the UI: omit a number while loudness
/// mode is still on the **startup safety trim** only (no cache row / no explicit
/// requested gain from analysis), so users do not read `-6 dB` as measured LUFS.
fn loudness_ui_current_gain_db(
norm_mode: u32,
resolved_loudness_gain_db: Option<f32>,
gain_linear: f32,
) -> Option<f32> {
if norm_mode == 2 && resolved_loudness_gain_db.is_none() {
None
} else {
gain_linear_to_db(gain_linear)
}
/// `audio:normalization-state` “Now dB” for the UI: effective applied gain, including
/// loudness pre-analysis trim from settings when no cache row exists yet (matches audible level).
fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
gain_linear_to_db(gain_linear)
}
fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
@@ -3476,7 +3486,13 @@ pub async fn audio_play(
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
let startup_loudness_gain_db = if norm_mode == 2 {
loudness_gain_db_after_resolve(cache_loudness, pre_analysis_db, false, loudness_gain_db)
loudness_gain_db_after_resolve(
cache_loudness,
target_lufs,
pre_analysis_db,
false,
loudness_gain_db,
)
} else {
cache_loudness
};
@@ -3489,7 +3505,7 @@ pub async fn audio_play(
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
crate::app_deprintln!(
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
playback_identity(&url),
@@ -4393,8 +4409,7 @@ pub fn audio_update_replay_gain(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
// so `loudness_ui_current_gain_db` can show a number (otherwise `and_then`
// drops the requested gain entirely).
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
resolve_loudness_gain_from_cache_impl(
&app,
@@ -4412,11 +4427,17 @@ pub fn audio_update_replay_gain(
match url_for_loudness.as_deref() {
Some(_u) => loudness_gain_db_after_resolve(
cache_loudness,
target_lufs,
pre_analysis_db,
true,
loudness_gain_db,
),
None => loudness_gain_db.or(Some(pre_analysis_db)),
None => {
loudness_gain_db.or(Some(loudness_gain_placeholder_until_cache(
target_lufs,
pre_analysis_db,
)))
}
}
} else {
loudness_gain_db
@@ -4430,7 +4451,7 @@ pub fn audio_update_replay_gain(
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
crate::app_deprintln!(
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
normalization_engine_name(norm_mode),
+20 -14
View File
@@ -1724,27 +1724,33 @@ fn analysis_delete_all_waveforms(
fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
force: Option<bool>,
app: tauri::AppHandle,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
if let Some(engine) = app.try_state::<crate::audio::AudioEngine>() {
if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
let force = force.unwrap_or(false);
if !force {
if let Some(engine) = app.try_state::<crate::audio::AudioEngine>() {
if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
}
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
}
let tid_log = track_id.clone();