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,
]);