feat(studio): server-side waveform generation with caching

This commit is contained in:
alexcraviotto
2026-04-26 00:45:12 +02:00
parent 9b72a87c17
commit 894f6e38f1
7 changed files with 210 additions and 34 deletions
@@ -6,6 +6,7 @@ import { registerPreviewRoutes } from "./routes/preview.js";
import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
/**
* Create a Hono sub-app with all studio API routes.
@@ -22,6 +23,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
return api;
}
@@ -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));
}
+7 -1
View File
@@ -13,6 +13,8 @@ import {
} from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js";
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
@@ -345,7 +347,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
continue;
}
writeFileSync(finalPath, buffer);
uploaded.push(subDir ? join(subDir, finalName) : finalName);
const relativePath = subDir ? join(subDir, finalName) : finalName;
uploaded.push(relativePath);
if (isAudioFile(finalName)) {
generateWaveformCache(project.dir, relativePath).catch(() => {});
}
}
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
@@ -0,0 +1,49 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { decodeAudioPeaks, buildWaveformCacheKey } from "../helpers/waveform.js";
export { isAudioFile } from "../helpers/mime.js";
export { generateWaveformCache } from "../helpers/waveform.js";
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/waveform/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
);
const audioPath = join(project.dir, assetPath);
if (!existsSync(audioPath)) return c.json({ error: "file not found" }, 404);
const cacheDir = join(project.dir, ".waveform-cache");
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
if (existsSync(cachePath)) {
try {
const peaks = JSON.parse(readFileSync(cachePath, "utf-8")) as number[];
return c.json({ peaks });
} catch {
// corrupt cache — regenerate
}
}
let peaks: number[];
try {
peaks = await decodeAudioPeaks(audioPath);
} catch {
return c.json({ error: "failed to decode audio" }, 500);
}
try {
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, JSON.stringify(peaks));
} catch {
// cache write failure is non-fatal
}
return c.json({ peaks });
});
}