// 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. import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; import { openLibraryDb } from '@/db/database'; import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries'; export const WAVEFORM_BINS = 512; 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>(); const previewInflight = new Map>(); export function getWaveform( trackPath: string, options: WaveformLoadOptions = {} ): Promise { if (!isLocalWaveformPath(trackPath)) return Promise.resolve(null); return loadWaveform(trackPath, options); } async function loadWaveform( trackPath: string, options: WaveformLoadOptions ): Promise { const db = await openLibraryDb(); const cached = await getWaveformPeaks(db, trackPath); if (cached && cached.length > 0) return cached; if (options.onPreview) { void getWaveformPreview(trackPath).then((preview) => { if (preview && preview.length > 0) options.onPreview?.(preview); }); } const existing = inflight.get(trackPath); if (existing) return existing; const task = decodeAccurateWaveform(trackPath).finally(() => inflight.delete(trackPath)); inflight.set(trackPath, task); return task; } async function decodeAccurateWaveform(trackPath: string): Promise { let raw: number[]; try { raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS); } catch { return null; } if (!raw || raw.length === 0) return null; const peaks = Float32Array.from(raw); const db = await openLibraryDb(); await putWaveformPeaks(db, trackPath, peaks).catch(() => { /* cache write failure is non-fatal */ }); return peaks; } function getWaveformPreview(trackPath: string): Promise { const existing = previewInflight.get(trackPath); if (existing) return existing; const task = decodePreviewWaveform(trackPath).finally(() => previewInflight.delete(trackPath)); previewInflight.set(trackPath, task); return task; } async function decodePreviewWaveform(trackPath: string): Promise { let raw: number[]; try { raw = await AstraLibraryScanner.extractWaveformPreview(trackPath, WAVEFORM_PREVIEW_BINS); } catch { return null; } if (!raw || raw.length === 0) return null; return Float32Array.from(raw); } 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; }