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
+22 -5
View File
@@ -29,6 +29,8 @@ import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -343,6 +345,7 @@ function QueuePanelHostOrSolo() {
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
useEffect(() => {
if (!showCrossfadePopover) return;
@@ -627,9 +630,23 @@ function QueuePanelHostOrSolo() {
const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · ');
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '';
const liveGainLabel = (() => {
if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
}
if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
loudnessPreAnalysisAttenuationDb,
authLoudnessTargetLufs,
);
const ph = loudnessGainPlaceholderUntilCacheDb(
authLoudnessTargetLufs,
preEff,
);
return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
}
return '—';
})();
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
const targetLabel = `${tgtNum} LUFS`;
if (!baseLine && !rgLine && !playbackSource) return null;
@@ -696,13 +713,14 @@ function QueuePanelHostOrSolo() {
{' · '}
<button
type="button"
className="queue-current-tech-metric"
className="queue-current-tech-metric queue-current-tech-metric--lufs-reanalyze"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(false);
void reanalyzeLoudnessForTrack(currentTrack.id);
}}
data-tooltip="Clear cached loudness and re-analyze this track"
aria-label="Clear cached loudness and re-analyze this track"
>
{liveGainLabel}
</button>
@@ -749,7 +767,6 @@ function QueuePanelHostOrSolo() {
e.stopPropagation();
if (v !== authLoudnessTargetLufs) {
setLoudnessTargetLufs(v);
void reanalyzeLoudnessForTrack(currentTrack.id);
}
setLufsTgtOpen(false);
}}
+2 -1
View File
@@ -858,7 +858,8 @@ export const deTranslation = {
loudnessTargetLufsDesc: 'Referenz-Lautstärke, an die alle Titel angepasst werden. Niedrigere Werte = lautere Ausgabe. Streaming-Dienste liegen meist bei etwa -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung',
loudnessPreAnalysisAttenuationDesc:
'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.',
'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser. Rechts steht der wirksame dB-Wert für die gewählte Ziel-LUFS.',
loudnessPreAnalysisAttenuationRef: 'Wirksam {{eff}} dB bei Ziel {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Standard',
loudnessFirstPlayNote:
'Beim ersten Abspielen eines neuen Titels kann die Lautstärke kurz schwanken, während gemessen wird. Beim nächsten Mal greift die gespeicherte Messung und alles bleibt stabil. Kommende Queue-Titel werden meist schon während des vorherigen Songs analysiert — in der Praxis tritt das daher selten auf.',
+2 -1
View File
@@ -864,7 +864,8 @@ export const enTranslation = {
loudnessTargetLufsDesc: 'Reference loudness all tracks are matched to. Lower numbers = louder output. Streaming services typically sit around -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Pre-analysis attenuation',
loudnessPreAnalysisAttenuationDesc:
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.',
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter. The number on the right is the effective dB for your current target.',
loudnessPreAnalysisAttenuationRef: 'Effective {{eff}} dB with target {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Reset to default',
loudnessFirstPlayNote:
'First play of a brand-new track may briefly drift in volume while it is being measured. The next play uses the cached measurement and is rock-solid. Upcoming queue tracks are usually pre-analysed during the previous song, so this rarely happens in practice.',
+2 -1
View File
@@ -851,7 +851,8 @@ export const esTranslation = {
loudnessTargetLufsDesc: 'Volumen de referencia al que se ajustan todas las pistas. Valor más bajo = salida más fuerte. Los servicios de streaming suelen rondar los -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir',
loudnessPreAnalysisAttenuationDesc:
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.',
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo. A la derecha se muestra el dB efectivo para el destino actual.',
loudnessPreAnalysisAttenuationRef: 'Efectivo {{eff}} dB con destino {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
loudnessFirstPlayNote:
'En la primera reproducción de un tema nuevo el volumen puede oscilar brevemente mientras se mide. La siguiente vez se usa la medición guardada y todo permanece estable. Las pistas próximas en la cola suelen analizarse durante la canción anterior, por lo que en la práctica esto rara vez ocurre.',
+2 -1
View File
@@ -846,7 +846,8 @@ export const frTranslation = {
loudnessTargetLufsDesc: 'Sonie de référence sur laquelle toutes les pistes sont calées. Valeur plus basse = sortie plus forte. Les services de streaming tournent autour de -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure',
loudnessPreAnalysisAttenuationDesc:
'Réduit le volume tant que la loudness du morceau nest pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.',
'Réduit le volume tant que la loudness du morceau nest pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret. À droite, le dB effectif pour la cible actuelle.',
loudnessPreAnalysisAttenuationRef: 'Effectif {{eff}} dB avec cible {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Valeur par défaut',
loudnessFirstPlayNote:
'À la première lecture dun nouveau morceau, le volume peut brièvement varier pendant la mesure. La fois suivante, la mesure mise en cache prend le relais et tout reste stable. Les pistes à venir dans la file sont en général pré-analysées pendant la lecture précédente, ce cas est donc rare en pratique.',
+2 -1
View File
@@ -845,7 +845,8 @@ export const nbTranslation = {
loudnessTargetLufsDesc: 'Referanse-loudness alle spor matches mot. Lavere tall = høyere utgang. Strømmetjenester ligger vanligvis rundt -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Demping før måling',
loudnessPreAnalysisAttenuationDesc:
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.',
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere. Til høyre vises effektiv dB for valgt mål.',
loudnessPreAnalysisAttenuationRef: 'Effektivt {{eff}} dB med mål {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Standard',
loudnessFirstPlayNote:
'Første avspilling av et helt nytt spor kan drive litt i volum mens målingen skjer. Neste gang brukes den lagrede målingen, og alt blir stabilt. Kommende spor i køen blir vanligvis pre-analysert mens det forrige spilles, så dette skjer sjelden i praksis.',
+2 -1
View File
@@ -845,7 +845,8 @@ export const nlTranslation = {
loudnessTargetLufsDesc: 'Referentie-luidheid waarop alle nummers worden afgestemd. Lagere waarde = luidere uitvoer. Streamingdiensten zitten meestal rond -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Demping vóór meting',
loudnessPreAnalysisAttenuationDesc:
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.',
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter. Rechts staat de effectieve dB voor het huidige doel.',
loudnessPreAnalysisAttenuationRef: 'Effectief {{eff}} dB bij doel {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Standaard',
loudnessFirstPlayNote:
'Bij de eerste keer afspelen van een nieuw nummer kan het volume even fluctueren terwijl er gemeten wordt. De volgende keer gebruikt de speler de opgeslagen meting en blijft alles rotsvast. Komende nummers in de wachtrij worden meestal al tijdens het vorige nummer geanalyseerd, dus dit komt in de praktijk zelden voor.',
+2 -1
View File
@@ -897,7 +897,8 @@ export const ruTranslation = {
loudnessTargetLufsDesc: 'Опорная громкость, к которой подгоняются все треки. Меньше число = громче на выходе. Стриминг-сервисы обычно держат около -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Ослабление до измерения',
loudnessPreAnalysisAttenuationDesc:
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.',
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише. Справа показывается фактическая поправка для текущей цели.',
loudnessPreAnalysisAttenuationRef: 'Фактически {{eff}} dB при цели {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'По умолчанию',
loudnessFirstPlayNote:
'При первом прослушивании нового трека громкость может ненадолго колебаться, пока идёт измерение. Со второго раза применяется уже сохранённое значение и всё стабильно. Следующие треки в очереди обычно анализируются во время предыдущей композиции, поэтому на практике это случается редко.',
+2 -1
View File
@@ -840,7 +840,8 @@ export const zhTranslation = {
loudnessTargetLufsDesc: '所有曲目对齐的参考响度。数值越小,输出越响。流媒体服务通常在 -14 LUFS 左右。',
loudnessPreAnalysisAttenuation: '测量前衰减',
loudnessPreAnalysisAttenuationDesc:
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。',
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。右侧显示当前目标下的等效 dB。',
loudnessPreAnalysisAttenuationRef: '目标 {{tgt}} LUFS 时等效 {{eff}} dB。',
loudnessPreAnalysisAttenuationReset: '恢复默认',
loudnessFirstPlayNote:
'首次播放新曲目时,音量可能在测量过程中略有波动。下一次播放将使用缓存的测量结果,保持稳定。队列中即将播放的曲目通常在上一首播放期间已完成预分析,所以这种情况在实际使用中很少出现。',
+20 -2
View File
@@ -44,6 +44,9 @@ import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import {
effectiveLoudnessPreAnalysisAttenuationDb,
} from '../utils/loudnessPreAnalysisSlider';
import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore';
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
@@ -1570,6 +1573,14 @@ export default function Settings() {
const prefix = `${serverId}:`;
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
}, [hotCacheEntries, serverId]);
const preAnalysisEffectiveDb = useMemo(
() => effectiveLoudnessPreAnalysisAttenuationDb(
auth.loudnessPreAnalysisAttenuationDb,
auth.loudnessTargetLufs,
),
[auth.loudnessPreAnalysisAttenuationDb, auth.loudnessTargetLufs],
);
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
const navigate = useNavigate();
@@ -2417,7 +2428,7 @@ export default function Settings() {
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
/>
<span className="settings-norm-value">
{auth.loudnessPreAnalysisAttenuationDb} dB
{preAnalysisEffectiveDb} dB
</span>
<button
type="button"
@@ -2433,7 +2444,14 @@ export default function Settings() {
<RotateCcw size={15} />
</button>
</div>
<div className="settings-norm-help">{t('settings.loudnessPreAnalysisAttenuationDesc')}</div>
<div className="settings-norm-help">
{t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
{t('settings.loudnessPreAnalysisAttenuationRef', {
ref: auth.loudnessPreAnalysisAttenuationDb,
eff: preAnalysisEffectiveDb,
tgt: auth.loudnessTargetLufs,
})}
</div>
</div>
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
</div>
+32 -7
View File
@@ -9,6 +9,10 @@ import {
} from '../utils/subsonicServerIdentity';
import { usePlayerStore } from './playerStore';
import { IS_LINUX } from '../utils/platform';
import {
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
clampStoredLoudnessPreAnalysisAttenuationRefDb,
} from '../utils/loudnessPreAnalysisSlider';
export interface ServerProfile {
id: string;
@@ -36,11 +40,10 @@ function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): L
: fallback;
}
function clampLoudnessPreAnalysisAttenuationDb(v: unknown): number {
function sanitizeLoudnessPreAnalysisFromStorage(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB;
const stepped = Math.round(n * 2) / 2;
return Math.max(-24, Math.min(0, stepped));
return clampStoredLoudnessPreAnalysisAttenuationRefDb(n);
}
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
@@ -73,8 +76,13 @@ interface AuthState {
replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: LoudnessLufsPreset;
/** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */
/**
* dB extra quieting until loudness is saved, **calibrated for 14 LUFS** target; engine applies
* `+ (loudnessTargetLufs - (14))` for other targets. See `effectiveLoudnessPreAnalysisAttenuationDb`.
*/
loudnessPreAnalysisAttenuationDb: number;
/** Persisted: stored pre is ref @ 14 (v1+); legacy falsey entries migrate once in onRehydrate. */
loudnessPreIsRefV1?: boolean;
replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
@@ -352,6 +360,7 @@ export const useAuthStore = create<AuthState>()(
normalizationEngine: 'off',
loudnessTargetLufs: -12,
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
loudnessPreIsRefV1: true,
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
@@ -489,7 +498,9 @@ export const useAuthStore = create<AuthState>()(
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessPreAnalysisAttenuationDb: (v) => {
set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) });
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return;
set({ loudnessPreAnalysisAttenuationDb: clampStoredLoudnessPreAnalysisAttenuationRefDb(n) });
},
resetLoudnessPreAnalysisAttenuationDbDefault: () => {
set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB });
@@ -736,9 +747,22 @@ export const useAuthStore = create<AuthState>()(
? { seekbarStyle: 'truewave' as SeekbarStyle }
: {};
const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown };
const st = state as {
loudnessTargetLufs?: unknown;
loudnessPreAnalysisAttenuationDb?: unknown;
loudnessPreIsRefV1?: unknown;
};
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb);
const rawN = st.loudnessPreAnalysisAttenuationDb;
const n = typeof rawN === 'number' ? rawN : Number(rawN);
const preSan =
st.loudnessPreIsRefV1 === true
? sanitizeLoudnessPreAnalysisFromStorage(rawN)
: (Number.isFinite(n)
? clampStoredLoudnessPreAnalysisAttenuationRefDb(
n - (targetSan - LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS),
)
: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB);
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
@@ -749,6 +773,7 @@ export const useAuthStore = create<AuthState>()(
),
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
loudnessPreIsRefV1: true,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
+62 -6
View File
@@ -15,6 +15,8 @@ import { onAnalysisStorageChanged } from './analysisSync';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useOrbitStore } from './orbitStore';
import { estimateLivePosition } from '../api/orbit';
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
export interface Track {
id: string;
@@ -832,8 +834,24 @@ function invokeAudioUpdateReplayGainDeduped(payload: {
preGainDb: number;
fallbackDb: number;
}) {
const auth = useAuthStore.getState();
/** Must vary when LUFS target / pre-trim changes: Rust recomputes in `audio_update_replay_gain` even if JS still sends the same cached dB. */
const preEff =
auth.normalizationEngine === 'loudness'
? effectiveLoudnessPreAnalysisAttenuationDb(
auth.loudnessPreAnalysisAttenuationDb,
auth.loudnessTargetLufs,
)
: auth.loudnessPreAnalysisAttenuationDb;
const normDedupeKey =
auth.normalizationEngine === 'loudness'
? `loudness|tgt=${auth.loudnessTargetLufs}|pre=${preEff.toFixed(2)}`
: auth.normalizationEngine === 'replaygain'
? 'replaygain'
: 'off';
const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3));
const key = [
normDedupeKey,
payload.volume.toFixed(4),
fmt(payload.replayGainDb),
fmt(payload.replayGainPeak),
@@ -910,7 +928,11 @@ async function reseedLoudnessForTrackId(trackId: string) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
const url = buildStreamUrl(trackId);
try {
await invoke('analysis_enqueue_seed_from_url', { trackId, url });
await invoke('analysis_enqueue_seed_from_url', {
trackId,
url,
force: true,
});
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
}
@@ -946,7 +968,8 @@ async function refreshLoudnessForTrack(
): Promise<void> {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}`;
const target = useAuthStore.getState().loudnessTargetLufs;
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
const existing = loudnessRefreshInflight.get(inflightKey);
if (existing) return existing;
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
@@ -959,10 +982,16 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: useAuthStore.getState().loudnessTargetLufs,
targetLufs: requestedTarget,
});
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
return;
}
if (!row || !Number.isFinite(row.recommendedGainDb)) {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
@@ -1568,7 +1597,10 @@ export function initAudioListeners(): () => void {
invokeAudioSetNormalizationDeduped({
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
normCfg.loudnessPreAnalysisAttenuationDb,
normCfg.loudnessTargetLufs,
),
});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
@@ -1605,6 +1637,9 @@ export function initAudioListeners(): () => void {
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
const targetLufsChanged =
state.normalizationEngine === 'loudness'
&& state.loudnessTargetLufs !== prevNormTarget;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
@@ -1624,13 +1659,19 @@ export function initAudioListeners(): () => void {
invokeAudioSetNormalizationDeduped({
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
state.loudnessPreAnalysisAttenuationDb,
state.loudnessTargetLufs,
),
});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
if (targetLufsChanged) {
clearLoudnessCacheStateForTrackId(currentId);
}
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
@@ -3037,10 +3078,25 @@ export const usePlayerStore = create<PlayerState>()(
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
const cachedLoud = cachedLoudnessGainByTrackId[currentTrack.id];
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud : null;
const haveStableLoud = !!stableLoudnessGainByTrackId[currentTrack.id];
const preEffForNorm = effectiveLoudnessPreAnalysisAttenuationDb(
authState.loudnessPreAnalysisAttenuationDb,
authState.loudnessTargetLufs,
);
const preAnalysisPlaceholderDb =
normalization.normalizationEngineLive === 'loudness'
&& cachedLoudDb == null
&& !haveStableLoud
&& Number.isFinite(preEffForNorm)
? loudnessGainPlaceholderUntilCacheDb(
authState.loudnessTargetLufs,
preEffForNorm,
)
: null;
set(prevState => ({
normalizationNowDb:
normalization.normalizationEngineLive === 'loudness'
? (prevState.normalizationNowDb ?? cachedLoudDb)
? (cachedLoudDb ?? preAnalysisPlaceholderDb ?? prevState.normalizationNowDb)
: normalization.normalizationNowDb,
normalizationTargetLufs: normalization.normalizationTargetLufs,
normalizationEngineLive: normalization.normalizationEngineLive,
+11
View File
@@ -2007,6 +2007,17 @@ html[data-platform="windows"] .player-bar.floating {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.queue-current-tech-metric--lufs-reanalyze {
display: inline-flex;
align-items: baseline;
gap: 0;
padding: 2px 4px;
margin: -2px -4px;
max-width: 100%;
min-width: 0;
font-variant-numeric: tabular-nums;
}
.queue-divider {
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
+15
View File
@@ -0,0 +1,15 @@
/**
* Before SQLite has integrated LUFS, match Rust `loudness_gain_placeholder_until_cache`:
* pivot at -14 LUFS, `true_peak = 0` (no EBU headroom cap), then add pre-attenuation from settings.
*/
const PLACEHOLDER_INTEGRATED_LUFS = -14;
export function loudnessGainPlaceholderUntilCacheDb(
targetLufs: number,
preAnalysisAttenuationDb: number,
): number {
const pre = Math.min(0, Math.max(-24, preAnalysisAttenuationDb));
let pivot = targetLufs - PLACEHOLDER_INTEGRATED_LUFS;
pivot = Math.max(-24, Math.min(24, pivot));
return Math.max(-24, Math.min(24, pivot + pre));
}
+21
View File
@@ -0,0 +1,21 @@
/** Pre-analysis level is defined relative to a 14 LUFS target; engine uses an offset for other targets. */
export const LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS = -14 as const;
/**
* dB actually applied by the engine (and placeholder math) for the current target.
* Example: ref 4.5 dB at 14, at 12 2.5 dB.
*/
export function effectiveLoudnessPreAnalysisAttenuationDb(
storedDbRelativeToRef: number,
targetLufs: number,
): number {
const stepped = Math.round(storedDbRelativeToRef * 2) / 2;
const effective = stepped + (targetLufs - LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS);
return Math.max(-24, Math.min(0, effective));
}
/** Stored [24, 0] dB, meaning “at 14 LUFS target”. */
export function clampStoredLoudnessPreAnalysisAttenuationRefDb(v: number): number {
const n = Math.round(v * 2) / 2;
return Math.max(-24, Math.min(0, n));
}