Archilyzer · Source

archilyzer

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

commit ac75077edc3893d0689fbc66853f3591d9f7be49
parent 71772a5621457f1b5a85aae8aa3c2377f07d6acb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 02:24:17 -0400

clip bench: play the edge that moved, and let the desk set the speed

Every selection change played `from` to `from + 6`, so nudging the END --
the edge that decides whether a clip stops mid-thought, and the one you move
most -- played the start and answered a question nobody asked. The Waveform
now says WHICH handle moved (optional third argument; the other two callers
are unchanged) and the keyboard knows it already, so a moved end plays the
four seconds ENDING on it and a moved start plays the four after it. `space`
and "play selection" still play the whole window, and the audition is still on
pointer-up rather than during a drag.

Two preferences beside the audition buttons, per browser in localStorage and
nowhere near the manifest: playback speed (0.75-2x, `-` and `=`, applied to
the rendered-segment player too, because listening to the cut at 1.5x and the
segment at 1x is two different clips as far as your ear is concerned), and
auto-audition (off by default, `a`), which plays the clip once on arrival --
waiting for `canplay` rather than racing it, because a play() before the
element is ready is a silent no-op that reads as "the option does not work".
Defaults render on the server and the stored values land in an effect, so a
preference cannot become a hydration mismatch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/components/Waveform.tsx | 14++++++++++++--
Mumtool/components/projects/ClipBench.tsx | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 162 insertions(+), 8 deletions(-)

diff --git a/umtool/components/Waveform.tsx b/umtool/components/Waveform.tsx @@ -18,7 +18,17 @@ type Props = { * twice, and so what has been banked is visible while choosing the next. */ captured?: { from: number; to: number; verdict: number }[]; - onSel: (sel: { from: number; to: number }, dragging: boolean) => void; + /** + * `edge` is WHICH HANDLE moved, and it is not decoration: a caller that + * auditions the change has to play the end when the end moved, or moving the + * end plays the start and tells you nothing. Optional, so the callers that + * do not care are unchanged. + */ + onSel: ( + sel: { from: number; to: number }, + dragging: boolean, + edge?: "start" | "end" | null, + ) => void; /** A handle dragged to the edge asks for more audio on that side. */ onReachEdge: (side: "start" | "end") => void; height?: number; @@ -181,7 +191,7 @@ export default function Waveform({ let next = { ...sel }; if (which === "start") next.from = Math.min(t, sel.to - 0.02); else next.to = Math.max(t, sel.from + 0.02); - onSel(next, dragging); + onSel(next, dragging, which); // Dragged to within 2% of the view edge: ask for another second on that // side. This is what makes the window unbounded in practice -- you pull and diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -177,6 +177,37 @@ const hms = (t: number) => { const round2 = (n: number) => Number(n.toFixed(2)); +// --------------------------------------------------------------------------- +// Playback preferences. +// +// Per BROWSER, never per manifest: how fast somebody listens and whether a +// clip plays itself on arrival are facts about the person at the desk, not +// about the cut. localStorage, read inside a try/catch because a private +// window throws on access rather than returning null. +// --------------------------------------------------------------------------- +const PLAYBACK_KEY = "umtool.bench.playback"; +const RATES = [0.75, 1, 1.25, 1.5, 2] as const; +type Playback = { rate: number; auto: boolean }; +const PLAYBACK_DEFAULT: Playback = { rate: 1, auto: false }; + +const readPlayback = (): Playback => { + try { + const raw = localStorage.getItem(PLAYBACK_KEY); + if (!raw) return PLAYBACK_DEFAULT; + const j = JSON.parse(raw) as Partial<Playback>; + const rate = Number(j.rate); + return { + rate: RATES.includes(rate as (typeof RATES)[number]) ? rate : PLAYBACK_DEFAULT.rate, + auto: !!j.auto, + }; + } catch { + return PLAYBACK_DEFAULT; + } +}; + +/** How much of a moved edge to play: enough to hear the join, not the clip. */ +const EDGE_AUDITION = 4; + export default function ClipBench({ data }: { data: ClipBenchData }) { const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); @@ -194,6 +225,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // it has something in it. An incorrect verdict with no note is a complaint // nobody can act on, so the bench asks for the note before it writes one. const [needNote, setNeedNote] = useState(false); + // Defaults on the server and on the first paint, then whatever this browser + // remembers. Reading localStorage during render would be a hydration + // mismatch on the one prop that changes what you hear. + const [playback, setPlayback] = useState<Playback>(PLAYBACK_DEFAULT); const [segment, setSegment] = useState(data.segment); const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime); @@ -208,6 +243,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { 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); + // One auto-audition per clip, keyed by id: `canplay` fires again after a + // seek, and a clip that replays itself every time you drag is unusable. + const autoPlayed = useRef<string | null>(null); // The widest cached file is the one the bench draws from: it is how much room // there is to drag before anything has to be fetched. @@ -232,18 +271,60 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }; }, [cached, data.project, clip.id]); + // ---- playback preferences ------------------------------------------------ + useEffect(() => setPlayback(readPlayback()), []); + + useEffect(() => { + try { + localStorage.setItem(PLAYBACK_KEY, JSON.stringify(playback)); + } catch { + /* a private window refuses to store; the session still works */ + } + // BOTH players. Listening to the cut at 1.5x and then to the rendered + // segment at 1x is two different clips as far as your ear is concerned. + if (video.current) video.current.playbackRate = playback.rate; + if (segVideo.current) segVideo.current.playbackRate = playback.rate; + }, [playback, segment]); + // ---- audition ----------------------------------------------------------- const play = useCallback( (from: number, to: number) => { const el = video.current; if (!el || !cached) return; el.currentTime = Math.max(0, from - fetchStart); + el.playbackRate = playback.rate; stopAt.current = to; void el.play(); }, - [cached, fetchStart], + [cached, fetchStart, playback.rate], ); + // ---- auto-audition ------------------------------------------------------- + // + // Off by default, and when it is on it waits for the element rather than + // racing it: a play() before `canplay` on a file being range-fetched is a + // silent no-op, which reads as "the option does not work". + useEffect(() => { + if (!playback.auto || !cached) return; + const el = video.current; + if (!el || autoPlayed.current === clip.id) return; + const go = () => { + if (autoPlayed.current === clip.id) return; + autoPlayed.current = clip.id; + play(clip.start, clip.end); + }; + if (el.readyState >= 3) { + go(); + return; + } + el.addEventListener("canplay", go); + el.addEventListener("loadeddata", go); + return () => { + el.removeEventListener("canplay", go); + el.removeEventListener("loadeddata", go); + }; + }, [playback.auto, cached, clip.id, clip.start, clip.end, play]); + useEffect(() => { const el = video.current; if (!el) return; @@ -265,14 +346,21 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // more" button appears; past the source's own duration it stops for good. const clampTo = Math.min(cachedTo || Infinity, data.sourceDuration ?? Infinity); const onSel = useCallback( - (next: { from: number; to: number }, dragging: boolean) => { + (next: { from: number; to: number }, dragging: boolean, edge?: "start" | "end" | null) => { const from = Math.max(fetchStart, next.from); const to = Math.min(clampTo, next.to); setSel({ from, to }); setDirty(true); // Audition on pointer-UP, never during a drag: the Deck's rule, and the // reason is that a sound restarting on every pointermove is unusable. - if (!dragging) play(from, Math.min(to, from + 6)); + if (dragging) return; + // AND PLAY THE EDGE THAT MOVED. Every move used to play the first six + // seconds, so nudging the end -- the edge that decides whether a clip + // stops mid-thought, and the one you move most -- played the start and + // answered a question nobody asked. + if (edge === "end") play(Math.max(from, to - EDGE_AUDITION), to); + else if (edge === "start") play(from, Math.min(to, from + EDGE_AUDITION)); + else play(from, Math.min(to, from + 6)); }, [fetchStart, clampTo, play], ); @@ -402,7 +490,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // ---- keyboard ----------------------------------------------------------- useEffect(() => { const nudge = (which: "from" | "to", by: number) => - onSel(which === "from" ? { ...sel, from: sel.from + by } : { ...sel, to: sel.to + by }, false); + onSel( + which === "from" ? { ...sel, from: sel.from + by } : { ...sel, to: sel.to + by }, + false, + which === "from" ? "start" : "end", + ); + const bumpRate = (by: number) => + setPlayback((pb) => { + const i = RATES.indexOf(pb.rate as (typeof RATES)[number]); + const next = RATES[Math.min(RATES.length - 1, Math.max(0, (i < 0 ? 1 : i) + by))]; + return { ...pb, rate: next }; + }); const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement | null; @@ -443,6 +541,19 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case "X": rejectClip(); break; + // How fast you listen, and whether a clip plays itself on arrival. + case "-": + case "_": + bumpRate(-1); + break; + case "=": + case "+": + bumpRate(1); + break; + case "a": + case "A": + setPlayback((pb) => ({ ...pb, auto: !pb.auto })); + break; default: return; } @@ -729,9 +840,41 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </Link> )} </span> + {/* ---- how you listen ---- + Per browser, not per cut: `localStorage`, defaults on the + server, and nothing here reaches the manifest. */} + <span className="flex items-center gap-1.5"> + <span className="micro">speed</span> + <select + data-playback-rate="" + aria-label="playback speed" + value={playback.rate} + onChange={(e) => setPlayback((pb) => ({ ...pb, rate: Number(e.target.value) }))} + className="rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-1 py-0.5 text-[11px]" + > + {RATES.map((r) => ( + <option key={r} value={r}> + {r}× + </option> + ))} + </select> + <button + type="button" + data-auto-audition={playback.auto ? "on" : "off"} + aria-pressed={playback.auto} + className={buttonVariants({ + variant: playback.auto ? "primary" : "outline", + size: "sm", + })} + onClick={() => setPlayback((pb) => ({ ...pb, auto: !pb.auto }))} + > + auto-audition {playback.auto ? "on" : "off"} + </button> + </span> <span className="micro"> - <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end · <kbd>space</kbd>{" "} - audition · <kbd>R</kbd> reset — shift for 0.5s + <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 </span> {busy && <span className="text-[var(--color-meter)]">{busy}</span>} {note && ( @@ -875,6 +1018,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { <div className="space-y-2"> {segSrc ? ( <video + ref={segVideo} data-testid="segment-video" src={segSrc} className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"