commit 654a78c5cabb44f53af6dcb96819daa676c126f3
parent b070be886ce6454d48a9255d8becc1f2d4956615
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 14:28:39 -0400
umtool: a clip's muteFrom is written by updateClip, and a window's sound is served decoded
updateClip takes `muteFrom` (source seconds): a number inside the clip's
[start, end] after the patch, rounded like an edge, or null/empty to delete
the key. A window save that would leave the mark outside the new extent is
refused unless the same patch moves or clears it. PUT /api/report/window
whitelists it; /api/report/clip returns it.
/api/report/audio serves a cached window's audio, picked by the same
membership rule as /api/report/raw, decoded by ffmpeg to 16-bit PCM WAV over
an absolute span (at most 120 s). The bench plays bounded ranges from it with
Web Audio. ffmpeg decodes it because the build cuts with ffmpeg: a cut set by
ear is set on the build's timeline.
lib/report/playback.mjs holds the arithmetic both sides use (decode span,
buffer schedule, playhead, mute ramp, the element fallback's stop test, the
muteFrom rule, the WAV header), unit-tested.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 559 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/report/audio/route.ts b/umtool/app/api/report/audio/route.ts
@@ -0,0 +1,114 @@
+import { spawn } from "node:child_process";
+import { stat } from "node:fs/promises";
+import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { MAX_DECODE_SPAN, wavHeader } from "@/lib/report/playback.mjs";
+
+export const dynamic = "force-dynamic";
+
+// A cached source window's SOUND, decoded, for the bench to play exactly.
+//
+// The bench plays every bounded range -- a selection, an edge, a line -- from
+// a decoded buffer with Web Audio, because an <video> element stopped from
+// `timeupdate` overran the end by up to a quarter of a second. This is that
+// buffer: the same file /api/report/raw serves (picked by the same membership
+// rule), as 16-bit PCM WAV, which every browser decodes without a codec.
+//
+// DECODED BY FFMPEG, NOT BY THE BROWSER. The build cuts with ffmpeg, so a cut
+// set by ear has to be set against ffmpeg's timeline: a browser's own AAC
+// decode may or may not honour the file's edit list, and the encoder's
+// priming samples are tens of milliseconds -- the size of the error this
+// exists to remove.
+//
+// `from`/`to` are ABSOLUTE source seconds, clamped to the file. A span longer
+// than MAX_DECODE_SPAN is refused rather than served: a whole recording in the
+// saved-video store is hours, and the bench asks for a window around the
+// selection instead (decodeSpan).
+
+/** Matches the build's `render.audioRate` default; the browser resamples anyway. */
+const RATE = 48000;
+const CHANNELS = 2;
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveClip(url.searchParams.get("project") ?? "", url.searchParams.get("clip") ?? "");
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const windows = await windowsFor(r.project, r.clip);
+ const win = pickWindow(windows, url.searchParams.get("file"));
+ if (!win) return Response.json({ error: "no cached window for this clip" }, { status: 404 });
+ const abs = absOf(win);
+ if (!abs) return Response.json({ error: "outside the roots" }, { status: 400 });
+ const st = await stat(abs).catch(() => null);
+ if (!st) return Response.json({ error: "gone" }, { status: 404 });
+
+ const num = (k: string, dflt: number) => {
+ const v = url.searchParams.get(k);
+ const n = v == null || v === "" ? dflt : Number(v);
+ return Number.isFinite(n) ? n : Number.NaN;
+ };
+ const from = Math.max(win.from, num("from", win.from));
+ const to = Math.min(win.to, num("to", win.to));
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
+ return Response.json({ error: "bad span" }, { status: 400 });
+ }
+ if (to - from > MAX_DECODE_SPAN + 0.5) {
+ return Response.json(
+ { error: `asked for ${Math.round(to - from)} s; the bench decodes at most ${MAX_DECODE_SPAN} s at once` },
+ { status: 400 },
+ );
+ }
+
+ // Input seeking with a decode is sample-accurate in ffmpeg: it seeks to the
+ // keyframe before and discards up to the requested time.
+ const chunks: Buffer[] = [];
+ let err = "";
+ const code = await new Promise<number>((resolve) => {
+ const ff = spawn("ffmpeg", [
+ "-nostdin", "-v", "error",
+ "-ss", (from - win.from).toFixed(6),
+ "-i", abs,
+ "-t", (to - from).toFixed(6),
+ "-map", "a:0",
+ "-ac", String(CHANNELS), "-ar", String(RATE),
+ "-f", "s16le", "-acodec", "pcm_s16le", "-",
+ ]);
+ ff.stdout.on("data", (b: Buffer) => chunks.push(b));
+ ff.stderr.on("data", (b: Buffer) => {
+ err += b.toString();
+ });
+ ff.on("error", (e) => {
+ err += String(e);
+ resolve(-1);
+ });
+ ff.on("close", (c) => resolve(c ?? -1));
+ });
+ const pcm = Buffer.concat(chunks);
+ const frameBytes = CHANNELS * 2;
+ const dataBytes = pcm.length - (pcm.length % frameBytes);
+ if (code !== 0 || dataBytes === 0) {
+ // Said in the bench, which then plays through the element: the message is
+ // the reason the playback is approximate.
+ const why = /matches no streams|does not contain any stream/i.test(err)
+ ? "this file has no audio track"
+ : err.trim().split("\n").pop() || `ffmpeg exited ${code}`;
+ return Response.json({ error: why }, { status: 422 });
+ }
+
+ const body = new Uint8Array(44 + dataBytes);
+ body.set(wavHeader({ channels: CHANNELS, sampleRate: RATE, dataBytes }), 0);
+ body.set(pcm.subarray(0, dataBytes), 44);
+ return new Response(body, {
+ headers: {
+ "content-type": "audio/wav",
+ // The window is in the file's name, so the same request is the same
+ // sound: it may be cached for as long as the raw file is.
+ "cache-control": "private, max-age=3600, immutable",
+ // What was actually decoded, in absolute source seconds: the client
+ // places the buffer on the source clock from these, not from what it
+ // asked for.
+ "x-audio-from": String(from),
+ "x-audio-to": String(from + dataBytes / frameBytes / RATE),
+ "x-window": win.name,
+ },
+ });
+}
diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts
@@ -59,6 +59,7 @@ export async function GET(request: Request) {
cutStart: clip.cutStart ?? null,
cutEnd: clip.cutEnd ?? null,
lockCut: !!clip.lockCut,
+ muteFrom: clip.muteFrom ?? null,
},
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts
@@ -47,6 +47,10 @@ export async function PUT(request: Request) {
"cutStart",
"cutEnd",
"lockCut",
+ // Where the sound fades out for the rest of the clip while the picture
+ // plays on, in source seconds; empty or null clears it. Checked by the
+ // writer against the clip's window, like the cut.
+ "muteFrom",
// Whether the walk has looked at this clip: "confirmed", or empty to clear
// it. A non-empty `correction` is the other answer and needs no value.
"verdict",
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -31,6 +31,7 @@ import {
} from "umtool-report-to-video/ledger-totals";
import { isCalendarDate } from "umtool-report-to-video/attribution";
import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck";
+import { parseMuteFrom } from "./playback.mjs";
// Its own write queue, not lib/state.ts's.
//
@@ -281,6 +282,29 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) {
}
}
+ // ---- the mute mark ------------------------------------------------------
+ //
+ // `muteFrom`: from this source second to the end of the clip the sound
+ // fades out and the picture plays on -- set by ear, in the bench, at the
+ // last silence before a finale's ending sound. Inside the EXTENT, like the
+ // cut, and checked against the entry AFTER the patch: a window save that
+ // leaves the mark outside is refused rather than keeping a mark that no
+ // longer says anything about the clip. Empty or null deletes it.
+ if (patch.muteFrom !== undefined) {
+ const v = parseMuteFrom(patch.muteFrom, entry.start, entry.end);
+ if (v == null) delete entry.muteFrom;
+ else entry.muteFrom = v;
+ } else if ((patch.start !== undefined || patch.end !== undefined) && entry.muteFrom != null) {
+ try {
+ parseMuteFrom(entry.muteFrom, entry.start, entry.end);
+ } catch {
+ throw new Error(
+ `the mute mark ${entry.muteFrom} must lie inside the window ` +
+ `${entry.start}–${entry.end} — widen the window, or clear the mark`,
+ );
+ }
+ }
+
// ---- the walk's verdict -------------------------------------------------
//
// Whether somebody has LOOKED at this clip and said the description is what
diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs
@@ -181,6 +181,42 @@ test("updateClip: onscreen is normalised, and empty or null deletes the key", as
}
});
+test("updateClip: muteFrom is a number inside the clip, rounded; empty or null deletes the key", async () => {
+ const dir = await project();
+ try {
+ const res = await updateClip(dir, "c01", { muteFrom: 18.456 });
+ assert.equal(res.entry.muteFrom, 18.46);
+ assert.equal(entry(await read(dir), "c01").muteFrom, 18.46);
+ // A string from a form is a number too.
+ await updateClip(dir, "c01", { muteFrom: "17.5" });
+ assert.equal(entry(await read(dir), "c01").muteFrom, 17.5);
+
+ const before = await readRaw(dir);
+ // Outside [start, end], and not a number: refused, nothing written.
+ await assert.rejects(updateClip(dir, "c01", { muteFrom: 25 }), /must lie inside the clip 10–20/);
+ await assert.rejects(updateClip(dir, "c01", { muteFrom: 5 }), /must lie inside/);
+ await assert.rejects(updateClip(dir, "c01", { muteFrom: "later" }), /must be a number/);
+ // A window that would leave the mark outside it is refused too...
+ await assert.rejects(updateClip(dir, "c01", { end: 17 }), /mute mark 17.5 must lie inside the window 10–17/);
+ assert.equal(await readRaw(dir), before);
+ // ...unless the same patch moves or clears it.
+ await updateClip(dir, "c01", { end: 17, muteFrom: 16 });
+ assert.equal(entry(await read(dir), "c01").muteFrom, 16);
+ // Checked against the window AFTER the patch: a wider window and a mark in
+ // the new seconds land together.
+ await updateClip(dir, "c01", { end: 22, muteFrom: 21 });
+ assert.equal(entry(await read(dir), "c01").muteFrom, 21);
+
+ await updateClip(dir, "c01", { muteFrom: "" });
+ assert.equal("muteFrom" in entry(await read(dir), "c01"), false);
+ await updateClip(dir, "c01", { muteFrom: 12 });
+ await updateClip(dir, "c01", { muteFrom: null });
+ assert.equal("muteFrom" in entry(await read(dir), "c01"), false);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } };
test("updateChrome: round trip stores the block as given; null removes it", async () => {
diff --git a/umtool/lib/report/playback.mjs b/umtool/lib/report/playback.mjs
@@ -0,0 +1,228 @@
+// The clip bench's bounded playback, as arithmetic.
+//
+// The bench used to play a range on the <video> element and stop it from
+// `timeupdate`, which fires every 15–250 ms: what you heard ran past the end
+// by up to a quarter of a second, and by a different amount every time. That
+// is the difference between a cut that lands between two words and one that
+// swallows the next syllable, and it could not be judged by ear.
+//
+// So a bounded range plays from the DECODED audio with Web Audio, the old
+// um-triage chooser's technique: an AudioBufferSourceNode started at an exact
+// buffer offset and stopped at an exact context time, sample-accurately. The
+// picture follows along, muted. These are the numbers that decide where in the
+// buffer to start, when to stop, where the playhead is, and when the finale's
+// mute mark goes quiet. Pure: no DOM, no Web Audio, so they are unit-tested and
+// the component only applies them.
+//
+// EVERYTHING IS IN ABSOLUTE SOURCE SECONDS except where a name says `wall`
+// (seconds of the listener's time, which is source time divided by the rate)
+// or `ctx` (the AudioContext's clock).
+
+/** Decoding is per window, but never more than this many seconds at once. */
+export const MAX_DECODE_SPAN = 120;
+
+/**
+ * Around the selection, when a window is too long to decode whole (a whole
+ * recording fetched into the saved-video store): enough to drag an edge and
+ * hear it without a second decode.
+ */
+export const DECODE_MARGIN = 30;
+
+/**
+ * How far ahead of "now" a playback is scheduled, in wall seconds.
+ *
+ * A start scheduled in the past is started late at the SAME offset, so every
+ * time computed from it would be early by however late it was. Scheduling a
+ * little ahead keeps `start` and `stop` on the clock they were computed on.
+ */
+export const START_LEAD = 0.03;
+
+/**
+ * The mute mark's fade, in source seconds: short enough to read as "the sound
+ * stops here", long enough not to click.
+ */
+export const MUTE_FADE = 0.05;
+
+/** One Web Audio render quantum, in frames. */
+export const RENDER_QUANTUM = 128;
+
+const round3 = (n) => Math.round(n * 1000) / 1000;
+
+/**
+ * Which span of a cached window to decode.
+ *
+ * The whole window when it is short enough -- one decode then serves every
+ * drag. A longer one decodes the selection plus a margin each side, clamped to
+ * the window, and capped: a decode is held in memory as 32-bit floats, and two
+ * minutes of stereo is already ~46 MB.
+ *
+ * @param {{from: number, to: number}} win the cached file's span
+ * @param {{from: number, to: number}} want the range about to be played
+ * @param {{max?: number, margin?: number}} [opts]
+ * @returns {{from: number, to: number}}
+ */
+export function decodeSpan(win, want, { max = MAX_DECODE_SPAN, margin = DECODE_MARGIN } = {}) {
+ if (win.to - win.from <= max) return { from: win.from, to: win.to };
+ let from = Math.max(win.from, want.from - margin);
+ let to = Math.min(win.to, Math.max(want.to, want.from) + margin);
+ if (to - from > max) {
+ // A selection wider than the cap: from its start, as much as fits.
+ from = Math.max(win.from, Math.min(want.from, win.to - max));
+ to = Math.min(win.to, from + max);
+ }
+ return { from: round3(from), to: round3(to) };
+}
+
+/**
+ * Does a decoded span hold this whole range? A hair of tolerance, because the
+ * decoded span's end is a frame count divided by a rate.
+ *
+ * @param {{from: number, to: number} | null} span
+ * @param {number} from
+ * @param {number} to
+ */
+export function covers(span, from, to) {
+ if (!span) return false;
+ return from >= span.from - 0.005 && to <= span.to + 0.005;
+}
+
+/**
+ * Where in the buffer to start, how much source to play, and how long that
+ * takes at this rate.
+ *
+ * `duration` is SOURCE seconds and `wall` is how long it lasts at `rate`. The
+ * stop is scheduled at `start + wall` on the context's clock rather than
+ * passed to `start()` as a duration: the spec reads that argument as buffer
+ * content, but a stop time on the context clock means one thing at any rate.
+ *
+ * @param {{from: number, to: number}} span the decoded buffer's absolute span
+ * @param {number} bufferSeconds the buffer's own duration
+ * @param {number} from
+ * @param {number} to
+ * @param {number} [rate]
+ * @returns {{offset: number, duration: number, wall: number} | null} null when
+ * nothing of the range is in the buffer
+ */
+export function bufferSchedule(span, bufferSeconds, from, to, rate = 1) {
+ const r = rate > 0 ? rate : 1;
+ const offset = Math.max(0, Math.min(bufferSeconds, from - span.from));
+ const duration = Math.min(bufferSeconds - offset, to - Math.max(from, span.from));
+ if (!(duration > 0)) return null;
+ return { offset, duration, wall: duration / r };
+}
+
+/**
+ * The playhead, in source seconds, from the audio clock.
+ *
+ * @param {number} from where the playback started, in source seconds
+ * @param {number} t0 the context time it started at
+ * @param {number} now the context time now
+ * @param {number} rate
+ * @param {number} duration source seconds the playback lasts
+ */
+export function playheadAt(from, t0, now, rate, duration) {
+ const r = rate > 0 ? rate : 1;
+ const played = Math.max(0, Math.min(duration, (now - t0) * r));
+ return from + played;
+}
+
+/**
+ * When the mute mark silences a playback of [from, to], as offsets in WALL
+ * seconds from its start.
+ *
+ * null the mark is not in this range (or there is none)
+ * { at: 0, fade: 0 } the range starts at or after the mark: silent
+ * from the first sample, the picture still plays
+ * { at, fade } full level until `at`, then a linear ramp to
+ * nothing over `fade`
+ *
+ * @param {number | null | undefined} muteFrom
+ * @param {number} from
+ * @param {number} to
+ * @param {number} [rate]
+ * @param {number} [fade] source seconds
+ * @returns {{at: number, fade: number} | null}
+ */
+export function muteRamp(muteFrom, from, to, rate = 1, fade = MUTE_FADE) {
+ if (muteFrom == null || !Number.isFinite(muteFrom)) return null;
+ const r = rate > 0 ? rate : 1;
+ if (muteFrom >= to) return null;
+ if (muteFrom <= from) return { at: 0, fade: 0 };
+ return { at: (muteFrom - from) / r, fade: fade / r };
+}
+
+/**
+ * The element fallback's stop test, run once per animation frame.
+ *
+ * Stopping at the first frame PAST the end overruns by up to a frame (and the
+ * element's own clock reports late on top of that). Stopping as soon as the
+ * end is less than half a frame away splits the error both ways instead: never
+ * more than half a frame early or late, at any rate.
+ *
+ * @param {number} t the element's position, in source seconds
+ * @param {number} stopAt
+ * @param {number} [rate]
+ * @param {number} [frame] wall seconds per animation frame
+ */
+export function elementShouldStop(t, stopAt, rate = 1, frame = 1 / 60) {
+ const r = rate > 0 ? rate : 1;
+ return t + (frame * r) / 2 >= stopAt;
+}
+
+/**
+ * A mute mark as the WRITER reads it: a number of source seconds inside the
+ * clip's extent, rounded like an edge -- or `null` to delete the key.
+ *
+ * `null` and `""` mean "no mark". Anything else must be a finite number in
+ * [start, end] (a hair of tolerance, the same 0.02 the cut's check allows).
+ *
+ * @param {unknown} raw
+ * @param {number} start
+ * @param {number} end
+ * @returns {number | null}
+ */
+export function parseMuteFrom(raw, start, end) {
+ if (raw === null || raw === "") return null;
+ const v = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.trim()) : Number.NaN;
+ if (!Number.isFinite(v)) {
+ throw new Error(`muteFrom must be a number of source seconds, or empty to clear it (got \`${String(raw)}\`)`);
+ }
+ if (v < start - 0.02 || v > end + 0.02) {
+ throw new Error(
+ `muteFrom ${v} must lie inside the clip ${start}–${end} — the mark mutes from there to the clip's end`,
+ );
+ }
+ return Math.round(Math.min(end, Math.max(start, v)) * 100) / 100;
+}
+
+/**
+ * A RIFF/WAVE header for interleaved 16-bit PCM.
+ *
+ * Written by hand because ffmpeg writing WAV to a pipe cannot seek back to fill
+ * in the sizes, and a header that says "unknown length" is one more thing a
+ * decoder may or may not forgive.
+ *
+ * @param {{channels: number, sampleRate: number, dataBytes: number}} f
+ * @returns {Uint8Array} 44 bytes
+ */
+export function wavHeader({ channels, sampleRate, dataBytes }) {
+ const b = new Uint8Array(44);
+ const v = new DataView(b.buffer);
+ const tag = (at, s) => {
+ for (let i = 0; i < 4; i += 1) b[at + i] = s.charCodeAt(i);
+ };
+ tag(0, "RIFF");
+ v.setUint32(4, 36 + dataBytes, true);
+ tag(8, "WAVE");
+ tag(12, "fmt ");
+ v.setUint32(16, 16, true);
+ v.setUint16(20, 1, true); // PCM
+ v.setUint16(22, channels, true);
+ v.setUint32(24, sampleRate, true);
+ v.setUint32(28, sampleRate * channels * 2, true);
+ v.setUint16(32, channels * 2, true);
+ v.setUint16(34, 16, true);
+ tag(36, "data");
+ v.setUint32(40, dataBytes, true);
+ return b;
+}
diff --git a/umtool/lib/report/playback.test.mjs b/umtool/lib/report/playback.test.mjs
@@ -0,0 +1,152 @@
+// The clip bench's bounded playback, as arithmetic.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ MAX_DECODE_SPAN,
+ MUTE_FADE,
+ bufferSchedule,
+ covers,
+ decodeSpan,
+ elementShouldStop,
+ muteRamp,
+ parseMuteFrom,
+ playheadAt,
+ wavHeader,
+} from "./playback.mjs";
+
+const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`);
+
+test("decodeSpan: a short window decodes whole, whatever is being played", () => {
+ assert.deepEqual(decodeSpan({ from: 24019.6, to: 24032.6 }, { from: 24022.6, to: 24029.6 }), {
+ from: 24019.6,
+ to: 24032.6,
+ });
+ // Exactly at the cap is still whole.
+ assert.deepEqual(decodeSpan({ from: 0, to: MAX_DECODE_SPAN }, { from: 5, to: 6 }), {
+ from: 0,
+ to: MAX_DECODE_SPAN,
+ });
+});
+
+test("decodeSpan: a long window decodes the selection plus a margin, clamped and capped", () => {
+ const whole = { from: 0, to: 7200 };
+ assert.deepEqual(decodeSpan(whole, { from: 3600, to: 3610 }), { from: 3570, to: 3640 });
+ // Clamped at the recording's start.
+ assert.deepEqual(decodeSpan(whole, { from: 10, to: 20 }), { from: 0, to: 50 });
+ // Clamped at its end.
+ assert.deepEqual(decodeSpan(whole, { from: 7190, to: 7200 }), { from: 7160, to: 7200 });
+ // A selection wider than the cap: from its start, as much as fits.
+ const wide = decodeSpan(whole, { from: 1000, to: 1300 });
+ assert.deepEqual(wide, { from: 1000, to: 1000 + MAX_DECODE_SPAN });
+ // ...and never past the window's end.
+ assert.deepEqual(decodeSpan(whole, { from: 7150, to: 7400 }), { from: 7120, to: 7200 });
+ assert.deepEqual(decodeSpan(whole, { from: 7000, to: 7400 }), { from: 7000, to: 7000 + MAX_DECODE_SPAN });
+});
+
+test("covers: the decoded span holds the whole range, with a hair of tolerance", () => {
+ const span = { from: 10, to: 20 };
+ assert.equal(covers(span, 10, 20), true);
+ assert.equal(covers(span, 12, 19.999), true);
+ assert.equal(covers(span, 9.9, 15), false);
+ assert.equal(covers(span, 15, 20.1), false);
+ assert.equal(covers(null, 1, 2), false);
+});
+
+test("bufferSchedule: offset into the buffer, source seconds to play, and how long that takes", () => {
+ const span = { from: 24019.6, to: 24032.6 };
+ const s = bufferSchedule(span, 13, 24022.6, 24029.6, 1);
+ near(s.offset, 3, 1e-6);
+ near(s.duration, 7, 1e-6);
+ near(s.wall, 7, 1e-6);
+ // Speed changes how long it takes, not what is played.
+ const fast = bufferSchedule(span, 13, 24022.6, 24029.6, 1.5);
+ near(fast.offset, 3, 1e-6);
+ near(fast.duration, 7, 1e-6);
+ near(fast.wall, 7 / 1.5, 1e-6);
+ const slow = bufferSchedule(span, 13, 24022.6, 24029.6, 0.75);
+ near(slow.wall, 7 / 0.75, 1e-6);
+ // A range running past the buffer stops at the buffer.
+ near(bufferSchedule(span, 13, 24030, 24040, 1).duration, 2.6, 1e-6);
+ // Nothing of the range in the buffer.
+ assert.equal(bufferSchedule(span, 13, 24040, 24050, 1), null);
+ // A nonsense rate is read as 1, not as a division by zero.
+ near(bufferSchedule(span, 13, 24022.6, 24029.6, 0).wall, 7, 1e-6);
+});
+
+test("playheadAt: from the audio clock, in source seconds, held at the end", () => {
+ near(playheadAt(100, 5, 5, 1, 4), 100);
+ near(playheadAt(100, 5, 6, 1, 4), 101);
+ near(playheadAt(100, 5, 6, 2, 4), 102);
+ // Before the scheduled start (the lead): at the start, not before it.
+ near(playheadAt(100, 5, 4.98, 1, 4), 100);
+ // Past the end: held at the end.
+ near(playheadAt(100, 5, 60, 1, 4), 104);
+});
+
+test("muteRamp: when the mark silences a playback, in wall seconds from its start", () => {
+ assert.equal(muteRamp(null, 10, 20), null);
+ assert.equal(muteRamp(undefined, 10, 20), null);
+ // At or past the end of the range: nothing to mute in it.
+ assert.equal(muteRamp(20, 10, 20), null);
+ assert.equal(muteRamp(25, 10, 20), null);
+ // At or before the start: silent throughout, the picture still plays.
+ assert.deepEqual(muteRamp(10, 10, 20), { at: 0, fade: 0 });
+ assert.deepEqual(muteRamp(5, 10, 20), { at: 0, fade: 0 });
+ // Inside: full level until the mark, then the fade.
+ const m = muteRamp(16, 10, 20, 1);
+ near(m.at, 6);
+ near(m.fade, MUTE_FADE);
+ // At 2x the mark arrives in half the time, and so does the fade.
+ const f = muteRamp(16, 10, 20, 2);
+ near(f.at, 3);
+ near(f.fade, MUTE_FADE / 2);
+});
+
+test("elementShouldStop: stops when the end is under half a frame away, at any rate", () => {
+ const frame = 1 / 60;
+ assert.equal(elementShouldStop(9.9, 10, 1, frame), false);
+ assert.equal(elementShouldStop(10 - frame / 2 - 0.001, 10, 1, frame), false);
+ assert.equal(elementShouldStop(10 - frame / 2 + 0.001, 10, 1, frame), true);
+ assert.equal(elementShouldStop(10.2, 10, 1, frame), true);
+ // At 2x a frame covers twice the source, so the stop comes a frame earlier.
+ assert.equal(elementShouldStop(10 - frame + 0.001, 10, 2, frame), true);
+});
+
+test("parseMuteFrom: a number inside the clip, rounded like an edge, or null to delete", () => {
+ assert.equal(parseMuteFrom(null, 10, 20), null);
+ assert.equal(parseMuteFrom("", 10, 20), null);
+ assert.equal(parseMuteFrom(15.456, 10, 20), 15.46);
+ assert.equal(parseMuteFrom(" 15.5 ", 10, 20), 15.5);
+ assert.equal(parseMuteFrom(10, 10, 20), 10);
+ assert.equal(parseMuteFrom(20, 10, 20), 20);
+ // The 0.02 tolerance an edge gets, clamped back inside.
+ assert.equal(parseMuteFrom(20.01, 10, 20), 20);
+ assert.equal(parseMuteFrom(9.99, 10, 20), 10);
+ assert.throws(() => parseMuteFrom(20.5, 10, 20), /must lie inside the clip 10–20/);
+ assert.throws(() => parseMuteFrom(9, 10, 20), /must lie inside/);
+ assert.throws(() => parseMuteFrom("soon", 10, 20), /must be a number/);
+ assert.throws(() => parseMuteFrom(true, 10, 20), /must be a number/);
+ assert.throws(() => parseMuteFrom(Number.NaN, 10, 20), /must be a number/);
+});
+
+test("wavHeader: 44 bytes of RIFF/WAVE for interleaved 16-bit PCM", () => {
+ const h = wavHeader({ channels: 2, sampleRate: 48000, dataBytes: 192000 });
+ assert.equal(h.length, 44);
+ const v = new DataView(h.buffer);
+ const tag = (at) => String.fromCharCode(...h.slice(at, at + 4));
+ assert.equal(tag(0), "RIFF");
+ assert.equal(v.getUint32(4, true), 36 + 192000);
+ assert.equal(tag(8), "WAVE");
+ assert.equal(tag(12), "fmt ");
+ assert.equal(v.getUint16(20, true), 1);
+ assert.equal(v.getUint16(22, true), 2);
+ assert.equal(v.getUint32(24, true), 48000);
+ assert.equal(v.getUint32(28, true), 48000 * 4);
+ assert.equal(v.getUint16(32, true), 4);
+ assert.equal(v.getUint16(34, true), 16);
+ assert.equal(tag(36), "data");
+ assert.equal(v.getUint32(40, true), 192000);
+});