anvilsign in

collin/strudel-claude

pre-demo / src / broadcast.js
1// Pushes what the editor is playing to the backend's /ws hub, so other clients
2// (the terminal visualiser in tui/strudel-tui.py, mainly) can draw the same
3// code with the same events lighting up.
4//
5// What goes over the wire:
6// code — the source, whenever it changes
7// chat — the transcript, whenever it changes
8// haps — the *upcoming* window of events, twice a second
9// and in the other direction, the only thing a viewer may send:
10// prompt — a message for Claude, handed to the same code path as the
11// composer in the page, so a terminal turn and a browser turn
12// are indistinguishable afterwards.
13//
14// Deliberately not a per-frame feed, for two reasons.
15//
16// The first is the link: each `haps` message says where the playhead was
17// (`now`, in cycles) and how fast it moves (`cps`), and every hap carries its
18// own begin/end, so a viewer advances its own clock and redraws at whatever
19// rate suits it — smooth over a connection that would make 60 messages a
20// second jitter, and cheap over a tunnel.
21//
22// The second is that the browser tab will be in the background. You background
23// it *because* you are watching the terminal. Strudel's own highlighting runs
24// on requestAnimationFrame, which a hidden tab does not fire at all, so
25// anything hung off the draw loop goes silent exactly when it is needed. This
26// polls the scheduler instead. Hidden tabs also clamp timers to ~1s, which
27// costs nothing here: a lookahead measured in seconds of music covers the gap,
28// and the viewer interpolates across it.
29
30const SEND_MS = 500;
31// Seconds of music each message covers. Comfortably more than the ~1s a
32// background tab will clamp our interval to.
33const LOOKAHEAD_S = 2.5;
34
35// Reconnect backoff: quick at first (a dev-server restart), then patient.
36const RETRY_MS = [500, 1000, 2000, 5000, 10_000];
37
38function wsUrl() {
39 const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
40 return `${proto}//${location.host}/ws?role=producer`;
41}
42
43export function createBroadcaster(editor, { getChat, onPrompt } = {}) {
44 let ws = null;
45 let attempt = 0;
46 let closed = false;
47 // Whether this tab currently holds the producer slot. Assume yes until the
48 // hub says otherwise: a hub that never sends `standby` (an older one) should
49 // get a page that talks, not one that waits forever for permission.
50 let live = true;
51 const last = { code: null, transport: null, chat: null };
52
53 function connect() {
54 if (closed) return;
55 try {
56 ws = new WebSocket(wsUrl());
57 } catch {
58 return schedule();
59 }
60 ws.onopen = () => {
61 attempt = 0;
62 live = true; // the hub re-decides on every connection
63
64 // A reconnected hub knows nothing about us; re-announce the state it
65 // replays to viewers who join later.
66 last.code = last.transport = last.chat = null;
67 tick();
68 };
69 ws.onclose = () => {
70 ws = null;
71 schedule();
72 };
73 ws.onerror = () => ws?.close();
74 ws.onmessage = (ev) => {
75 let msg;
76 try {
77 msg = JSON.parse(ev.data);
78 } catch {
79 return;
80 }
81 // The hub only ever forwards `prompt` to us, but check anyway rather
82 // than trusting the far end of a socket to have stayed honest.
83 if (msg.type === 'prompt' && typeof msg.text === 'string' && msg.text.trim()) {
84 onPrompt?.(msg.text.trim());
85 } else if (msg.type === 'standby') {
86 // Another tab is the live source. Go quiet rather than fight over it:
87 // two tabs both insisting is what made the terminal flip between them.
88 live = false;
89 } else if (msg.type === 'active') {
90 live = true;
91 last.code = last.transport = last.chat = null;
92 tick();
93 } else if (msg.type === 'refresh') {
94 // A viewer joined. Forget what we have already said so the next tick
95 // re-sends all of it — otherwise a session that hasn't changed since
96 // the last viewer left would leave this one with a blank screen.
97 last.code = last.transport = last.chat = null;
98 tick();
99 }
100 };
101 }
102
103 function schedule() {
104 if (closed) return;
105 setTimeout(connect, RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)]);
106 }
107
108 function send(obj) {
109 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
110 }
111
112 // Only when it changed — the poll runs regardless, and viewers do not need
113 // the same source text and transcript twice a second.
114 function sendOnce(key, obj) {
115 const json = JSON.stringify(obj);
116 if (json === last[key]) return;
117 last[key] = json;
118 if (ws?.readyState === WebSocket.OPEN) ws.send(json);
119 }
120
121 function tick() {
122 if (!live || ws?.readyState !== WebSocket.OPEN) return;
123 const scheduler = editor.repl?.scheduler;
124 const cps = scheduler?.cps ?? 0.5;
125 const playing = Boolean(scheduler?.started);
126
127 sendOnce('code', { type: 'code', code: editor.code ?? '' });
128 sendOnce('transport', { type: 'transport', playing, cps });
129 // Only role and content: `codeBefore` and `code` on a turn are there for
130 // revert and for Claude's own context, and would multiply the size of a
131 // message sent on every edit for something no viewer displays.
132 if (getChat) {
133 sendOnce('chat', {
134 type: 'chat',
135 turns: getChat().map(({ role, content }) => ({ role, content })),
136 });
137 }
138 if (!playing || !scheduler.pattern) return;
139
140 const now = scheduler.now();
141 const lookahead = Math.max(LOOKAHEAD_S * cps, 0.5);
142 let haps;
143 try {
144 // From a cycle back, not from `now`: a sustained note that began before
145 // this window is still sounding, and a viewer that only heard about
146 // events with an onset inside the window would drop its highlight.
147 haps = scheduler.pattern.queryArc(now - 1, now + lookahead);
148 } catch {
149 return; // mid-eval, or a pattern that can't be queried — try again next tick
150 }
151
152 const out = [];
153 for (const hap of haps) {
154 const locs = hap.context?.locations;
155 if (!locs?.length || !hap.whole) continue;
156 const e = hap.whole.end.valueOf();
157 if (e <= now) continue; // already over
158 out.push({
159 b: hap.whole.begin.valueOf(),
160 e,
161 // Character offsets into the same source we send as `code`, so a
162 // viewer highlights exactly the characters strudel.cc would flash.
163 l: locs.map(({ start, end }) => [start, end]),
164 s: labelOf(hap.value),
165 c: hap.value?.color,
166 });
167 }
168 send({ type: 'haps', now, cps, haps: out });
169 }
170
171 connect();
172 const timer = setInterval(tick, SEND_MS);
173
174 return {
175 // A line of feedback for the terminal — why a prompt was ignored, mostly.
176 // It reaches every viewer, which is fine for something this rare.
177 notice(text) {
178 send({ type: 'notice', text });
179 },
180
181 close() {
182 closed = true;
183 clearInterval(timer);
184 ws?.close();
185 },
186 };
187}
188
189// A short human label for the event — the sound or note name if there is one,
190// so the terminal can print what is actually sounding, not a value dump.
191function labelOf(value) {
192 if (value == null) return '';
193 if (typeof value !== 'object') return String(value);
194 const v = value.s ?? value.sound ?? value.note ?? value.n ?? value.value;
195 return v == null ? '' : String(v);
196}