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:
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"