mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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:
+32
-7
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user