feat(playback): loudness bind rules, analysis seeding, and normalization UI

Resolve integrated loudness from SQLite only at decode bind; keep pre-trim
until a row exists, then allow provisional gain from live updates. Pass
DB-stable hints from the web app into play and gapless preload.

Default pre-measurement attenuation -4.5 dB with an icon reset in Settings;
drop redundant normalization copy and shorten pre-trim descriptions.

analysis_cache seed_from_bytes returns an outcome and skips redundant
waveform work on cache hits; wire callers and related frontend/backend glue.
This commit is contained in:
Maxim Isaev
2026-04-26 02:29:54 +03:00
parent c6fc3ec844
commit 4b60495e38
16 changed files with 749 additions and 425 deletions
+24 -14
View File
@@ -109,6 +109,24 @@ function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - x, 3);
}
function binsToHeights(src: number[]): Float32Array {
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > eps) return false;
}
return true;
}
function waveformBarThickness(logicalH: number, norm: number): number {
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
return Math.max(1, safeNorm * logicalH);
@@ -865,9 +883,6 @@ export default function WaveformSeek({ trackId }: Props) {
const seek = usePlayerStore(s => s.seek);
const waveformBins = usePlayerStore(s => s.waveformBins);
const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial);
const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec);
const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -882,18 +897,16 @@ export default function WaveformSeek({ trackId }: Props) {
return;
}
if (waveformBins && waveformBins.length > 0) {
const src = waveformBins;
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
const h = binsToHeights(waveformBins);
const prev = heightsRef.current;
if (!prev || prev.length !== BAR_COUNT) {
heightsRef.current = h;
return;
}
if (heightsNearlyEqual(prev, h, 0.02)) {
heightsRef.current = h;
return;
}
const from = new Float32Array(prev);
const to = h;
const startedAt = performance.now();
@@ -946,7 +959,7 @@ export default function WaveformSeek({ trackId }: Props) {
}
// No analysis bins yet: render 500 flat bars immediately.
heightsRef.current = makeFlatWaveHeights();
}, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]);
}, [trackId, waveformBins]);
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
@@ -983,9 +996,6 @@ export default function WaveformSeek({ trackId }: Props) {
seekbarStyle,
trackId,
waveformBins,
waveformIsPartial,
waveformKnownUntilSec,
waveformDurationSec,
duration,
]);
+6
View File
@@ -847,6 +847,12 @@ export const deTranslation = {
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
replayGainFallback: 'Fallback (ohne Tags / Radio)',
normalization: 'Normalisierung',
loudnessTargetLufs: 'Ziel-LUFS',
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)',
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.',
loudnessPreAnalysisAttenuationReset: 'Standard',
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}} s',
+6
View File
@@ -853,6 +853,12 @@ export const enTranslation = {
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (tagged files)',
replayGainFallback: 'Fallback (untagged / radio)',
normalization: 'Normalization',
loudnessTargetLufs: 'Target LUFS',
loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)',
loudnessPreAnalysisAttenuationDesc:
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.',
loudnessPreAnalysisAttenuationReset: 'Reset to default',
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}} s',
+6
View File
@@ -840,6 +840,12 @@ export const esTranslation = {
replayGainAlbum: 'Álbum',
replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)',
replayGainFallback: 'Respaldo (sin etiquetar / radio)',
normalization: 'Normalización',
loudnessTargetLufs: 'LUFS objetivo',
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)',
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.',
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
crossfade: 'Crossfade',
crossfadeDesc: 'Transición entre pistas',
crossfadeSecs: '{{n}} s',
+6
View File
@@ -835,6 +835,12 @@ export const frTranslation = {
replayGainAlbum: 'Album',
replayGainPreGain: 'Pré-Gain (fichiers taggés)',
replayGainFallback: 'Repli (sans tags / radio)',
normalization: 'Normalisation',
loudnessTargetLufs: 'LUFS cible',
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)',
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.',
loudnessPreAnalysisAttenuationReset: 'Valeur par défaut',
crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}} s',
+6
View File
@@ -834,6 +834,12 @@ export const nbTranslation = {
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (taggede filer)',
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
normalization: 'Normalisering',
loudnessTargetLufs: 'Mål-LUFS',
loudnessPreAnalysisAttenuation: 'Demping før måling (dB)',
loudnessPreAnalysisAttenuationDesc:
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.',
loudnessPreAnalysisAttenuationReset: 'Standard',
crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s',
+6
View File
@@ -834,6 +834,12 @@ export const nlTranslation = {
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getagde bestanden)',
replayGainFallback: 'Terugval (zonder tags / radio)',
normalization: 'Normalisatie',
loudnessTargetLufs: 'Doel-LUFS',
loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)',
loudnessPreAnalysisAttenuationDesc:
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.',
loudnessPreAnalysisAttenuationReset: 'Standaard',
crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}} s',
+6
View File
@@ -886,6 +886,12 @@ export const ruTranslation = {
replayGainAlbum: 'По альбому',
replayGainPreGain: 'Предусиление (файлы с тегами)',
replayGainFallback: 'Резерв (без тегов / радио)',
normalization: 'Нормализация',
loudnessTargetLufs: 'Целевой LUFS',
loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)',
loudnessPreAnalysisAttenuationDesc:
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.',
loudnessPreAnalysisAttenuationReset: 'По умолчанию',
crossfade: 'Кроссфейд',
crossfadeDesc: 'Плавный переход между треками',
crossfadeSecs: '{{n}} с',
+6
View File
@@ -829,6 +829,12 @@ export const zhTranslation = {
replayGainAlbum: '专辑',
replayGainPreGain: '预增益(有标签文件)',
replayGainFallback: '回退增益(无标签 / 收音机)',
normalization: '响度归一化',
loudnessTargetLufs: '目标 LUFS',
loudnessPreAnalysisAttenuation: '测量前衰减 (dB)',
loudnessPreAnalysisAttenuationDesc:
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。',
loudnessPreAnalysisAttenuationReset: '恢复默认',
crossfade: '交叉淡入淡出',
crossfadeDesc: '曲目间淡入淡出',
crossfadeSecs: '{{n}} 秒',
+16
View File
@@ -30,6 +30,22 @@ try {
// Ignore in non-Tauri runtimes.
}
// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the
// user's persisted `loggingMode` until then — but waveform/audio may already
// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`).
try {
const raw = localStorage.getItem('psysonic-auth');
if (raw) {
const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } };
const mode = parsed.state?.loggingMode;
if (mode === 'off' || mode === 'normal' || mode === 'debug') {
void invoke('set_logging_mode', { mode });
}
}
} catch {
// Ignore parse / non-Tauri.
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+78 -45
View File
@@ -26,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore';
import {
useAuthStore,
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
ServerProfile,
MIX_MIN_RATING_FILTER_MAX_STARS,
type SeekbarStyle,
type LyricsSourceId,
type LyricsSourceConfig,
type LoggingMode,
type LoudnessLufsPreset,
} from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
@@ -65,6 +75,29 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
function LoudnessLufsButtonGroup(props: {
value: LoudnessLufsPreset;
onSelect: (v: LoudnessLufsPreset) => void;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
{LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
<button
key={v}
type="button"
className={`btn ${props.value === v ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => props.onSelect(v)}
>
{v}
</button>
))}
</div>
);
}
const CONTRIBUTORS = [
{
github: 'jiezhuo',
@@ -1795,9 +1828,6 @@ export default function Settings() {
const deleted = await invoke<number>('analysis_delete_all_waveforms');
usePlayerStore.setState({
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
});
showToast(
t('settings.waveformCacheCleared', { count: deleted }),
@@ -2243,12 +2273,7 @@ export default function Settings() {
<div className="settings-card">
{/* Normalization */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })}
</div>
</div>
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
@@ -2283,43 +2308,51 @@ export default function Settings() {
</button>
</div>
</div>
{auth.normalizationEngine === 'loudness' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.55rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
{t('settings.loudnessTargetLufs')}
</span>
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
{t('settings.loudnessPreAnalysisAttenuation')}
</span>
<input
type="range"
min={-24}
max={0}
step={0.5}
value={auth.loudnessPreAnalysisAttenuationDb}
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 44, textAlign: 'right' }}>
{auth.loudnessPreAnalysisAttenuationDb} dB
</span>
<button
type="button"
className="icon-btn"
style={{ flexShrink: 0 }}
disabled={
auth.loudnessPreAnalysisAttenuationDb === DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
}
onClick={() => auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
>
<RotateCcw size={15} />
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.35, maxWidth: 520 }}>
{t('settings.loudnessPreAnalysisAttenuationDesc')}
</div>
</div>
)}
{auth.normalizationEngine !== 'off' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{auth.normalizationEngine === 'loudness' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span>
<button
className={`btn ${auth.loudnessTargetLufs === -10 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-10)}
>
-10
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -12 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-12)}
>
-12
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-14)}
>
-14
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -16 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-16)}
>
-16
</button>
</div>
</>
)}
{auth.normalizationEngine === 'replaygain' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
+41 -2
View File
@@ -22,6 +22,27 @@ export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented
export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
/** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10];
/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */
export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5;
function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset {
return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number)
? (v as LoudnessLufsPreset)
: fallback;
}
function clampLoudnessPreAnalysisAttenuationDb(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));
}
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
@@ -51,7 +72,9 @@ interface AuthState {
customGenreBlacklist: string[];
replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: -16 | -14 | -12 | -10;
loudnessTargetLufs: LoudnessLufsPreset;
/** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */
loudnessPreAnalysisAttenuationDb: number;
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)
@@ -212,7 +235,9 @@ interface AuthState {
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void;
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
setLoudnessPreAnalysisAttenuationDb: (v: number) => void;
resetLoudnessPreAnalysisAttenuationDbDefault: () => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void;
@@ -326,6 +351,7 @@ export const useAuthStore = create<AuthState>()(
replayGainEnabled: false,
normalizationEngine: 'off',
loudnessTargetLufs: -12,
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
@@ -462,6 +488,13 @@ export const useAuthStore = create<AuthState>()(
set({ loudnessTargetLufs: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessPreAnalysisAttenuationDb: (v) => {
set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) });
},
resetLoudnessPreAnalysisAttenuationDbDefault: () => {
set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => {
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
@@ -696,6 +729,10 @@ export const useAuthStore = create<AuthState>()(
} catch { /* ignore */ }
}
const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown };
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb);
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -703,6 +740,8 @@ export const useAuthStore = create<AuthState>()(
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
+95 -87
View File
@@ -148,9 +148,6 @@ async function buildInfiniteQueueCandidates(
interface PlayerState {
currentTrack: Track | null;
waveformBins: number[] | null;
waveformIsPartial: boolean;
waveformKnownUntilSec: number;
waveformDurationSec: number;
normalizationNowDb: number | null;
normalizationTargetLufs: number | null;
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
@@ -264,7 +261,8 @@ interface PlayerState {
}
type WaveformCachePayload = {
bins: number[];
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
bins: number[] | Uint8Array;
binCount: number;
isPartial: boolean;
knownUntilSec: number;
@@ -272,6 +270,27 @@ type WaveformCachePayload = {
updatedAt: number;
};
/** `Vec<u8>` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */
function coerceWaveformBins(bins: unknown): number[] | null {
if (bins == null) return null;
if (Array.isArray(bins)) {
return bins.length > 0 ? bins.map(x => Number(x) & 255) : null;
}
if (bins instanceof Uint8Array) {
return bins.length > 0 ? Array.from(bins) : null;
}
if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
const len = (bins as { length: number }).length;
if (len === 0) return null;
try {
return Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
} catch {
return null;
}
}
return null;
}
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
@@ -613,6 +632,14 @@ function clearLoudnessCacheStateForTrackId(trackId: string) {
}
}
/** Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit partial hints so Rust uses pre-trim until `analysis:loudness-partial` + `audio_update_replay_gain`. */
function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null {
if (!trackId) return null;
if (!stableLoudnessGainByTrackId[trackId]) return null;
const v = cachedLoudnessGainByTrackId[trackId];
return Number.isFinite(v) ? v : null;
}
function resetLoudnessBackfillStateForTrackId(trackId: string) {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete analysisBackfillInFlightByTrackId[k];
@@ -651,20 +678,15 @@ async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
if (!row || !Array.isArray(row.bins) || row.bins.length === 0) {
const bins = row ? coerceWaveformBins(row.bins) : null;
if (!bins || bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
});
return;
}
usePlayerStore.setState({
waveformBins: row.bins,
waveformIsPartial: !!row.isPartial,
waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0,
waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0,
waveformBins: bins,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
@@ -738,6 +760,31 @@ async function refreshLoudnessForTrack(
}
}
/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */
const LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS = 40;
function prefetchLoudnessForEnqueuedTracks(
incoming: Track[],
mergedQueue: Track[],
queueIndex: number,
) {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
const ids = new Set<string>();
const next = mergedQueue[queueIndex + 1];
if (next?.id) ids.add(next.id);
let n = 0;
for (const t of incoming) {
if (n >= LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS) break;
if (t?.id) {
ids.add(t.id);
n++;
}
}
for (const id of ids) {
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
}
}
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
try {
const res = await invoke<{ path: string; size: number } | null>(
@@ -824,10 +871,6 @@ function handleAudioProgress(current_time: number, duration: number) {
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = displayTime / dur;
// Do not couple `waveformKnownUntilSec` to playhead: for partial streams it
// must track **download/analysis progress** (emitted in `analysis:waveform-partial`).
// Using `displayTime` here broke the seekbar after seeks — bins only cover the
// buffered prefix of the file while `knownUntil` jumped with the seek position.
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
@@ -915,7 +958,11 @@ function handleAudioProgress(current_time: number, duration: number) {
gaplessEnabled,
});
}
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
}).catch(() => {});
}
// Gapless chain — decode + chain into Sink 30s before track boundary.
@@ -942,10 +989,11 @@ function handleAudioProgress(current_time: number, duration: number) {
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null,
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
hiResEnabled: authState.enableHiRes,
analysisTrackId: nextTrack.id,
}).catch(() => {});
}
}
@@ -972,8 +1020,6 @@ function handleAudioEnded() {
progress: 0,
currentTime: 0,
buffered: 0,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
});
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
@@ -1019,9 +1065,6 @@ function handleAudioTrackSwitched(duration: number) {
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
@@ -1106,48 +1149,6 @@ export function initAudioListeners(): () => void {
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!payload.isPartial || !payload?.bins?.length) {
usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 });
return;
}
const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0;
const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0);
usePlayerStore.setState((prev) => {
const prevBins = prev.waveformBins;
const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0;
if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) {
return {
waveformBins: payload.bins,
waveformIsPartial: true,
waveformKnownUntilSec: nextKnownUntilSec,
waveformDurationSec: nextDurationSec,
};
}
const len = Math.max(prevBins.length, payload.bins.length);
const merged = new Array<number>(len);
for (let i = 0; i < len; i++) {
merged[i] = i < prevBins.length ? prevBins[i] : 0;
}
const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len)));
const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len)));
if (nextKnownBars > prevKnownBars) {
for (let i = prevKnownBars; i < nextKnownBars; i++) {
if (i < payload.bins.length) merged[i] = payload.bins[i];
}
}
return {
waveformBins: merged,
waveformIsPartial: true,
waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec),
waveformDurationSec: nextDurationSec,
};
});
}),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
@@ -1167,12 +1168,10 @@ export function initAudioListeners(): () => void {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentId = usePlayerStore.getState().currentTrack?.id;
const currentRaw = usePlayerStore.getState().currentTrack?.id;
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
if (currentId && payloadTrackId === currentId) {
if (!payload.isPartial) {
usePlayerStore.setState({ waveformIsPartial: false });
}
void refreshWaveformForTrack(currentId);
void refreshWaveformForTrack(currentRaw!);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
@@ -1265,7 +1264,12 @@ export function initAudioListeners(): () => void {
invoke('audio_set_normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb,
}).catch(() => {});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
void refreshWaveformForTrack(bootTrackId);
}
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
@@ -1281,6 +1285,7 @@ export function initAudioListeners(): () => void {
// Keep audio settings in sync whenever auth store changes.
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
@@ -1289,10 +1294,16 @@ export function initAudioListeners(): () => void {
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget;
|| state.loudnessTargetLufs !== prevNormTarget
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
if (!normChanged) return;
const onlyPreAnalysisChanged =
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
@@ -1309,10 +1320,13 @@ export function initAudioListeners(): () => void {
invoke('audio_set_normalization', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb,
}).catch(() => {});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
@@ -1504,9 +1518,6 @@ export const usePlayerStore = create<PlayerState>()(
(set, get) => ({
currentTrack: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
@@ -1643,9 +1654,6 @@ export const usePlayerStore = create<PlayerState>()(
currentTime: 0,
currentRadio: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
@@ -1690,9 +1698,6 @@ export const usePlayerStore = create<PlayerState>()(
currentRadio: station,
currentTrack: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
@@ -1804,9 +1809,6 @@ export const usePlayerStore = create<PlayerState>()(
currentTrack: track,
currentRadio: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
@@ -1848,11 +1850,12 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
})
.then(() => {
if (playGeneration !== gen) return;
@@ -2029,11 +2032,12 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: trackToPlay.id,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
@@ -2064,11 +2068,12 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null,
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: currentTrack.id,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
@@ -2358,6 +2363,7 @@ export const usePlayerStore = create<PlayerState>()(
...state.queue.slice(firstAutoIdx),
];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex);
return { queue: newQueue };
});
},
@@ -2404,6 +2410,7 @@ export const usePlayerStore = create<PlayerState>()(
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex);
return { queue: newQueue, queueIndex: newQueueIndex };
});
},
@@ -2495,6 +2502,7 @@ export const usePlayerStore = create<PlayerState>()(
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
void refreshWaveformForTrack(currentTrack.id);
}
} catch (e) {
console.error('Failed to initialize queue from server', e);