mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): server-side waveform generation with caching
This commit is contained in:
@@ -18,6 +18,9 @@ export const MIME_TYPES: Record<string, string> = {
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".opus": "audio/ogg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
@@ -30,3 +33,7 @@ export function getMimeType(path: string): string {
|
||||
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
|
||||
export function isAudioFile(name: string): boolean {
|
||||
return (getMimeType(name) ?? "").startsWith("audio/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SAMPLE_RATE = 4000;
|
||||
const PEAK_COUNT = 4000;
|
||||
export const WAVEFORM_CACHE_VERSION = "v2";
|
||||
|
||||
export function buildWaveformCacheKey(assetPath: string): string {
|
||||
return `${WAVEFORM_CACHE_VERSION}_${assetPath.replace(/[/\\]/g, "_")}.json`;
|
||||
}
|
||||
|
||||
function computePeaks(floats: Float32Array, count: number): number[] {
|
||||
const step = floats.length / count;
|
||||
const peaks: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const start = Math.floor(i * step);
|
||||
const end = Math.min(Math.floor((i + 1) * step), floats.length);
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(floats[j] ?? 0);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(max);
|
||||
}
|
||||
const maxPeak = Math.max(...peaks, 0.001);
|
||||
return peaks.map((p) => p / maxPeak);
|
||||
}
|
||||
|
||||
export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-i",
|
||||
audioPath,
|
||||
"-af",
|
||||
"atrim=start_sample=1152",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(SAMPLE_RATE),
|
||||
"-vn",
|
||||
"pipe:1",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "ignore"] },
|
||||
);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0 && chunks.length === 0) {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
const numSamples = Math.floor(buf.length / 4);
|
||||
if (numSamples === 0) {
|
||||
reject(new Error("ffmpeg produced no audio samples"));
|
||||
return;
|
||||
}
|
||||
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
|
||||
resolve(computePeaks(new Float32Array(ab), PEAK_COUNT));
|
||||
});
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWaveformCache(projectDir: string, assetPath: string): Promise<void> {
|
||||
const audioPath = join(projectDir, assetPath);
|
||||
if (!existsSync(audioPath)) return;
|
||||
|
||||
const cacheDir = join(projectDir, ".waveform-cache");
|
||||
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
|
||||
if (existsSync(cachePath)) return;
|
||||
|
||||
const peaks = await decodeAudioPeaks(audioPath);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(cachePath, JSON.stringify(peaks));
|
||||
}
|
||||
Reference in New Issue
Block a user