Files
PodTui/src/utils/audio-waveform.ts
2026-02-06 09:57:33 -05:00

104 lines
2.7 KiB
TypeScript

/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}