109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
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);
|
|
});
|