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
+56
View File
@@ -14,6 +14,7 @@ import { getLyricsCacheCount } from '@/db/lyricsQueries';
import { AstraLibraryData } from '../../../modules/astra-library-scanner';
import { clearAllLyricsCache } from '@/lyrics/lyrics';
import { clearAllWaveformCache } from '@/scope/waveform';
import { getRecentAnalysisTimings, type AnalysisTiming } from '@/audio/trackAnalysis';
import { useLyricsStore } from '@/stores/lyricsStore';
import { useLibraryStore } from '@/stores/libraryStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
@@ -183,10 +184,59 @@ export default function TroubleshootingSettingsScreen() {
subtitle="Audition semantic feedback, device primitives, and signature candidates."
onPress={() => router.push('/settings/haptics-lab' as never)}
/>
<AnalysisTimingPanel />
</SettingsSectionScreen>
);
}
/**
* How fast waveform/loudness decodes are actually running, per format and decoder. The
* realtime multiple is the number that decides whether MediaCodec is fast enough or whether
* the analysis path needs its own in-process decoder.
*/
function AnalysisTimingPanel() {
const styles = useStyles();
const colors = useColors();
const [timings, setTimings] = useState<readonly AnalysisTiming[]>([]);
useEffect(() => {
const read = () => setTimings(getRecentAnalysisTimings().slice(0, 6));
read();
const timer = setInterval(read, 2000);
return () => clearInterval(timer);
}, []);
if (timings.length === 0) {
return (
<Text variant="caption" color={colors.textSecondary} style={styles.timingEmpty}>
Decode speed appears here after a track with no cached waveform plays.
</Text>
);
}
return (
<View style={styles.timingPanel}>
{timings.map((timing) => (
<View key={`${timing.path}-${timing.at}`} style={styles.timingRow}>
<Text variant="mono" color={colors.textPrimary}>
{timing.kind === 'preview'
? `preview · ${Math.round(timing.decodeMs)}ms`
: `${(timing.mime ?? 'audio/?').replace('audio/', '')} · ${Math.round(timing.decodeMs)}ms` +
(timing.realtimeFactor ? ` · ${Math.round(timing.realtimeFactor)}× realtime` : '')}
</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{timing.kind === 'preview'
? 'sparse first-paint pass'
: `${timing.decoderName ?? 'unknown decoder'}${
timing.withLoudness ? ' · loudness folded in' : ''
}`}
</Text>
</View>
))}
</View>
);
}
function MaintenanceRow({
icon,
title,
@@ -247,4 +297,10 @@ const useStyles = createThemedStyles((colors) => ({
},
errorFeedback: { borderColor: colors.warning },
feedbackText: { flex: 1 },
timingPanel: {
gap: spacing.sm, padding: spacing.md, borderRadius: radius.sm,
borderWidth: 1, borderColor: colors.glassBorder, backgroundColor: colors.glassBg,
},
timingRow: { gap: 1 },
timingEmpty: { paddingHorizontal: spacing.md, lineHeight: 16 },
}));
+237 -45
View File
@@ -1,18 +1,28 @@
// Per-track normalization facts: ReplayGain tags (cheap, container-only) + measured
// integrated LUFS / sample peak (a decode, only when ReplayGain can't cover the track).
// Per-track analysis facts: waveform peaks + ReplayGain tags + measured integrated LUFS /
// sample peak.
//
// ensureTrackLoudness is the single deduped entry point used by the normalization sync
// (current track + queue prefetch). It reads ReplayGain tags once per track, and only
// falls back to the expensive loudness decode when ReplayGain is off or absent — so a
// fully tagged library normalizes with no decoding at all.
// Peaks and loudness come from ONE native decode pass (analyzeTrack). Both need every
// sample, so running them as separate whole-file decodes meant decoding each track twice —
// and the two decodes then competed for the same native concurrency permits, which is why
// the waveform used to arrive so late. Peaks fall out of the pass regardless, so we persist
// them even when loudness was the only reason we decoded.
//
// ensureTrackAnalysis is the single deduped entry point. It reads what's already cached,
// decodes only what's missing, and stores both halves. ReplayGain tags are read first
// (container-only, no decode), so a fully tagged library still normalizes without decoding.
import {
AstraLibraryData,
AstraLibraryScanner,
type NativeTrackLoudness,
type TrackAnalysis,
} from '../../modules/astra-library-scanner';
import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { CacheInvalidationGate } from '@/lib/cacheInvalidation';
/** Stored waveform resolution. Downsampled to the bar count at render time. */
export const WAVEFORM_BINS = 512;
/** Map a loudness DB row (or a miss) to the resolver's facts shape. */
export function factsFromRow(row: NativeTrackLoudness | null): LoudnessFacts {
@@ -26,46 +36,80 @@ export function factsFromRow(row: NativeTrackLoudness | null): LoudnessFacts {
};
}
/**
* Measure + store integrated loudness + sample peak for one track (always
* re-measures). The decode is the expensive part; failures leave loudness NULL.
*/
export async function measureAndStoreLoudness(
path: string
): Promise<{ lufs: number | null; peak: number | null }> {
try {
const res = await AstraLibraryScanner.measureLoudness(path);
const lufs = res?.lufs ?? null;
const peak = res?.peak ?? null;
await AstraLibraryData.setTrackLoudness(path, lufs, peak).catch(() => {});
return { lufs, peak };
} catch {
return { lufs: null, peak: null };
}
export interface TrackAnalysisResult {
/** Normalized [0,1] peaks, or null when unavailable / not requested and uncached. */
peaks: Float32Array | null;
facts: LoudnessFacts;
}
const inflight = new Map<string, Promise<LoudnessFacts>>();
export interface EnsureAnalysisOptions {
/**
* Decode for waveform peaks when they're missing. Pass false from headless paths
* (Android Auto / Bluetooth with no UI) that only need loudness — peaks are still
* persisted if a loudness decode happens to run, since they come out free.
*/
peaks?: boolean;
}
interface InflightRun {
promise: Promise<TrackAnalysisResult>;
wantPeaks: boolean;
}
const inflight = new Map<string, InflightRun>();
// Paths cancelled while their run was still in its DB-read phase. The native cancel flag
// only exists once analyzeTrack has been called, so without this a cancel landing in that
// window would be silently lost and the decode would run to completion anyway.
const cancelledPaths = new Set<string>();
const cacheGate = new CacheInvalidationGate();
/**
* Loudness facts for a track, reading ReplayGain tags and decoding only as needed
* (deduped by path). Cheap when already analyzed (single DB read). The normalization
* sync uses this so tracks from a pre-M4 library still normalize before a full rescan.
* Analysis facts for a track, decoding at most once and only for what's actually missing
* (deduped by path). Cheap when already analyzed — two DB reads and no decode.
*/
export function ensureTrackLoudness(path: string): Promise<LoudnessFacts> {
export function ensureTrackAnalysis(
path: string,
options: EnsureAnalysisOptions = {}
): Promise<TrackAnalysisResult> {
const wantPeaks = options.peaks !== false;
const existing = inflight.get(path);
if (existing) return existing;
const task = run(path).finally(() => inflight.delete(path));
inflight.set(path, task);
return task;
// A run that already covers what we need — join it.
if (existing && (existing.wantPeaks || !wantPeaks)) return existing.promise;
// A loudness-only run is going and we need peaks: let it finish (so we don't decode the
// same file twice concurrently), then fill in the peaks.
if (existing) return existing.promise.then(() => start(path, wantPeaks));
return start(path, wantPeaks);
}
async function run(path: string): Promise<LoudnessFacts> {
const row = (await AstraLibraryData.getTrackLoudness([path]))[0] ?? null;
let facts = factsFromRow(row);
function start(path: string, wantPeaks: boolean): Promise<TrackAnalysisResult> {
const existing = inflight.get(path);
if (existing?.wantPeaks) return existing.promise;
const promise = run(path, wantPeaks).finally(() => {
if (inflight.get(path)?.promise === promise) {
inflight.delete(path);
cancelledPaths.delete(path);
}
});
inflight.set(path, { promise, wantPeaks });
return promise;
}
// 1. Read ReplayGain tags once per track (container-only, no decode). Decoupled
// from loudness so a track measured before ReplayGain was enabled still picks
// up its tags; rg_scanned stays unset on failure so it retries next touch.
async function run(path: string, wantPeaks: boolean): Promise<TrackAnalysisResult> {
const generation = cacheGate.capture();
const [row, cachedPeaks] = await Promise.all([
AstraLibraryData.getTrackLoudness([path])
.then((rows) => rows[0] ?? null)
.catch(() => null),
AstraLibraryData.getWaveform(path).catch(() => null),
]);
let facts = factsFromRow(row);
let peaks = cachedPeaks && cachedPeaks.length > 0 ? Float32Array.from(cachedPeaks) : null;
// ReplayGain tags: container-only, no decode. Decoupled from loudness so a track measured
// before ReplayGain was enabled still picks up its tags; rg_scanned stays unset on failure
// so it retries next touch.
if (!row || row.rg_scanned !== 1) {
try {
const rg = await AstraLibraryScanner.readReplayGain(path);
@@ -88,14 +132,162 @@ async function run(path: string): Promise<LoudnessFacts> {
}
}
// 2. Loudness already measured — nothing more to do.
if (facts.loudnessLufs != null) return facts;
// 3. ReplayGain alone can normalize this track — skip the expensive decode.
// Loudness only needs measuring when it's unknown AND ReplayGain can't cover the track.
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
if (hasUsableReplayGain(facts, settings)) return facts;
const needLoudness = facts.loudnessLufs == null && !hasUsableReplayGain(facts, settings);
const needPeaks = wantPeaks && !peaks;
if (!needLoudness && !needPeaks) return { peaks, facts };
// Skipped past while we were reading the DB — don't start the decode at all.
if (cancelledPaths.has(path)) return { peaks, facts };
// 4. Otherwise measure loudness now (decode) and merge it in.
const measured = await measureAndStoreLoudness(path);
return { ...facts, loudnessLufs: measured.lufs, samplePeak: measured.peak };
let analysis: TrackAnalysis;
try {
analysis = await AstraLibraryScanner.analyzeTrack(path, WAVEFORM_BINS, needLoudness);
} catch {
return { peaks, facts };
}
recordTiming(path, analysis);
// Skipped past / timed out: peaks are truncated and loudness is partial. Cache neither.
if (analysis.cancelled) return { peaks, facts };
if (analysis.peaks && analysis.peaks.length > 0) {
peaks = Float32Array.from(analysis.peaks);
await persistPeaks(path, peaks, generation);
}
if (needLoudness) {
await AstraLibraryData.setTrackLoudness(path, analysis.lufs, analysis.peak).catch(() => {});
facts = { ...facts, loudnessLufs: analysis.lufs, samplePeak: analysis.peak };
}
return { peaks, facts };
}
async function persistPeaks(
path: string,
peaks: Float32Array,
generation: number
): Promise<void> {
await cacheGate
.enqueue(async () => {
if (!cacheGate.isCurrent(generation)) return;
await AstraLibraryData.putWaveform(path, Array.from(peaks));
})
.catch(() => {
/* cache write failure is non-fatal */
});
}
/**
* Loudness facts only — the normalization path's entry point. Does not decode purely to
* fill in a missing waveform, but keeps the peaks if a loudness decode produces them.
*/
export async function ensureTrackLoudness(path: string): Promise<LoudnessFacts> {
const { facts } = await ensureTrackAnalysis(path, { peaks: false });
return facts;
}
/**
* Stop an in-flight analysis for a track we've skipped past, so it stops burning CPU and
* frees a native decode permit for the track the user is actually on.
*/
export function cancelTrackAnalysis(path: string): void {
if (!inflight.has(path)) return;
cancelledPaths.add(path);
void AstraLibraryScanner.cancelAnalysis(path).catch(() => {});
}
/** Paths with an analysis currently running or queued. */
export function activeAnalysisPaths(): string[] {
return Array.from(inflight.keys());
}
/**
* Whether a decode for this path is already under way — i.e. progress events are about to
* start arriving, so a second "fast preview" decode would only land late and cause a
* visible rescale rather than buying a faster first paint.
*/
export function isAnalysisRunning(path: string): boolean {
return inflight.has(path);
}
/** Drops cached waveform rows and stops in-flight decodes from writing them back. */
export async function clearWaveformCache(): Promise<void> {
inflight.clear();
cancelledPaths.clear();
await cacheGate.invalidate(async () => {
await AstraLibraryData.clearWaveforms();
});
}
// ---------------------------------------------------------------------------
// Timing instrumentation
// ---------------------------------------------------------------------------
export interface AnalysisTiming {
path: string;
/** 'preview' entries are the sparse first-paint decode, 'analysis' the real pass. */
kind: 'analysis' | 'preview';
decodeMs: number;
durationMs: number | null;
/** durationMs / decodeMs — how many times faster than realtime the decode ran. */
realtimeFactor: number | null;
decoderName: string | null;
mime: string | null;
withLoudness: boolean;
at: number;
}
const MAX_TIMINGS = 20;
const recentTimings: AnalysisTiming[] = [];
function push(timing: AnalysisTiming): void {
recentTimings.unshift(timing);
if (recentTimings.length > MAX_TIMINGS) recentTimings.length = MAX_TIMINGS;
}
/**
* Record how long the sparse preview decode took, measured end to end from JS (so it
* includes the wait for a native permit — which is the number that decides whether the
* preview can still beat the real decode's first progress event to the screen).
*/
export function recordPreviewTiming(path: string, elapsedMs: number): void {
push({
path,
kind: 'preview',
decodeMs: elapsedMs,
durationMs: null,
realtimeFactor: null,
decoderName: null,
mime: null,
withLoudness: false,
at: Date.now(),
});
if (__DEV__) console.log(`[analysis] preview ${elapsedMs.toFixed(0)}ms`);
}
function recordTiming(path: string, analysis: TrackAnalysis): void {
if (analysis.cancelled || analysis.decodeMs == null) return;
push({
path,
kind: 'analysis',
decodeMs: analysis.decodeMs,
durationMs: analysis.durationMs,
realtimeFactor: analysis.realtimeFactor,
decoderName: analysis.decoderName,
mime: analysis.mime,
withLoudness: analysis.withLoudness,
at: Date.now(),
});
if (__DEV__) {
const rt = analysis.realtimeFactor;
console.log(
`[analysis] ${analysis.mime ?? '?'} ${analysis.decodeMs.toFixed(0)}ms` +
`${rt ? ` (${rt.toFixed(0)}x realtime)` : ''}` +
` via ${analysis.decoderName ?? '?'}${analysis.withLoudness ? ' +loudness' : ''}`
);
}
}
/** Most recent decodes, newest first — surfaced in Settings → Troubleshooting. */
export function getRecentAnalysisTimings(): readonly AnalysisTiming[] {
return recentTimings;
}
+36 -15
View File
@@ -13,7 +13,11 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization';
import { ensureTrackLoudness } from '@/audio/trackAnalysis';
import {
activeAnalysisPaths,
cancelTrackAnalysis,
ensureTrackAnalysis,
} from '@/audio/trackAnalysis';
import {
setNormalizationGainNative,
setTrackGainNative,
@@ -61,13 +65,13 @@ export function useNormalizationSync(): void {
return;
}
// ensureTrackLoudness is cheap when already analyzed (single DB read) and
// decodes+stores on a miss (lazy backfill for pre-scan tracks). During the
// ensureTrackAnalysis is cheap when already analyzed (two DB reads) and decodes on a
// miss — one pass covering both loudness and the seek bar's waveform. During the
// await the track is already playing at the conservative fallback gain
// (gainRegistry) — never at unity/full volume.
let facts = EMPTY_FACTS;
try {
facts = await ensureTrackLoudness(path);
({ facts } = await ensureTrackAnalysis(path));
if (cancelled) return;
// Track changed during the await — let the newer recompute win.
if (usePlayerStore.getState().currentTrack?.path !== path) return;
@@ -91,27 +95,38 @@ export function useNormalizationSync(): void {
useScopeStore.getState().setOscGain(computeOscilloscopeGain(basePeak, resolved.linearGain));
}
// MEASURE the next several upcoming tracks' loudness while the current one plays
// (decode-ahead for tracks with no facts yet), and register each late-arriving
// result natively by URL — so when the player advances, the gain is in the map
// and applies at the transition with no JS in the loop. Already-analyzed tracks
// are bulk-registered by gainRegistry; re-registering them here is a harmless
// cheap DB hit with the same value. Looking a few ahead (not just the immediate
// next) means a song added several positions back is still measured with plenty
// of lead time. Derived from the queue mirror, so it re-runs on reorder /
// add-next / advance. Deduped + DB-cached + native-semaphore-capped.
// ANALYZE the next several upcoming tracks while the current one plays (decode-ahead
// for tracks with no facts yet), and register each late-arriving gain natively by URL —
// so when the player advances, the gain is in the map and applies at the transition with
// no JS in the loop. Already-analyzed tracks are bulk-registered by gainRegistry;
// re-registering them here is a harmless cheap DB hit with the same value. Looking a few
// ahead (not just the immediate next) means a song added several positions back is still
// analyzed with plenty of lead time. Derived from the queue mirror, so it re-runs on
// reorder / add-next / advance. Deduped + DB-cached + native-semaphore-capped.
//
// The same pass fills the seek bar's waveform, which is why the waveform is usually
// already cached by the time you open now-playing: it used to only start decoding when
// WaveformSeekBar mounted, from cold, queued behind these very decodes.
function prefetchUpcoming(): void {
const { tracks, activeIndex } = useQueueStore.getState();
if (activeIndex < 0) return;
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
// The set of tracks worth spending a decode on right now. Everything else that is
// still decoding has been skipped past.
const wanted = new Set<string>();
const currentPath = usePlayerStore.getState().currentTrack?.path;
if (currentPath) wanted.add(currentPath);
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
const queued = tracks[activeIndex + i];
const url = queued?.url;
if (typeof url !== 'string' || url.length === 0) continue;
// Remote tracks: unity gain, and decoding the stream URL would download it.
if (queued?.sourceType && queued.sourceType !== 'local') continue;
void ensureTrackLoudness(url)
.then((facts) => {
wanted.add(url);
void ensureTrackAnalysis(url)
.then(({ facts }) => {
if (cancelled) return;
const resolved = resolveNormalizationGain(facts, settings);
setTrackGainNative(url, resolved.linearGain);
@@ -120,6 +135,12 @@ export function useNormalizationSync(): void {
/* leave unregistered — defaults to unity at the transition */
});
}
// Free the native decode permits: a decode for a track the user has skipped past is
// pure waste, and it would otherwise block the track they're actually on.
for (const path of activeAnalysisPaths()) {
if (!wanted.has(path)) cancelTrackAnalysis(path);
}
}
// The queue can change rapidly (drag-reorder); coalesce re-warms.
+45 -5
View File
@@ -21,7 +21,12 @@ import { Text } from './Text';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
import {
downsampleWaveform,
getWaveform,
mergeProgressiveWaveform,
subscribeWaveformProgress,
} from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { usePlayerStore } from '@/stores/playerStore';
import { playHaptic } from '@/lib/haptics';
@@ -36,7 +41,8 @@ const BAR_WIDTH = 3;
const BAR_GAP = 2;
const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
const PLAYHEAD_WIDTH = 2;
type WaveformQuality = 'preview' | 'accurate';
/** Ascending confidence — a lower quality never overwrites a higher one for the same track. */
type WaveformQuality = 'preview' | 'partial' | 'accurate';
interface WaveformSeekBarProps {
onSeek: (seconds: number) => void;
@@ -90,16 +96,47 @@ export function WaveformSeekBar({
const grantRef = useRef({ fraction: 0, pageX: 0 });
const detentRef = useRef<ScrubDetentState | null>(null);
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
// The coarse preview is kept aside as well as rendered: it's the amplitude reference the
// partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail.
const previewRef = useRef<{ path: string; peaks: Float32Array } | null>(null);
// Whether progressive fill has already begun for the current track. A preview that shows
// up after that point is worse than useless: adopting it mid-fill rescales every bar at
// once (the prefix is scaled against the preview's amplitude), which reads as two
// different waveforms fighting. Once we're filling, the preview is dropped.
const progressStartedRef = useRef(false);
// Load (cache-first) the offline peaks whenever the track changes.
// Load (cache-first) the offline peaks whenever the track changes, and follow the decode
// as it runs so the bars resolve left-to-right rather than snapping in at the end. The
// progress subscription is independent of who started the decode — usually the queue
// prefetch got there first, in which case this only ever sees the cache hit.
useEffect(() => {
if (!trackPath) return;
let cancelled = false;
previewRef.current = null;
progressStartedRef.current = false;
const unsubscribe = subscribeWaveformProgress(trackPath, ({ peaks, totalBins }) => {
if (cancelled) return;
progressStartedRef.current = true;
const preview = previewRef.current?.path === trackPath ? previewRef.current.peaks : null;
const merged = mergeProgressiveWaveform(peaks, totalBins, preview);
setLoaded((current) => {
if (current?.path === trackPath && current.quality === 'accurate' && current.peaks) {
return current;
}
return { path: trackPath, peaks: merged, quality: 'partial' };
});
});
void getWaveform(trackPath, {
onPreview: (peaks) => {
if (cancelled) return;
// Lost the race — the real decode is already painting. Adopting the preview now
// would rescale the whole bar in one frame.
if (progressStartedRef.current) return;
previewRef.current = { path: trackPath, peaks };
setLoaded((current) => {
if (current?.path === trackPath && current.quality === 'accurate' && current.peaks) {
if (current?.path === trackPath && current.quality !== 'preview' && current.peaks) {
return current;
}
return { path: trackPath, peaks, quality: 'preview' };
@@ -108,12 +145,15 @@ export function WaveformSeekBar({
}).then((peaks) => {
if (cancelled) return;
setLoaded((current) => {
if (!peaks && current?.path === trackPath && current.quality === 'preview') return current;
// A failed decode must not wipe a good preview or partial fill.
if (!peaks && current?.path === trackPath && current.peaks) return current;
return { path: trackPath, peaks, quality: 'accurate' };
});
});
return () => {
cancelled = true;
unsubscribe();
};
}, [trackPath]);
+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;
}