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>
This commit is contained in:
108
api/test/routes/waveforms.test.ts
Normal file
108
api/test/routes/waveforms.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createUser, get, insertDump, test, TEST_DIR } from "../helpers.ts";
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { WAVEFORM_PEAKS } from "../../../shared/waveform.ts";
|
||||
|
||||
async function hasFfmpeg(): Promise<boolean> {
|
||||
try {
|
||||
return (await new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output()).success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a mono 16-bit WAV of a 440 Hz tone, quiet for its first `seconds`
|
||||
* and four times louder for as long again.
|
||||
*/
|
||||
function wav(seconds: number, rate = 8000): Uint8Array {
|
||||
const n = seconds * rate * 2;
|
||||
const bytes = new Uint8Array(44 + n * 2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const ascii = (at: number, s: string) =>
|
||||
[...s].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
|
||||
ascii(0, "RIFF");
|
||||
view.setUint32(4, 36 + n * 2, true);
|
||||
ascii(8, "WAVEfmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, rate, true);
|
||||
view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
view.setUint32(40, n * 2, true);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const gain = i < n / 2 ? 4000 : 16000;
|
||||
view.setInt16(
|
||||
44 + i * 2,
|
||||
gain * Math.sin((2 * Math.PI * 440 * i) / rate),
|
||||
true,
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test("GET /api/dumps/:id/waveform: an audio file's peaks, made once, with privacy", async () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const owner = await createUser();
|
||||
const song = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "s.wav",
|
||||
fileMime: "audio/wav",
|
||||
fileData: wav(2),
|
||||
});
|
||||
const res = await get<{ peaks: number[] }>(`/api/dumps/${song.id}/waveform`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("content-type"), "application/json");
|
||||
assertEquals(res.headers.get("cache-control"), "public, no-cache");
|
||||
const { peaks } = res.data;
|
||||
assertEquals(peaks.length, WAVEFORM_PEAKS);
|
||||
// The loud half reaches the top; the quiet half a quarter of it.
|
||||
assertEquals(Math.max(...peaks), 1);
|
||||
assert(Math.abs(peaks[10] - 0.25) < 0.02, `quiet peak ${peaks[10]}`);
|
||||
assert(peaks[140] > 0.98, `loud peak ${peaks[140]}`);
|
||||
assert(
|
||||
[...Deno.readDirSync(`${TEST_DIR}/uploads/thumbnails`)].some((e) =>
|
||||
e.name === `${song.id}.waveform.json`
|
||||
),
|
||||
);
|
||||
// The kept file is served again, with its ETag.
|
||||
const etag = res.headers.get("etag")!;
|
||||
const again = await get(`/api/dumps/${song.id}/waveform`, {
|
||||
headers: { "If-None-Match": etag },
|
||||
});
|
||||
assertEquals(again.status, 304);
|
||||
|
||||
const secret = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p.wav",
|
||||
fileMime: "audio/wav",
|
||||
fileData: wav(1),
|
||||
isPrivate: true,
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${secret.id}/waveform`)).status, 404);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${secret.id}/waveform?token=${owner.mediaToken}`))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/waveform: 404 for anything but readable audio", async () => {
|
||||
const owner = await createUser();
|
||||
const text = insertDump({ userId: owner.id, fileName: "t.txt" });
|
||||
assertEquals((await get(`/api/dumps/${text.id}/waveform`)).status, 404);
|
||||
const noise = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "n.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileData: new TextEncoder().encode("not audio at all"),
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${noise.id}/waveform`)).status, 404);
|
||||
assertEquals((await get(`/api/dumps/nope/waveform`)).status, 400);
|
||||
});
|
||||
Reference in New Issue
Block a user