Files
gerbeur/api/routes/waveforms.ts
khannurien fac0e2db28 v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:22 +00:00

49 lines
1.7 KiB
TypeScript

/**
* Serves an audio file dump's waveform, a sub-resource of its dump, for the
* player to seek on. The frontend builds the URL with `dumpWaveformUrl`.
* lib/ffmpeg.ts makes it from the upload on first request and keeps it, so a
* listener downloads a few hundred bytes of peaks rather than the file a
* second time to decode it.
*
* - `GET /api/dumps/:dumpId/waveform?v=&token=` — public; a private dump's
* waveform needs its poster's media `token`. Answers a `DumpWaveform`; 404
* when the dump isn't an audio file or its audio can't be read.
*
* @module
*/
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import { DUMPS_DIR } from "../config.ts";
import { resolveMediaDump } from "../lib/dump-media.ts";
import {
audioWaveform,
ensureGenerated,
generatedPaths,
} from "../lib/ffmpeg.ts";
import { serveFile } from "../lib/serve-file.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/dumps" });
router.get("/:dumpId/waveform", async (ctx) => {
const { dump, cacheControl } = await resolveMediaDump(ctx, ctx.params.dumpId);
if (dump.kind !== "file" || !dump.fileMime?.startsWith("audio/")) {
throw new APIException(404, "No waveform for this dump");
}
const outPath = generatedPaths(dump.id).waveform;
const srcPath = `${DUMPS_DIR}/${dump.id}`;
if (
!await ensureGenerated(outPath, () => audioWaveform(srcPath, outPath)) ||
!await serveFile(ctx, outPath, { mime: "application/json", cacheControl })
) {
throw new APIException(404, "Could not generate waveform");
}
});
export default router;