redo waveform and nomalization analysis

This commit is contained in:
Boof2015
2026-07-25 16:41:56 -04:00
parent a33a3b7137
commit 8b07b9e0e9
11 changed files with 1059 additions and 396 deletions
+82 -79
View File
@@ -1,22 +1,28 @@
// Waveform peaks for the seek bar: cache-first, preview-on-miss, accurate
// decode-on-miss, store. The heavy native decode (extractWaveform) still runs
// once per track and persists; extractWaveformPreview gives uncached local
// tracks a fast first paint.
// Waveform peaks for the seek bar: cache-first, preview-on-miss, then the accurate
// decode. The accurate pass lives in trackAnalysis (it shares one decode with loudness)
// and streams partial results back through onWaveformProgress, so the bar fills in
// left-to-right instead of snapping in when the whole file is done.
import { AstraLibraryData, AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { CacheInvalidationGate } from '@/lib/cacheInvalidation';
import {
WAVEFORM_BINS,
clearWaveformCache,
ensureTrackAnalysis,
isAnalysisRunning,
recordPreviewTiming,
} from '@/audio/trackAnalysis';
export const WAVEFORM_BINS = 512;
export { WAVEFORM_BINS };
export { downsampleWaveform, mergeProgressiveWaveform } from '@/scope/waveformMath';
export const WAVEFORM_PREVIEW_BINS = 96;
export interface WaveformLoadOptions {
onPreview?: (peaks: Float32Array) => void;
}
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
const inflight = new Map<string, Promise<Float32Array | null>>();
// Dedupe concurrent preview requests for the same track (e.g. mini-player + now-playing).
// The accurate decode is deduped inside trackAnalysis.
const previewInflight = new Map<string, Promise<Float32Array | null>>();
const cacheGate = new CacheInvalidationGate();
export function getWaveform(
trackPath: string,
@@ -30,52 +36,88 @@ async function loadWaveform(
trackPath: string,
options: WaveformLoadOptions
): Promise<Float32Array | null> {
const cached = await AstraLibraryData.getWaveform(trackPath);
const cached = await AstraLibraryData.getWaveform(trackPath).catch(() => null);
if (cached && cached.length > 0) return Float32Array.from(cached);
if (options.onPreview) {
// The preview is a SECOND native decode competing for the same two permits as the real
// pass. It only earns that cost when it can beat the real decode's first progress event
// to the screen. If a decode for this track is already running — the common case, since
// the queue prefetch starts one several tracks ahead — progress events are about to
// arrive immediately, and the preview would land late enough only to cause a visible
// rescale. Skip it entirely there.
if (options.onPreview && !isAnalysisRunning(trackPath)) {
const startedAt = Date.now();
void getWaveformPreview(trackPath).then((preview) => {
recordPreviewTiming(trackPath, Date.now() - startedAt);
if (preview && preview.length > 0) options.onPreview?.(preview);
});
}
const existing = inflight.get(trackPath);
if (existing) return existing;
const generation = cacheGate.capture();
const task = decodeAccurateWaveform(trackPath, generation).finally(() => {
if (inflight.get(trackPath) === task) inflight.delete(trackPath);
});
inflight.set(trackPath, task);
return task;
}
async function decodeAccurateWaveform(trackPath: string, generation: number): Promise<Float32Array | null> {
let raw: number[];
// Shares one decode pass with loudness, and may already be running from the queue
// prefetch — in which case this just joins it. Failures fall back to flat bars.
try {
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
const { peaks } = await ensureTrackAnalysis(trackPath);
return peaks;
} catch {
return null;
}
if (!raw || raw.length === 0) return null;
const peaks = Float32Array.from(raw);
await cacheGate.enqueue(async () => {
if (!cacheGate.isCurrent(generation)) return;
if (!cacheGate.isCurrent(generation)) return;
await AstraLibraryData.putWaveform(trackPath, Array.from(peaks));
}).catch(() => {
/* cache write failure is non-fatal */
});
return peaks;
}
/** Deletes waveform rows and prevents decodes already in flight from writing them back. */
export async function clearAllWaveformCache(): Promise<void> {
inflight.clear();
previewInflight.clear();
await cacheGate.invalidate(async () => {
await AstraLibraryData.clearWaveforms();
});
await clearWaveformCache();
}
// ---------------------------------------------------------------------------
// Progressive decode updates
// ---------------------------------------------------------------------------
export type WaveformProgressListener = (partial: {
/** Raw (un-normalized) RMS for the bins decoded so far. */
peaks: Float32Array;
filledBins: number;
totalBins: number;
}) => void;
const progressListeners = new Map<string, Set<WaveformProgressListener>>();
let nativeProgressSub: { remove(): void } | null = null;
/**
* Listen for partial waveforms while a track decodes. Independent of who started the
* decode, so the seek bar still fills progressively when the queue prefetch kicked it off.
* Returns an unsubscribe function.
*/
export function subscribeWaveformProgress(
trackPath: string,
listener: WaveformProgressListener
): () => void {
if (!nativeProgressSub) {
nativeProgressSub = AstraLibraryScanner.addListener('onWaveformProgress', (event) => {
const listeners = progressListeners.get(event.uri);
if (!listeners || listeners.size === 0) return;
const partial = {
peaks: Float32Array.from(event.peaks),
filledBins: event.filledBins,
totalBins: event.totalBins,
};
for (const cb of listeners) cb(partial);
});
}
let listeners = progressListeners.get(trackPath);
if (!listeners) {
listeners = new Set();
progressListeners.set(trackPath, listeners);
}
listeners.add(listener);
return () => {
const current = progressListeners.get(trackPath);
if (!current) return;
current.delete(listener);
if (current.size === 0) progressListeners.delete(trackPath);
};
}
function getWaveformPreview(trackPath: string): Promise<Float32Array | null> {
@@ -99,45 +141,6 @@ async function decodePreviewWaveform(trackPath: string): Promise<Float32Array |
return Float32Array.from(raw);
}
function isLocalWaveformPath(trackPath: string): boolean {
export function isLocalWaveformPath(trackPath: string): boolean {
return trackPath.startsWith('content://') || trackPath.startsWith('file://');
}
/**
* Downsample high-res peaks to `barCount` bars with a power curve and two
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
* mobile seek bar matches the desktop look.
*/
export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array {
if (source.length === 0 || barCount <= 0) return new Float32Array(0);
const binsPerBar = source.length / barCount;
const peaks = new Float32Array(barCount);
for (let i = 0; i < barCount; i++) {
const start = Math.floor(i * binsPerBar);
const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar));
let sum = 0;
for (let j = start; j < end; j++) sum += source[j];
peaks[i] = sum / (end - start);
}
let max = 0;
for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i];
if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max;
// Power curve — exaggerate dynamic range.
for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2;
// Two smoothing passes.
let current = peaks;
for (let p = 0; p < 2; p++) {
const smoothed = new Float32Array(current.length);
smoothed[0] = current[0];
smoothed[current.length - 1] = current[current.length - 1];
for (let i = 1; i < current.length - 1; i++) {
smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25;
}
current = smoothed;
}
return current;
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { downsampleWaveform, mergeProgressiveWaveform } from './waveformMath.ts';
const TOTAL = 512;
/** Preview normalized to [0,1] across the whole track, as the native preview returns it. */
function makePreview(values: number[]): Float32Array {
return Float32Array.from(values);
}
test('merge with no prefix is just the stretched preview', () => {
const preview = makePreview([0.2, 0.8, 0.4, 1]);
const merged = mergeProgressiveWaveform(new Float32Array(0), TOTAL, preview);
assert.equal(merged.length, TOTAL);
assert.ok(Math.abs(merged[0] - 0.2) < 1e-6);
assert.ok(Math.abs(merged[TOTAL - 1] - 1) < 1e-6);
// Each preview value should occupy an equal quarter of the width.
assert.ok(Math.abs(merged[Math.floor(TOTAL * 0.3)] - 0.8) < 1e-6);
});
test('merge with no preview normalizes the prefix against its own max', () => {
const prefix = Float32Array.from([0.01, 0.02, 0.04]); // raw RMS, tiny absolute values
const merged = mergeProgressiveWaveform(prefix, TOTAL, null);
assert.ok(Math.abs(merged[2] - 1) < 1e-6, 'loudest decoded bin should reach full scale');
assert.ok(Math.abs(merged[0] - 0.25) < 1e-6);
assert.equal(merged[3], 0, 'undecoded tail stays empty without a preview');
});
test('prefix is rescaled to the preview, not to its own max', () => {
// Preview says the first half is quiet (0.25) and the second half is loud (1.0).
const preview = makePreview([0.25, 0.25, 1, 1]);
// We have decoded the quiet first half only. Raw RMS values are arbitrary in scale.
const prefix = new Float32Array(TOTAL / 2).fill(0.003);
const merged = mergeProgressiveWaveform(prefix, TOTAL, preview);
// Naive self-normalization would put the decoded half at 1.0 — far louder than the
// preview says it is, and louder than the not-yet-decoded loud half. It must stay at
// the preview's amplitude for that region instead.
assert.ok(Math.abs(merged[0] - 0.25) < 1e-6, `decoded region should match preview scale, got ${merged[0]}`);
assert.ok(merged[0] < merged[TOTAL - 1], 'quiet decoded half must stay below the loud undecoded half');
assert.ok(Math.abs(merged[TOTAL - 1] - 1) < 1e-6, 'undecoded tail keeps the preview value');
});
test('merge never exceeds full scale', () => {
const preview = makePreview([1, 1, 1, 1]);
const prefix = Float32Array.from([5, 10, 2]);
const merged = mergeProgressiveWaveform(prefix, TOTAL, preview);
for (let i = 0; i < merged.length; i++) {
assert.ok(merged[i] <= 1, `bin ${i} exceeded 1: ${merged[i]}`);
assert.ok(merged[i] >= 0, `bin ${i} went negative: ${merged[i]}`);
}
});
test('an all-silent prefix falls back to the preview rather than blanking the bar', () => {
const preview = makePreview([0.5, 0.6, 0.7, 0.8]);
const merged = mergeProgressiveWaveform(new Float32Array(64), TOTAL, preview);
assert.ok(Math.abs(merged[0] - 0.5) < 1e-6);
});
test('downsampling a partially-filled merge keeps the decoded region proportionate', () => {
// Decoded half is quiet, undecoded half is loud — after downsampling the relationship
// must survive, i.e. the global normalize must not lift the quiet decoded half.
const preview = makePreview([0.25, 0.25, 1, 1]);
const prefix = new Float32Array(TOTAL / 2).fill(0.003);
const merged = mergeProgressiveWaveform(prefix, TOTAL, preview);
const bars = downsampleWaveform(merged, 64);
assert.equal(bars.length, 64);
const firstQuarter = bars[8];
const lastQuarter = bars[56];
assert.ok(
lastQuarter > firstQuarter * 4,
`loud half (${lastQuarter}) should dominate the quiet decoded half (${firstQuarter})`
);
for (let i = 0; i < bars.length; i++) {
assert.ok(bars[i] >= 0 && bars[i] <= 1, `bar ${i} out of range: ${bars[i]}`);
}
});
test('downsample is unchanged for a fully accurate waveform', () => {
const source = Float32Array.from({ length: TOTAL }, (_, i) => (i < TOTAL / 2 ? 0.2 : 1));
const bars = downsampleWaveform(source, 32);
assert.equal(bars.length, 32);
assert.ok(Math.abs(bars[31] - 1) < 1e-6, 'loudest bar normalizes to full scale');
assert.ok(bars[0] < 0.1, 'x^2 power curve should push the quiet region well down');
});
+85
View File
@@ -0,0 +1,85 @@
// Pure waveform shaping — no native imports, so it stays unit-testable under `node --test`
// (see waveformMath.test.mts). waveform.ts re-exports these.
/**
* Splice a partially-decoded raw RMS prefix over the coarse preview, so the bar fills
* left-to-right with no visible seam.
*
* The prefix is raw — mid-decode the native side can't know the track's global max — while
* the preview is already normalized against the whole track. So the prefix is rescaled to
* the preview's amplitude over the region it covers rather than to its own max; normalizing
* it independently would make the decoded part read far louder than the rest until a loud
* section happened to arrive.
*/
export function mergeProgressiveWaveform(
prefix: Float32Array,
totalBins: number,
preview: Float32Array | null
): Float32Array {
const out = new Float32Array(Math.max(0, totalBins));
if (totalBins <= 0) return out;
// Stretch the (much coarser) preview across the full width first.
const hasPreview = !!preview && preview.length > 0;
if (preview && hasPreview) {
for (let i = 0; i < totalBins; i++) {
const p = Math.min(preview.length - 1, Math.floor((i / totalBins) * preview.length));
out[i] = preview[p];
}
}
const filled = Math.min(prefix.length, totalBins);
if (filled === 0) return out;
let prefixMax = 0;
for (let i = 0; i < filled; i++) if (prefix[i] > prefixMax) prefixMax = prefix[i];
if (prefixMax <= 0) return out;
// Match the preview's scale over the decoded region so the seam is continuous.
let reference = 0;
if (hasPreview) {
for (let i = 0; i < filled; i++) if (out[i] > reference) reference = out[i];
}
const scale = (reference > 0 ? reference : 1) / prefixMax;
for (let i = 0; i < filled; i++) out[i] = Math.min(1, prefix[i] * scale);
return out;
}
/**
* Downsample high-res peaks to `barCount` bars with a power curve and two
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
* mobile seek bar matches the desktop look.
*/
export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array {
if (source.length === 0 || barCount <= 0) return new Float32Array(0);
const binsPerBar = source.length / barCount;
const peaks = new Float32Array(barCount);
for (let i = 0; i < barCount; i++) {
const start = Math.floor(i * binsPerBar);
const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar));
let sum = 0;
for (let j = start; j < end; j++) sum += source[j];
peaks[i] = sum / (end - start);
}
let max = 0;
for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i];
if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max;
// Power curve — exaggerate dynamic range.
for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2;
// Two smoothing passes.
let current = peaks;
for (let p = 0; p < 2; p++) {
const smoothed = new Float32Array(current.length);
smoothed[0] = current[0];
smoothed[current.length - 1] = current[current.length - 1];
for (let i = 1; i < current.length - 1; i++) {
smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25;
}
current = smoothed;
}
return current;
}