From 4069b2b7b8920d0ba6e5788e4aceab8a7837b8ee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:57:51 -0700 Subject: [PATCH 1/3] Tweak voice diagrams: aligned loss, click-to-seek - TTS: define the loss by media timestamp so both transports lose the same words, and lengthen it to "Obviously. Did you". - STT: extend the loss to cover "of". - Replace the scrub bar and speed toggle with click-to-seek on the tracks; keep a pause button. Co-Authored-By: Claude Opus 5.5 --- src/components/voice/diagram.tsx | 57 ++++++++++++++------------------ src/components/voice/sim.ts | 10 +++++- src/components/voice/stt.tsx | 2 +- src/components/voice/tts.tsx | 9 +++-- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/src/components/voice/diagram.tsx b/src/components/voice/diagram.tsx index fc83a19..c2cb48b 100644 --- a/src/components/voice/diagram.tsx +++ b/src/components/voice/diagram.tsx @@ -1,9 +1,9 @@ import { type Accessor, createSignal, createUniqueId, For, onCleanup, onMount, Show } from "solid-js"; -import { FLIGHT, FRAME, fill, has, head, type Loss, type Packet, type Segment, type Word } from "./sim"; +import { FLIGHT, FRAME, fill, has, head, type Loss, type Packet, reach, type Segment, type Word } from "./sim"; // Every drawn element can be swapped for a hand-drawn image (PNG/SVG under /public). // Sizes are in viewBox units, where the diagram is 600 wide; draw at 4x for crisp output. -// An array of images cycles at FPS, like hand-drawn animation. Frames follow the clock, so scrubbing works. +// An array of images cycles at FPS, like hand-drawn animation. Frames follow the clock. export type Sprite = string | string[]; export interface Art { @@ -48,7 +48,7 @@ export interface Props { } const HOLD = 2.5; // seconds to linger on the final frame before looping -const SPEEDS = [0.5, 1, 0.25]; +const SPEED = 0.5; // half speed, so the packets are easy to follow const W = 600; const GUTTER = 44; @@ -72,7 +72,6 @@ const COLOR = { export function Diagram(props: Props) { const [t, setT] = createSignal(0); const [playing, setPlaying] = createSignal(false); - const [speed, setSpeed] = createSignal(SPEEDS[0]); const now = () => Math.min(t(), props.end); const id = createUniqueId(); @@ -90,7 +89,7 @@ export function Diagram(props: Props) { const dt = (time - last) / 1000; last = time; if (playing()) { - const next = t() + dt * speed(); + const next = t() + dt * SPEED; setT(next > props.end + HOLD ? 0 : next); } frame = requestAnimationFrame(tick); @@ -136,6 +135,10 @@ export function Diagram(props: Props) { panel={panel} t={now} x={x} + seek={(frac, segments) => { + const at = reach(segments, frac * props.axis); + if (at !== undefined) setT(at); + }} art={props.art ?? {}} hatch={`url(#${id}-lost)`} wobble={props.art?.wobble ? `url(#${id}-wobble)` : undefined} @@ -145,10 +148,10 @@ export function Diagram(props: Props) { -
+
- { - setPlaying(false); - setT(e.currentTarget.valueAsNumber); - }} - aria-label="Time" - /> - - {now().toFixed(1)}s -
- -
{props.legend.done} {props.legend.have} waiting for retransmit @@ -214,6 +192,7 @@ function Swatch(props: { fill?: string; stroke?: string; children: string }) { interface ViewProps { t: Accessor; x: (ts: number) => number; + seek: (frac: number, segments: Segment[]) => void; // jump to when a track reaches `frac` of the way along art: Art; hatch: string; wobble?: string; @@ -314,6 +293,20 @@ function TrackView(props: ViewProps & { track: Track; y: number; notes: "above" stroke-width="2" /> + {/* Click a track to jump to when it reaches that point. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: a mouse shortcut; the animation plays on its own and has a pause button. */} + { + const r = e.currentTarget.getBoundingClientRect(); + props.seek((e.clientX - r.left) / r.width, props.track.segments); + }} + /> ts < s.ts + FRAME); + if (s?.lost !== undefined) return s.lost; + if (s?.start === undefined || s.done === undefined) return undefined; + return s.start + ((s.done - s.start) * Math.max(0, ts - s.ts)) / FRAME; +} + function round(n: number): number { return Math.round(n * 1000) / 1000; } diff --git a/src/components/voice/stt.tsx b/src/components/voice/stt.tsx index b634cb7..fdf0a3c 100644 --- a/src/components/voice/stt.tsx +++ b/src/components/voice/stt.tsx @@ -12,7 +12,7 @@ const WORDS = [ ]; const AXIS = 3.4; -const LOSS = { start: 1.3, end: 2.1 }; +const LOSS = { start: 1.3, end: 2.4 }; // How much faster than real-time the model can chew through buffered audio. // Real models are much faster; 4x keeps the catch-up visible. diff --git a/src/components/voice/tts.tsx b/src/components/voice/tts.tsx index 02fef66..2367314 100644 --- a/src/components/voice/tts.tsx +++ b/src/components/voice/tts.tsx @@ -14,7 +14,8 @@ const WORDS = [ ]; const AXIS = 4.3; -const LOSS = { start: 1.0, end: 1.8 }; +// The same stretch of audio is lost on both transports, by media timestamp, so they lose the same words. +const LOST = { start: 0.7, end: 2.5 }; const GENERATE = 10; // the model speaks 10x faster than real-time const THROUGHPUT = 3; // MoQ sends as fast as the (congested) network allows @@ -26,7 +27,9 @@ const generated = (ts: number) => (ts + FRAME) / GENERATE; function panel(moq: boolean): Panel { // WebRTC is a real-time transport: audio goes out at human speed, no matter how fast it was made. const send = moq ? (ts: number) => Math.max(generated(ts), ts / THROUGHPUT) : (ts: number) => ts + FRAME; - const packets = transmit(TS, send, LOSS, moq); + // Sending is monotonic, so the outage is the wall-clock window those timestamps went out in. + const loss = { start: send(LOST.start), end: send(LOST.end) }; + const packets = transmit(TS, send, loss, moq); const pace = moq ? FRAME / THROUGHPUT : FRAME; const spoken: Segment[] = packets.map((p) => ({ @@ -70,7 +73,7 @@ function panel(moq: boolean): Panel { top: { who: "ai", words: WORDS, segments: spoken, note: moq ? undefined : queued }, bottom: { who: "you", words: WORDS, segments: heard, note: buffered }, packets, - loss: LOSS, + loss, result: moq ? { at: finished + 0.2, text: "🧑 “Rude, but fair.”", good: true } : { at: finished + 0.2, text: "🧑 “Sorry, you broke up. Can you repeat that?”", good: false }, From 01d350d3f6e9f6a242363d5a09e82ebe31095613 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 16:12:05 -0700 Subject: [PATCH 2/3] Keyboard seeking for the voice diagrams Wrap the SVG in a focusable role="slider": arrows step 0.1s, Home/End jump to either end, and seeking by keyboard pauses playback. Clicking a track remains the mouse path. Co-Authored-By: Claude Opus 5.5 --- src/components/voice/diagram.tsx | 100 ++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/src/components/voice/diagram.tsx b/src/components/voice/diagram.tsx index c2cb48b..ebdbac7 100644 --- a/src/components/voice/diagram.tsx +++ b/src/components/voice/diagram.tsx @@ -49,6 +49,7 @@ export interface Props { const HOLD = 2.5; // seconds to linger on the final frame before looping const SPEED = 0.5; // half speed, so the packets are easy to follow +const STEP = 0.1; // seconds per arrow key const W = 600; const GUTTER = 44; @@ -108,45 +109,72 @@ export function Diagram(props: Props) { const x = (ts: number) => GUTTER + (ts / props.axis) * TRACK_W; + // The keyboard path for seeking: arrows step, Home/End jump. Clicking a track is the mouse path. + const key = (e: KeyboardEvent) => { + const target = { + ArrowLeft: now() - STEP, + ArrowDown: now() - STEP, + ArrowRight: now() + STEP, + ArrowUp: now() + STEP, + Home: 0, + End: props.end, + }[e.key]; + if (target === undefined) return; + e.preventDefault(); + setPlaying(false); + setT(Math.max(0, Math.min(props.end, target))); + }; + return (
- - - - - - - - - - - - - {(panel, i) => ( - - { - const at = reach(segments, frac * props.axis); - if (at !== undefined) setT(at); - }} - art={props.art ?? {}} - hatch={`url(#${id}-lost)`} - wobble={props.art?.wobble ? `url(#${id}-wobble)` : undefined} - /> - - )} - - + +