| 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 | |
| 30 | const SEND_MS = 500; |
| 31 | // Seconds of music each message covers. Comfortably more than the ~1s a |
| 32 | // background tab will clamp our interval to. |
| 33 | const LOOKAHEAD_S = 2.5; |
| 34 | |
| 35 | // Reconnect backoff: quick at first (a dev-server restart), then patient. |
| 36 | const RETRY_MS = [500, 1000, 2000, 5000, 10_000]; |
| 37 | |
| 38 | function wsUrl() { |
| 39 | const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| 40 | return `${proto}//${location.host}/ws?role=producer`; |
| 41 | } |
| 42 | |
| 43 | export 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. |
| 191 | function 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 | } |