Archilyzer · Source

archilyzer

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

commit 7085e0db117c4329ddae47acc3bd804eef870489
parent 5f42c57ecb7910e5b4eb427371bd86edab88955f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 15:00:24 -0400

Merge deck/bench-b1 (slice B1) — the clip bench plays every bounded range through Web Audio from a server-side ffmpeg decode (stops on the scheduled end: 0 of 20 trials off, was ~230 ms over), the mute mark (m / pick on waveform / nudge / clear) saved through the window route, updateClip muteFrom; e2e 97/97; reviewed

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/components/projects/ClipBench.tsx | 789++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ClipBenchPage.tsx | 5+++++
Mumtool/e2e/clip-bench.spec.ts | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1574 insertions(+), 52 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/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -11,6 +11,16 @@ import { buttonVariants } from "@/components/ui/button"; // would only find out twenty minutes into a build. import { attributionLine } from "umtool-report-to-video/attribution"; import { + MUTE_FADE, + START_LEAD, + bufferSchedule, + covers, + decodeSpan, + elementShouldStop, + muteRamp, + playheadAt, +} from "@/lib/report/playback.mjs"; +import { DeckFrame, NeutralFrame, composePreview, @@ -100,6 +110,12 @@ type Clip = { cutEnd: number | null; /** The cut is deliberate; `resolve-windows --cut-to-quote` leaves it alone. */ lockCut: boolean; + /** + * The MUTE MARK, in source seconds: from here to the end of the clip the + * sound fades out and the picture plays on. Set by ear, at the last silence + * before a finale's ending sound. Absent means the clip plays with its sound. + */ + muteFrom: number | null; /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */ verdict: "confirmed" | "incorrect" | null; /** What the on-screen panel says over this clip. Absent means the auto text. */ @@ -180,6 +196,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ cutStart: e.cutStart == null ? null : Number(e.cutStart), cutEnd: e.cutEnd == null ? null : Number(e.cutEnd), lockCut: !!e.lockCut, + muteFrom: e.muteFrom == null ? null : Number(e.muteFrom), verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null, onscreen: (e.onscreen as Onscreen | undefined) ?? null, }); @@ -303,6 +320,62 @@ const clock = (t: number) => { return `${Math.floor(m / 60) > 0 ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}` : m}:${String(s % 60).padStart(2, "0")}`; }; +/** What `save window` (and a confirmation that moved something) writes. */ +type WindowPatch = { + start: number; + end: number; + cutStart?: string; + cutEnd?: string; + /** A number to set the mark, "" to clear it; absent leaves it alone. */ + muteFrom?: number | string; +}; + +/** A decoded span of the cached window, placed on the source clock. */ +type Decoded = { name: string; span: { from: number; to: number }; buf: AudioBuffer }; + +/** + * What is playing, and what the last playback measured. + * + * On the page as data attributes (`data-play-*`), because "did it stop where + * the selection ends" is the claim this bench now makes, and a spec has to be + * able to check it against the audio clock rather than against a feeling. + */ +type PlayInfo = { + engine: "webaudio" | "element"; + state: "playing" | "stopped"; + from: number; + /** The scheduled end, in source seconds. */ + to: number; + rate: number; + /** Context clock: when the source starts and when it is told to stop. */ + ctxStart: number | null; + ctxStop: number | null; + /** + * Where the playback had reached, in source seconds, when the page heard it + * end: the element's own position at its pause, or the context clock at + * `ended` -- which reaches the page a task later than the sound stopped, so + * for Web Audio it is an upper bound and `ctxStop` is the stop itself. + */ + endedAt: number | null; +}; + +/** The playback in flight. One at a time: a new one stops the last. */ +type Session = { + gen: number; + engine: "webaudio" | "element"; + node: AudioBufferSourceNode | null; + gain: GainNode | null; + raf: number; + timers: number[]; + from: number; + to: number; + rate: number; + t0: number; + ctxStop: number; + /** The element's own mute, put back when the picture stops following. */ + mutedBefore: boolean; +}; + export default function ClipBench({ data }: { data: ClipBenchData }) { const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); @@ -310,6 +383,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [proposed, setProposed] = useState(data.proposed); const [view, setView] = useState(data.view); const [sel, setSel] = useState({ from: data.clip.start, to: data.clip.end }); + // The mute mark as drafted. Like the edges it is unsaved until `save window` + // or `y`, and it is what the bench's own playback mutes at -- so a mark is + // heard before it is written. + const [mute, setMute] = useState<number | null>(data.clip.muteFrom); + // Armed: the next click on the waveform places the mark instead of moving + // an edge. + const [mutePick, setMutePick] = useState(false); const [peaks, setPeaks] = useState<Peaks | null>(null); // The cues AROUND the cached window: what is coming, read before paying for // the media. One request, widened only when somebody asks. @@ -319,6 +399,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [cutScore, setCutScore] = useState<number | null>(null); const [peekPad, setPeekPad] = useState(PEEK_STEP); const [playhead, setPlayhead] = useState<number | null>(null); + const playheadRef = useRef<number | null>(null); + useEffect(() => { + playheadRef.current = playhead; + }, [playhead]); const [note, setNote] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null); const [dirty, setDirty] = useState(false); @@ -356,7 +440,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const token = useRef(data.token); // And they must not interleave: same read-modify-write, one clip. const saving = useRef<Promise<boolean>>(Promise.resolve(true)); - const stopAt = useRef<number | null>(null); // `x` answers "no" by putting the cursor in the note, which is the answer. const correctionBox = useRef<HTMLTextAreaElement | null>(null); const segVideo = useRef<HTMLVideoElement | null>(null); @@ -532,20 +615,366 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }, [playback, segment, cached, renderedOpen, deckPreview]); // ---- audition ----------------------------------------------------------- - const play = useCallback( - (from: number, to: number) => { + // + // EVERY BOUNDED RANGE PLAYS FROM DECODED AUDIO. A selection, an edge, a line + // of the transcript, the auto-audition: each is an AudioBufferSourceNode + // started at an exact offset and stopped at an exact time on the audio + // clock, so what you hear ends where the selection ends, to the sample. The + // <video> used to play these itself and was stopped from `timeupdate`, which + // fires every 15-250 ms -- the stop overran by up to a quarter of a second, + // and by a different amount every time, which is exactly what makes a cut + // between two words impossible to judge by ear. (The old HTML chooser had + // this right; it is the same technique.) + // + // The picture FOLLOWS, muted, and the playhead is read off the audio clock. + // When the decode fails the element plays instead, stopped per animation + // frame by remaining time, and the bench says so. + + /** The decoded audio for the cached window: one decode per window. */ + const decoded = useRef<Decoded | null>(null); + const decoding = useRef<{ key: string; p: Promise<Decoded> } | null>(null); + const actx = useRef<AudioContext | null>(null); + const session = useRef<Session | null>(null); + const playGen = useRef(0); + // Pauses the bench asked for itself and has not yet heard the event of. A + // `pause` event is a task, so it arrives AFTER the next playback has started: + // without the count, stopping one playback to start the next would read as + // somebody pausing the picture, and stop the new one. + const selfPauses = useRef(0); + const pauseVideo = useCallback((el: HTMLVideoElement) => { + if (el.paused) return; + selfPauses.current += 1; + el.pause(); + }, []); + // Read by play() at the moment it schedules, so a mark moved since the last + // render is the one you hear -- without re-creating play() on every nudge. + const muteRef = useRef<number | null>(data.clip.muteFrom); + const [audio, setAudio] = useState<{ state: "idle" | "loading" | "ready" | "failed"; why?: string }>({ + state: "idle", + }); + const [playInfo, setPlayInfo] = useState<PlayInfo | null>(null); + + /** + * The cached window's sound over (at least) `want`, decoded once. + * + * Through /api/report/audio -- ffmpeg's decode of the same file the player + * loads, as PCM -- and decoded here on an OfflineAudioContext, so no audio + * output is opened before somebody asks to hear something. A buffer belongs + * to no context and plays in the real one. + */ + const ensureDecoded = useCallback( + async (want: { from: number; to: number }): Promise<Decoded> => { + if (!cached) throw new Error("nothing is cached for this clip"); + const have = decoded.current; + if (have && have.name === cached.name && covers(have.span, want.from, want.to)) return have; + const span = decodeSpan({ from: cached.from, to: cached.to }, want); + const key = `${cached.name}|${span.from}|${span.to}`; + if (decoding.current?.key === key) return decoding.current.p; + const p = (async () => { + setAudio({ state: "loading" }); + const r = await fetch( + `/api/report/audio?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}` + + `&file=${encodeURIComponent(cached.name)}&from=${span.from}&to=${span.to}`, + ); + if (!r.ok) { + const j = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(j?.error ?? `the audio route answered ${r.status}`); + } + const from = Number(r.headers.get("x-audio-from") ?? span.from); + const bytes = await r.arrayBuffer(); + const Offline = + typeof window !== "undefined" ? (window.OfflineAudioContext ?? null) : null; + if (!Offline) throw new Error("this browser has no Web Audio"); + const buf = await new Offline(2, 1, 48000).decodeAudioData(bytes); + const d: Decoded = { name: cached.name, span: { from, to: from + buf.duration }, buf }; + decoded.current = d; + setAudio({ state: "ready" }); + return d; + })(); + decoding.current = { key, p }; + p.catch((e: unknown) => { + if (decoding.current?.p === p) decoding.current = null; + setAudio({ state: "failed", why: e instanceof Error ? e.message : String(e) }); + }); + return p; + }, + [cached, data.project, clip.id], + ); + + // A different window -- a wider one after "fetch more", or the first after a + // fetch -- is different audio: drop the old decode and start the new one + // now, so the first play after it does not wait. + const cachedName = cached?.name ?? null; + useEffect(() => { + decoded.current = null; + decoding.current = null; + setAudio({ state: "idle" }); + if (!cachedName) return; + ensureDecoded({ from: clip.start, to: clip.end }).catch(() => { + /* said in the bench; play() falls back to the element */ + }); + // ensureDecoded changes identity with `cached`, which is this effect's + // whole subject; keying on the NAME is what stops a refresh() that returns + // the same window from decoding it again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cachedName]); + + /** Stop whatever is playing, Web Audio or element, and let go of it. */ + const stopPlayback = useCallback(() => { + const s = session.current; + if (!s) return; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + if (s.node) { + s.node.onended = null; + try { + s.node.stop(); + } catch { + /* never started, or already stopped */ + } + s.node.disconnect(); + s.gain?.disconnect(); + } + const el = video.current; + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayInfo((pi) => (pi && pi.state === "playing" ? { ...pi, state: "stopped" } : pi)); + }, [pauseVideo]); + + /** + * The fallback: the element, stopped per animation frame by REMAINING TIME. + * + * `timeupdate` was the old stop and is the overrun this replaces; a frame + * tick that stops once the end is under half a frame away is the best the + * element can do, and the bench says that this is what is playing. + */ + const playElement = useCallback( + (from: number, to: number, gen: number) => { const el = video.current; if (!el || !cached) return; + const rate = playback.rate; el.currentTime = Math.max(0, from - fetchStart); - el.playbackRate = playback.rate; - stopAt.current = to; + el.playbackRate = rate; + const s: Session = { + gen, + engine: "element", + node: null, + gain: null, + raf: 0, + timers: [], + from, + to, + rate, + t0: 0, + ctxStop: 0, + mutedBefore: el.muted, + }; + session.current = s; + setPlayInfo({ engine: "element", state: "playing", from, to, rate, ctxStart: null, ctxStop: null, endedAt: null }); + const stopNow = () => { + if (session.current !== s) return; + pauseVideo(el); + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + el.muted = s.mutedBefore; + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: el.currentTime + fetchStart } : pi)); + }; + const tick = () => { + if (session.current !== s) return; + const t = el.currentTime + fetchStart; + setPlayhead(t); + // The mark, as near as a frame tick gets it. + const m = muteRef.current; + el.muted = s.mutedBefore || (m != null && t >= m); + if (!el.paused && elementShouldStop(t, to, rate)) { + stopNow(); + return; + } + // Inside the last few frames, a timer for the REMAINING time: it lands + // between frames, where the next tick would land up to a frame late. + const remaining = (to - t) / rate; + if (!el.paused && remaining < 0.1 && !s.timers.length) { + s.timers.push(window.setTimeout(stopNow, remaining * 1000)); + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); // A rejected play() is normal, not a bug: Chrome refuses unmuted audio on // a document nobody has interacted with (a typed URL, a fresh tab), and // an unhandled rejection in that case would be noise. The seek has // already happened either way. void el.play().catch(() => {}); }, - [cached, fetchStart, playback.rate], + [cached, fetchStart, playback.rate, pauseVideo], + ); + + const play = useCallback( + async (from: number, to: number) => { + const gen = (playGen.current += 1); + stopPlayback(); + if (!cached || !(to > from)) return; + const rate = playback.rate; + // The context is opened HERE, inside the click or the key that asked: + // that gesture is what lets it start. + let ac = actx.current; + if (!ac && typeof window !== "undefined" && window.AudioContext) { + ac = new window.AudioContext(); + actx.current = ac; + } + const resumed = ac && ac.state !== "running" ? ac.resume().catch(() => {}) : null; + let d: Decoded; + try { + if (!ac) throw new Error("this browser has no Web Audio"); + d = await ensureDecoded({ from, to }); + } catch { + if (gen === playGen.current) playElement(from, to, gen); + return; + } + if (gen !== playGen.current) return; + // Longer than one decode: the element, rather than a second decode in + // the middle of a click. + if (!covers(d.span, from, to)) { + playElement(from, to, gen); + return; + } + if (resumed) await Promise.race([resumed, new Promise((res) => setTimeout(res, 300))]); + // No gesture has ever reached this document, so the context may not + // start: the same refusal a muted-autoplay policy gives the element, and + // just as silent. + if (gen !== playGen.current || ac!.state !== "running") return; + const ctx = ac!; + const sch = bufferSchedule(d.span, d.buf.duration, from, to, rate); + if (!sch) return; + + const node = ctx.createBufferSource(); + node.buffer = d.buf; + node.playbackRate.value = rate; + const gain = ctx.createGain(); + node.connect(gain); + gain.connect(ctx.destination); + // Ahead of now, so start and stop stay on the clock they were computed + // on: a start in the past begins late at the SAME offset. + const t0 = ctx.currentTime + START_LEAD; + const ctxStop = t0 + sch.wall; + const m = muteRamp(muteRef.current, from, from + sch.duration, rate); + if (m) { + if (m.at <= 0) gain.gain.setValueAtTime(0, t0); + else { + gain.gain.setValueAtTime(1, t0 + m.at); + gain.gain.linearRampToValueAtTime(0, t0 + m.at + m.fade); + } + } + // The stop is a TIME on the context's clock, not start()'s duration + // argument: that one is buffer content, and a clock time means the same + // thing at every speed. + node.start(t0, sch.offset); + node.stop(ctxStop); + + const el = video.current; + const s: Session = { + gen, + engine: "webaudio", + node, + gain, + raf: 0, + timers: [], + from, + to: from + sch.duration, + rate, + t0, + ctxStop, + mutedBefore: el?.muted ?? false, + }; + session.current = s; + setPlayInfo({ + engine: "webaudio", + state: "playing", + from, + to: s.to, + rate, + ctxStart: t0, + ctxStop, + endedAt: null, + }); + + // The picture follows, muted. It is not what is being judged; a few + // milliseconds of drift between the two is not worth a sync loop. + if (el) { + el.muted = true; + el.playbackRate = rate; + el.currentTime = Math.max(0, from - fetchStart); + s.timers.push( + window.setTimeout(() => { + if (session.current === s) void el.play().catch(() => {}); + }, START_LEAD * 1000), + ); + } + + node.onended = () => { + if (session.current !== s) return; + // How late the context clock reads at `ended`, mapped back onto the + // source: what the playback measured, for the bench's own record. + const late = ctx.currentTime - ctxStop; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + node.disconnect(); + gain.disconnect(); + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayhead(s.to); + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: s.to + Math.max(0, late) * rate } : pi)); + }; + + // The playhead at ~30 Hz, not every frame: each update re-renders the + // bench, and the line moving smoothly is not worth that at 60. + // + // And the picture is pulled back to the sound when it drifts by more + // than a quarter second -- which it does after a seek into a file that + // is only partly loaded, where the element starts late by however long + // the bytes took. + let painted = -1; + let checked = t0; + const tick = () => { + if (session.current !== s) return; + const now = ctx.currentTime; + const pos = playheadAt(from, t0, now, rate, sch.duration); + if (now - painted >= 1 / 30) { + painted = now; + setPlayhead(pos); + } + if (el && now - checked >= 0.5 && now > t0) { + checked = now; + const want = pos - fetchStart; + if (!el.seeking && Math.abs(el.currentTime - want) > 0.25) el.currentTime = want; + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); + }, + [cached, fetchStart, playback.rate, ensureDecoded, playElement, stopPlayback, pauseVideo], + ); + + // A speed change mid-playback would leave the picture and the sound at two + // rates: stop, and the next play is at the new one. + useEffect(() => { + stopPlayback(); + }, [playback.rate, stopPlayback]); + + // Let go of the audio output with the bench. + useEffect( + () => () => { + stopPlayback(); + void actx.current?.close().catch(() => {}); + actx.current = null; + }, + [stopPlayback], ); // ---- auto-audition ------------------------------------------------------- @@ -579,20 +1008,40 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }; }, [playback.auto, cached, clip.id, clip.start, clip.end, play]); + // The element's own controls: unbounded "play from here", which stays on the + // element. Its clock moves the playhead only while nothing bounded is + // playing -- during a Web Audio playback the muted picture would otherwise + // fight the audio clock for it. Pausing the picture by hand stops the sound + // it is following. useEffect(() => { const el = video.current; if (!el) return; + // While it plays by itself. A paused element's `timeupdate` is the one its + // own pause fires -- after a Web Audio playback, that is the muted picture + // stopping wherever it had got to, which is not where the sound stopped. const tick = () => { - const t = el.currentTime + fetchStart; - setPlayhead(t); - if (stopAt.current != null && t >= stopAt.current) { - el.pause(); - stopAt.current = null; + if (!session.current && !el.paused) setPlayhead(el.currentTime + fetchStart); + }; + // A scrub on the paused element's own bar. + const seeked = () => { + if (!session.current) setPlayhead(el.currentTime + fetchStart); + }; + const paused = () => { + if (selfPauses.current > 0) { + selfPauses.current -= 1; + return; } + if (session.current) stopPlayback(); }; el.addEventListener("timeupdate", tick); - return () => el.removeEventListener("timeupdate", tick); - }, [fetchStart]); + el.addEventListener("seeked", seeked); + el.addEventListener("pause", paused); + return () => { + el.removeEventListener("timeupdate", tick); + el.removeEventListener("seeked", seeked); + el.removeEventListener("pause", paused); + }; + }, [fetchStart, stopPlayback, cachedName]); // ---- the selection ------------------------------------------------------ // @@ -655,6 +1104,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { setSel({ from: next.start, to: next.end }); setDirty(false); } + // The mark goes back to what was STORED -- rounded, or cleared because + // the window no longer held it. + if (patch.muteFrom !== undefined) { + setMute(next.muteFrom); + muteRef.current = next.muteFrom; + } // Re-sync only the fields this save carried, and from what the writer // actually stored -- which is trimmed, rounded, or gone. setDraft((d) => { @@ -745,13 +1200,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // longer contains, so the rule lives here rather than in each of them. const windowMoved = Math.abs(round2(sel.from) - clip.start) > 0.02 || Math.abs(round2(sel.to) - clip.end) > 0.02; + // The mark moved, set or cleared since the last save. + const muteMoved = + (mute == null) !== (clip.muteFrom == null) || + (mute != null && clip.muteFrom != null && Math.abs(round2(mute) - clip.muteFrom) > 0.005); + const unsaved = dirty || muteMoved; - const windowPatch = useCallback((): { - start: number; - end: number; - cutStart?: string; - cutEnd?: string; - } => { + const windowPatch = useCallback((): WindowPatch => { const start = round2(sel.from); const end = round2(sel.to); const cutOutside = @@ -761,8 +1216,16 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // The extent is the judgement being made right now; the cut was derived // from a wider one and is no longer inside it. Clearing it in the SAME // patch is what keeps the writer's rule and the screen agreeing. - return cutOutside ? { start, end, cutStart: "", cutEnd: "" } : { start, end }; - }, [sel.from, sel.to, clip.cutStart, clip.cutEnd]); + const out: WindowPatch = cutOutside + ? { start, end, cutStart: "", cutEnd: "" } + : { start, end }; + // The mute mark rides the same patch, by the same rule: a mark the new + // extent does not hold is cleared rather than refused. + const markOutside = mute != null && (mute < start - 0.02 || mute > end + 0.02); + if (markOutside) out.muteFrom = ""; + else if (muteMoved) out.muteFrom = mute == null ? "" : round2(mute); + return out; + }, [sel.from, sel.to, clip.cutStart, clip.cutEnd, mute, muteMoved]); // ---- the walk's verdict --------------------------------------------------- // @@ -776,7 +1239,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // is a judgement about THAT window, and the advance would otherwise walk // away from it -- so the edges go in the SAME patch as the verdict rather // than needing `save window` pressed first. One write, one token. - const win = windowMoved ? windowPatch() : null; + const win = windowMoved || muteMoved ? windowPatch() : null; // A note survives a confirmation. It stops being a complaint and becomes // what it now says it is: why this clip is here in the shape it is in. const ok = await save({ verdict: "confirmed", ...(win ?? {}) }); @@ -788,7 +1251,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { : "saved — the window you moved was saved with it", ); if (ok && data.next) router.push(`/browse/${data.project}/clip/${data.next}`); - }, [save, router, data.project, data.next, windowMoved, windowPatch]); + }, [save, router, data.project, data.next, windowMoved, muteMoved, windowPatch]); const rejectClip = useCallback(() => { setNeedNote(true); @@ -807,6 +1270,76 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { el.setSelectionRange(el.value.length, el.value.length); }, [clip, save]); + // ---- the mute mark --------------------------------------------------------- + // + // A finale's last clip plays its picture to the end, but its ending sound is + // not wanted: `muteFrom` fades the sound out from a source second to the end + // of the clip. It is set BY EAR, at the last silence before that sound, so it + // is placed the ways an edge is -- at the playhead, by a click on the + // waveform, by a nudge -- and every playback here mutes at it, so a mark is + // heard before it is saved. + + /** Re-aim the playback in flight at a new mark, from now. */ + const retargetMute = useCallback((t: number | null) => { + const s = session.current; + const ctx = actx.current; + if (!s || s.engine !== "webaudio" || !s.gain || !ctx) return; + const now = ctx.currentTime; + const g = s.gain.gain; + g.cancelScheduledValues(now); + const pos = playheadAt(s.from, s.t0, now, s.rate, s.to - s.from); + const m = t == null ? null : muteRamp(t, pos, s.to, s.rate); + if (!m) { + g.setValueAtTime(1, now); + return; + } + if (m.at <= 0) { + // Already past the mark: fade out from here. + g.setValueAtTime(g.value, now); + g.linearRampToValueAtTime(0, now + MUTE_FADE / s.rate); + return; + } + g.setValueAtTime(1, now); + g.setValueAtTime(1, now + m.at); + g.linearRampToValueAtTime(0, now + m.at + m.fade); + }, []); + + /** + * Put the mark at `t`, inside the selection. `audition` plays across it -- + * three seconds of sound and two of what should now be silence -- the way a + * moved edge plays the edge it moved. + */ + const placeMute = useCallback( + (t: number, audition: boolean) => { + const at = round2(Math.min(sel.to, Math.max(sel.from, t))); + setMute(at); + muteRef.current = at; + setMutePick(false); + if (audition) void play(Math.max(sel.from, at - 3), Math.min(sel.to, at + 2)); + else retargetMute(at); + }, + [sel.from, sel.to, play, retargetMute], + ); + + const clearMute = useCallback(() => { + setMute(null); + muteRef.current = null; + setMutePick(false); + retargetMute(null); + }, [retargetMute]); + + /** `m`: where you are listening. Anywhere outside the selection is refused. */ + const muteAtPlayhead = useCallback(() => { + // Through a ref: the playhead moves every frame while something plays, + // and a callback keyed on it would re-bind the keyboard every frame. + const at = playheadRef.current; + if (at == null || at < sel.from - 0.02 || at > sel.to + 0.02) { + setNote("the playhead is not inside the selection — play to the spot, or pick it on the waveform"); + return; + } + placeMute(at, false); + }, [sel.from, sel.to, placeMute]); + // ---- keyboard ----------------------------------------------------------- useEffect(() => { const nudge = (which: "from" | "to", by: number) => @@ -841,12 +1374,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case ">": nudge("to", step); break; case " ": e.preventDefault(); - play(sel.from, sel.to); + void play(sel.from, sel.to); break; case "r": case "R": setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; break; // Walking the cut. Reviewing a whole video is nineteen clips in a row, // and going back to the project page between each one is nineteen round @@ -882,6 +1417,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case "A": choosePlayback((pb) => ({ ...pb, auto: !pb.auto })); break; + // The mute mark: `m` at the playhead, `M` clears it, and `;` `'` move + // it like an edge (shift for 0.5 s), each playing across it. + case "m": + muteAtPlayhead(); + break; + case "M": + clearMute(); + break; + case ";": + case ":": + case "'": + case '"': + if (mute == null) { + setNote("no mute mark yet — m sets one at the playhead"); + break; + } + placeMute(mute + (e.key === ";" || e.key === ":" ? -step : step), true); + break; + case "Escape": + if (!mutePick) return; + setMutePick(false); + break; default: return; } @@ -893,6 +1450,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { sel, clip.start, clip.end, + clip.muteFrom, onSel, play, router, @@ -902,6 +1460,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { confirmClip, rejectClip, choosePlayback, + mute, + mutePick, + muteAtPlayhead, + clearMute, + placeMute, ]); const refresh = useCallback(async (): Promise<ClipBenchData | null> => { @@ -1129,10 +1692,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const saveWindow = useCallback(() => { const patch = windowPatch(); void save(patch).then((ok) => { - if (ok && patch.cutStart !== undefined) - setNote("saved — the cut no longer fitted this window and was cleared"); + if (!ok) return; + const cleared = [ + patch.cutStart !== undefined ? "the cut" : null, + patch.muteFrom === "" && mute != null ? "the mute mark" : null, + ].filter(Boolean); + if (cleared.length) setNote(`saved — ${cleared.join(" and ")} no longer fitted this window and ${cleared.length > 1 ? "were" : "was"} cleared`); }); - }, [windowPatch, save]); + }, [windowPatch, save, mute]); // ---- the warnings -------------------------------------------------------- const endCue = cues.find((c) => sel.to >= c.start - 0.02 && sel.to <= c.end + 0.02) ?? null; @@ -1373,29 +1940,38 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { unsaved — was {hms(clip.start)} – {hms(clip.end)} </span> )} + {muteMoved && ( + <span data-mute-unsaved="" className="num text-[var(--color-dirty)]"> + mute mark unsaved — was {clip.muteFrom == null ? "none" : hms(clip.muteFrom)} + </span> + )} <span className="flex flex-wrap items-center gap-1.5"> <button type="button" + data-save-window="" className={buttonVariants({ variant: "primary", size: "sm" })} - disabled={!dirty || !!busy} + disabled={!unsaved || !!busy} onClick={saveWindow} > save window </button> <button type="button" + data-play-selection="" className={buttonVariants({ size: "sm" })} - onClick={() => play(sel.from, sel.to)} + onClick={() => void play(sel.from, sel.to)} > play selection </button> <button type="button" className={buttonVariants({ size: "sm" })} - disabled={!dirty} + disabled={!unsaved} onClick={() => { setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; }} > reset @@ -1446,7 +2022,41 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { > auto-audition {playback.auto ? "on" : "off"} </button> + {/* What is playing, and what it measured. The attributes are the + bench's record of its last playback, on the audio clock -- + the claim is "stops where the selection ends", and this is + where it can be checked. */} + <span + data-playback="" + data-audio-state={audio.state} + data-play-engine={playInfo?.engine ?? ""} + data-play-state={playInfo?.state ?? ""} + data-play-from={playInfo?.from ?? ""} + data-play-to={playInfo?.to ?? ""} + data-play-rate={playInfo?.rate ?? ""} + data-play-ctx-start={playInfo?.ctxStart ?? ""} + data-play-ctx-stop={playInfo?.ctxStop ?? ""} + data-play-ended-at={playInfo?.endedAt ?? ""} + className="micro" + title={ + audio.state === "failed" + ? undefined + : "every bounded playback is the decoded audio, started and stopped on the audio clock: it ends where the selection ends, to the sample" + } + > + {audio.state === "ready" + ? "exact playback" + : audio.state === "loading" + ? "decoding the audio…" + : null} + </span> </span> + {audio.state === "failed" && ( + <span data-playback-fallback="" className="text-[11px] text-[var(--color-dirty)]"> + exact playback unavailable — {audio.why}. The video plays instead, stopped by the + frame: up to half a frame either side of the end. + </span> + )} {/* ---- EXTENT above, CUT here ---- The window row says how much of the recording is worth having. This says what will actually play, and offers to derive it @@ -1493,10 +2103,60 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </button> )} </span> + {/* ---- the MUTE MARK ---- + Beside the cut because it is the same kind of decision -- what + the finished clip does with its seconds -- and drafted like the + edges: heard at once, written by `save window` or `y`. */} + <span + data-mute={mute ?? ""} + className="flex flex-wrap items-center gap-1.5" + title="From the mark to the end of the clip the sound fades out and the picture plays on. Set it at the last silence before the ending sound." + > + {mute != null ? ( + <span data-mute-from={mute} className="num text-[var(--color-text)]"> + mute from {hms(mute)}{" "} + <span className="text-[var(--color-dim)]"> + ({Math.max(0, sel.to - mute).toFixed(2)}s silent to the end) + </span> + </span> + ) : ( + <span className="text-[var(--color-dim)]">sound to the end</span> + )} + <button + type="button" + data-mute-set="" + className={buttonVariants({ size: "sm" })} + title="put the mute mark at the playhead" + onClick={muteAtPlayhead} + > + mute from here + </button> + <button + type="button" + data-mute-pick={mutePick ? "armed" : ""} + aria-pressed={mutePick} + className={buttonVariants({ variant: mutePick ? "primary" : "outline", size: "sm" })} + title="the next click on the waveform places the mute mark (Esc cancels)" + onClick={() => setMutePick((v) => !v)} + > + {mutePick ? "click the waveform…" : "pick on waveform"} + </button> + {mute != null && ( + <button + type="button" + data-mute-clear="" + className={buttonVariants({ size: "sm" })} + onClick={clearMute} + > + clear mute + </button> + )} + </span> <span className="micro"> <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end — each plays the edge it moved · <kbd>space</kbd> the whole selection · <kbd>R</kbd> reset · <kbd>-</kbd>{" "} - <kbd>=</kbd> speed · <kbd>a</kbd> auto — shift for 0.5s + <kbd>=</kbd> speed · <kbd>a</kbd> auto · <kbd>m</kbd> mute from the playhead,{" "} + <kbd>;</kbd> <kbd>&apos;</kbd> move it, <kbd>M</kbd> clear — shift for 0.5s </span> {busy && <span className="text-[var(--color-meter)]">{busy}</span>} {note && ( @@ -1507,19 +2167,58 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </div> {/* ---- the instrument ---- */} - <Waveform - view={view} - sel={sel} - cand={{ from: clip.start, to: clip.end }} - words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} - peaks={peaks} - playhead={playhead} - height={104} - onSel={onSel} - onReachEdge={() => { - /* widening is a FETCH here, not a redraw -- see the button below */ - }} - /> + <div className="relative"> + <Waveform + view={view} + sel={sel} + cand={{ from: clip.start, to: clip.end }} + words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} + peaks={peaks} + playhead={playhead} + height={104} + onSel={onSel} + onReachEdge={() => { + /* widening is a FETCH here, not a redraw -- see the button below */ + }} + /> + {/* The mute mark over the waveform: a line where the sound stops + and a hatch over what is silent to the end of the selection. + In the interaction colour, like the handles -- it is an edit, + not a reading -- and never in the way of a drag. */} + {mute != null && mute >= view.from && mute <= view.to && ( + <> + <div + data-mute-span="" + className="pointer-events-none absolute top-0 h-full [background:repeating-linear-gradient(135deg,color-mix(in_srgb,var(--color-dim)_22%,transparent)_0_3px,transparent_3px_7px)]" + style={{ + left: pct(mute), + width: `calc(${pct(Math.max(mute, sel.to))} - ${pct(mute)})`, + }} + /> + <div + data-mute-marker={mute} + className="pointer-events-none absolute top-0 h-full border-l-2 border-dashed border-[var(--color-sel)]" + style={{ left: pct(mute) }} + > + <span className="absolute left-1 top-0.5 rounded bg-[var(--color-panel-2)] px-1 font-mono text-[9px] leading-tight text-[var(--color-sel)]"> + mute + </span> + </div> + </> + )} + {/* Armed: the next click places the mark rather than an edge. */} + {mutePick && ( + <div + data-mute-pick-layer="" + className="absolute inset-0 cursor-crosshair rounded-md outline outline-1 outline-[var(--color-sel)]" + onPointerDown={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / Math.max(1, r.width))); + placeMute(view.from + frac * span, true); + }} + /> + )} + </div> {/* Offered at the edge of the cache, as always -- and also whenever the rail has something to read past it. Having just read the next @@ -1767,7 +2466,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { if (!atMaxPad) void fetchMore(padsForCue(c)); return; } - play(c.start, Math.min(c.end + 4, view.to)); + void play(c.start, Math.min(c.end + 4, view.to)); }} className={`flex w-full gap-2 px-2 py-1 text-left hover:bg-[color-mix(in_srgb,var(--color-sel)_10%,transparent)] ${ inSel ? "bg-[color-mix(in_srgb,var(--color-sel)_14%,transparent)]" : "" diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -88,6 +88,11 @@ export default async function ClipBenchPage({ cutStart: entry.cutStart ?? null, cutEnd: entry.cutEnd ?? null, lockCut: !!entry.lockCut, + // The mute mark, from the manifest's own entry: readClipDetail's rows + // carry the fields it reads, and this is not one of them. + muteFrom: + ((manifest.timeline ?? []) as { id: string; muteFrom?: number }[]).find((e) => e.id === clipId) + ?.muteFrom ?? null, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, // From the manifest's own entry: what the on-screen panel says over this diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts @@ -57,6 +57,7 @@ const readClipIn = (project: string, id: string) => { verdict?: string; cutStart?: number; cutEnd?: number; + muteFrom?: number; }[]; }; return m.timeline.find((e) => e.id === id)!; @@ -804,13 +805,266 @@ test("moving the end auditions the END", async ({ page }) => { await page.locator("body").press("."); // Clamped to the cached file, which ends at 9.00 -- a drag never downloads. const to = Math.min(before.end + 0.05, 9); - const t = await page - .getByTestId("clip-video") - .evaluate((el: HTMLVideoElement) => el.currentTime); - // vid1_0.00-9.00 starts at 0.00, so file time IS source time here. The four - // seconds ENDING on the new edge, not the four after the start. - expect(t).toBeGreaterThan(to - 4 - 0.4); - expect(t).toBeLessThan(to - 4 + 2); + // The four seconds ENDING on the new edge, not the four after the start -- + // read from the bench's own record of what it scheduled, on the audio clock. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(to, 2); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(to - 4, 2); + // And the picture follows it there, muted: vid1_0.00-9.00 starts at 0.00, + // so file time IS source time. + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => el.currentTime)) + .toBeGreaterThan(to - 4 - 0.4); +}); + +// --------------------------------------------------------------------------- +// Exact playback. +// +// Every bounded range plays from the DECODED audio with Web Audio, started and +// stopped on the audio clock. The element it replaces was stopped from +// `timeupdate`, which overran the end by up to a quarter of a second -- the +// difference between a cut between two words and one into the next. +// +// The proof is the signal, not the bench's word for it: TAP (below) is an +// AudioWorklet put between the bench's audio and the speakers that records the +// first and last NON-ZERO frame it is handed, on the context's own frame +// clock. The bench publishes when it scheduled the stop (`data-play-ctx-stop`); +// the two must agree to within one render quantum (128 frames). +// +// vid1 is a 440 Hz tone with silences at 2.9-3.1 and 5.9-6.1, so a selection +// ending at 5.00 ends INSIDE the tone: the last sound is the stop, not a +// silence that happened to come first. +// --------------------------------------------------------------------------- + +const TAP = `(() => { + const code = \`class Tap extends AudioWorkletProcessor { + constructor() { super(); this.first = -1; this.last = -1; + this.port.onmessage = (e) => { + if (e.data === "reset") { this.first = -1; this.last = -1; } + if (e.data === "read") this.port.postMessage({ first: this.first, last: this.last, sr: sampleRate }); + }; + } + process(inputs) { + const ch = inputs[0] && inputs[0][0]; + if (ch) for (let i = 0; i < ch.length; i += 1) if (ch[i] !== 0) { + const f = currentFrame + i; if (this.first < 0) this.first = f; this.last = f; + } + return true; + } + } + registerProcessor("tap", Tap);\`; + const url = URL.createObjectURL(new Blob([code], { type: "application/javascript" })); + const Orig = window.AudioContext; + if (!Orig) return; + const conn = AudioNode.prototype.connect; + window.__tapRead = () => new Promise((res) => { + const n = window.__tapNode; + if (!n) return res(null); + n.port.onmessage = (e) => res(e.data); + n.port.postMessage("read"); + }); + window.__tapReset = () => window.__tapNode && window.__tapNode.port.postMessage("reset"); + window.AudioContext = class extends Orig { + constructor(...a) { + super(...a); + const ctx = this; + ctx.audioWorklet.addModule(url).then(() => { + const n = new AudioWorkletNode(ctx, "tap", { outputChannelCount: [1] }); + conn.call(n, ctx.destination); + ctx.__tap = n; + window.__tapNode = n; + for (const src of ctx.__pending || []) conn.call(src, n); + ctx.__pending = []; + }); + } + }; + AudioNode.prototype.connect = function (dest, ...rest) { + const r = conn.call(this, dest, ...rest); + const tap = this.context && this.context.__tap; + if (dest === this.context.destination && this !== tap) { + if (tap) conn.call(this, tap); + else (this.context.__pending = this.context.__pending || []).push(this); + } + return r; + }; +})();`; + +type Tap = { first: number; last: number; sr: number } | null; +const tapRead = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapRead: () => Promise<Tap> }).__tapRead()); +const tapReset = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapReset: () => void }).__tapReset()); + +/** The bench's record of its last playback, as numbers. */ +const lastPlay = async (page: import("@playwright/test").Page) => { + const p = page.locator("[data-playback]"); + const n = async (k: string) => Number(await p.getAttribute(`data-play-${k}`)); + return { + engine: await p.getAttribute("data-play-engine"), + from: await n("from"), + to: await n("to"), + rate: await n("rate"), + ctxStart: await n("ctx-start"), + ctxStop: await n("ctx-stop"), + }; +}; + +/** Start a playback with `go` and wait until the bench says it has stopped. */ +const playThrough = async (page: import("@playwright/test").Page, go: () => Promise<void>) => { + const p = page.locator("[data-playback]"); + await tapReset(page); + await go(); + await expect(p).toHaveAttribute("data-play-state", "playing", { timeout: 10_000 }); + await expect(p).toHaveAttribute("data-play-state", "stopped", { timeout: 20_000 }); + // `ended` reaches the page a task after the audio thread stopped; the tap's + // reply is one more message behind it. + await page.waitForTimeout(100); + return { play: await lastPlay(page), tap: await tapRead(page) }; +}; + +/** Put c01 back to its fixture window with no mute mark. */ +const resetC01 = async (request: import("@playwright/test").APIRequestContext) => { + const { token: t } = await token(request, "c01"); + const r = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", start: 3, end: 6, muteFrom: "", token: t }, + }); + expect(r.ok()).toBeTruthy(); +}; + +test("a play-selection stops within one audio render quantum of its end, at any speed", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-audio-state", "ready", { + timeout: 15_000, + }); + await keyboardLive(page); + // 6.00 -> 5.00 (shift-, is half a second): the end now lies in the tone. + // Each press auditions the end it moved, which also opens the audio output + // and puts the tap in the path. + await page.locator("body").press("Shift+Comma"); + await page.locator("body").press("Shift+Comma"); + await expect + .poll(async () => Number(await page.locator("[data-playback]").getAttribute("data-play-to")), { + timeout: 10_000, + }) + .toBeCloseTo(5, 3); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-state", "stopped", { + timeout: 10_000, + }); + + for (const rate of ["1", "2"]) { + await page.locator("[data-playback-rate]").selectOption(rate); + const { play, tap } = await playThrough(page, () => page.locator("[data-play-selection]").click()); + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(3, 3); + expect(play.to).toBeCloseTo(5, 3); + expect(play.rate).toBe(Number(rate)); + expect(tap, "the tap saw the bench's audio").not.toBeNull(); + const { first, last, sr } = tap!; + expect(last).toBeGreaterThan(first); + const quantum = 128; + // The STOP: the last sound is the scheduled stop, to within a quantum. + const stopFrame = Math.round(play.ctxStop * sr); + expect(Math.abs(last + 1 - stopFrame), `stopped ${last + 1 - stopFrame} frames from the end at ${rate}x`).toBeLessThanOrEqual(quantum); + // And nothing sounded before the scheduled start. (Not "the first sound + // IS the start": 3.00 is inside the silence at 2.9-3.1, so the first + // non-zero frame is the tone coming back.) + expect(first).toBeGreaterThanOrEqual(Math.round(play.ctxStart * sr) - quantum); + } +}); + +test("the mute mark: picked on the waveform, heard at once, saved, shown, cleared", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-audio-state", "ready", { timeout: 15_000 }); + await expect(page.locator("[data-mute]")).toHaveAttribute("data-mute", ""); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + + // Armed, the next click on the waveform places the mark: at 5.00 of the + // cached 0.00-9.00, the tone between the two silences. + await page.locator("[data-mute-pick]").click(); + await expect(page.locator("[data-mute-pick=armed]")).toBeVisible(); + const layer = page.locator("[data-mute-pick-layer]"); + const box = (await layer.boundingBox())!; + const { play, tap } = await playThrough(page, () => + page.mouse.click(box.x + (box.width * 5) / 9, box.y + box.height / 2), + ); + const marker = page.locator("[data-mute-marker]"); + await expect(marker).toBeVisible(); + const mark = Number(await marker.getAttribute("data-mute-marker")); + expect(mark).toBeGreaterThan(4.9); + expect(mark).toBeLessThan(5.1); + await expect(page.locator("[data-mute-pick-layer]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toContainText("was none"); + + // The pick plays ACROSS the mark -- up to three seconds before it (here + // from the selection's start), to the end of the selection -- and the sound + // goes at the mark: the last non-zero frame is where the fade (0.05 s) runs + // out, not the 6.00 the tone runs on to. + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(Math.max(3, mark - 3), 2); + expect(play.to).toBeCloseTo(6, 2); + const { last, sr } = tap!; + const silentAt = play.ctxStart + (mark - play.from) + 0.05; + expect(Math.abs((last + 1) / sr - silentAt), "silent at the mark, not at the end").toBeLessThan(0.01); + + // Saved by `save window`, like the edges; the manifest has it. + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeCloseTo(mark, 2); + await expect(page.locator("[data-mute-unsaved]")).toHaveCount(0); + + // A reload shows the saved mark. + await page.reload(); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute("data-mute-marker", String(readClip("c01").muteFrom)); + await expect(page.locator("[data-mute-from]")).toBeVisible(); + + // Moved like an edge, and cleared. + await keyboardLive(page); + await page.locator("body").press("Shift+Semicolon"); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute( + "data-mute-marker", + String(Number((readClip("c01").muteFrom! - 0.5).toFixed(2))), + ); + await page.locator("[data-mute-clear]").click(); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toBeVisible(); + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeUndefined(); + + // The writer's rule, through the route: inside the clip, or refused. + const { token: t } = await token(request, "c01"); + const out = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", muteFrom: 8, token: t }, + }); + expect(out.status()).toBe(400); + expect(((await out.json()) as { error: string }).error).toMatch(/must lie inside the clip 3–6/); +}); + +test("a decode that fails falls back to the element, and the bench says so", async ({ page }) => { + await page.route("**/api/report/audio**", (r) => + r.fulfill({ status: 422, json: { error: "this file has no audio track" } }), + ); + await page.goto(bench("c01")); + const fallback = page.locator("[data-playback-fallback]"); + await expect(fallback).toBeVisible({ timeout: 15_000 }); + await expect(fallback).toContainText("this file has no audio track"); + await page.locator("[data-play-selection]").click(); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-engine", "element"); + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused)) + .toBe(true); }); test("the playback speed is this browser's, and it survives a reload", async ({ page }) => { @@ -841,6 +1095,11 @@ test("auto-audition plays the clip you walk onto", async ({ page, request }) => await page.locator("[data-clip-nav=next]").click(); await expect(page.locator("[data-bench=c04]")).toBeVisible(); + // The whole clip, from the decoded audio -- and the picture with it. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(15, 2); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(18, 2); await expect .poll( () => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused), 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); +});