mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user