diff --git a/src/components/voice/diagram.tsx b/src/components/voice/diagram.tsx index fc83a19..a193cd0 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,8 @@ 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 STEP = 0.1; // seconds per arrow key const W = 600; const GUTTER = 44; @@ -72,7 +73,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 +90,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); @@ -109,46 +109,77 @@ 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) => ( - - - - )} - - + +
-
+
- { - setPlaying(false); - setT(e.currentTarget.valueAsNumber); - }} - aria-label="Time" - /> - - {now().toFixed(1)}s -
- -
{props.legend.done} {props.legend.have} waiting for retransmit @@ -214,6 +220,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; @@ -271,7 +278,9 @@ function PanelView(props: ViewProps & { panel: Panel }) { {p.result.text} - {(href) => } + + {(href) => } + ); } @@ -314,6 +323,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: the mouse path; the slider handles the keyboard. */} + { + 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 },