Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aumtool/app/api/report/audio/route.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/clip/route.ts | 1+
Mumtool/app/api/report/window/route.ts | 4++++
Mumtool/lib/report/manifest.mjs | 24++++++++++++++++++++++++
Mumtool/lib/report/manifest.test.mjs | 36++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/playback.mjs | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/playback.test.mjs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +});