Mercurial
comparison dictation/web/dictation.js @ 280:49e9e591c9bb
Add persistent dictation, prewarmed WebRTC speech input, Copilot SDK routing, animated conversation lifecycle controls, parking, and architecture coverage.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 18 Aug 2026 19:14:53 -0700 |
| parents | 78699f810817 |
| children |
comparison
equal
deleted
inserted
replaced
| 279:b3b547563ec7 | 280:49e9e591c9bb |
|---|---|
| 7 let peer = null; | 7 let peer = null; |
| 8 let channel = null; | 8 let channel = null; |
| 9 let stream = null; | 9 let stream = null; |
| 10 let sessionId = null; | 10 let sessionId = null; |
| 11 let stopResolver = null; | 11 let stopResolver = null; |
| 12 let captureActive = false; | |
| 13 let connectionPromise = null; | |
| 14 const canvasTransport = | |
| 15 new URLSearchParams(window.location.search).get("canvas") === "1"; | |
| 16 | |
| 17 function publishDictation(kind, text) { | |
| 18 const payload = { | |
| 19 type: "zenbu.dictation.event", | |
| 20 kind, | |
| 21 text, | |
| 22 }; | |
| 23 window.parent?.postMessage(payload, "*"); | |
| 24 document.title = | |
| 25 `Zenbu Dictation|${Date.now()}|${kind}|${encodeURIComponent(text)}`; | |
| 26 } | |
| 12 | 27 |
| 13 function setStatus(message, state = "") { | 28 function setStatus(message, state = "") { |
| 14 status.textContent = message; | 29 status.textContent = message; |
| 15 status.dataset.state = state; | 30 status.dataset.state = state; |
| 31 publishDictation("status", message); | |
| 16 } | 32 } |
| 17 | 33 |
| 18 function waitForIceGathering(connection) { | 34 function waitForIceGathering(connection) { |
| 19 if (connection.iceGatheringState === "complete") return Promise.resolve(); | 35 if (connection.iceGatheringState === "complete") return Promise.resolve(); |
| 20 return new Promise(resolve => { | 36 return new Promise(resolve => { |
| 25 }; | 41 }; |
| 26 connection.addEventListener("icegatheringstatechange", listener); | 42 connection.addEventListener("icegatheringstatechange", listener); |
| 27 }); | 43 }); |
| 28 } | 44 } |
| 29 | 45 |
| 46 function waitForTranscriptChannel(transcriptChannel) { | |
| 47 if (transcriptChannel.readyState === "open") return Promise.resolve(); | |
| 48 return new Promise((resolve, reject) => { | |
| 49 const timeout = window.setTimeout( | |
| 50 () => reject(new Error("Transcript channel readiness timed out")), | |
| 51 10000, | |
| 52 ); | |
| 53 transcriptChannel.addEventListener("open", () => { | |
| 54 window.clearTimeout(timeout); | |
| 55 resolve(); | |
| 56 }, { once: true }); | |
| 57 transcriptChannel.addEventListener("close", () => { | |
| 58 window.clearTimeout(timeout); | |
| 59 reject(new Error("Transcript channel closed during startup")); | |
| 60 }, { once: true }); | |
| 61 }); | |
| 62 } | |
| 63 | |
| 30 function handleTranscript(message) { | 64 function handleTranscript(message) { |
| 31 let event; | 65 let event; |
| 32 try { | 66 try { |
| 33 event = JSON.parse(message.data); | 67 event = JSON.parse(message.data); |
| 34 } catch { | 68 } catch { |
| 35 setStatus("Received an invalid server event", "error"); | 69 setStatus("Received an invalid server event", "error"); |
| 36 return; | 70 return; |
| 37 } | 71 } |
| 38 switch (event.type) { | 72 switch (event.type) { |
| 39 case "ready": | 73 case "ready": |
| 40 setStatus("Listening", "ready"); | 74 setStatus(captureActive ? "Listening" : "Idle", "ready"); |
| 41 break; | 75 break; |
| 42 case "speech.started": | 76 case "speech.started": |
| 43 setStatus("Speech detected", "working"); | 77 setStatus("Speech detected", "working"); |
| 44 break; | 78 break; |
| 45 case "transcript.partial": | 79 case "transcript.partial": |
| 46 partial.textContent = event.text; | 80 partial.textContent = event.text; |
| 81 publishDictation("partial", event.text); | |
| 47 break; | 82 break; |
| 48 case "transcript.final": | 83 case "transcript.final": |
| 49 finalText.value += `${finalText.value ? " " : ""}${event.text}`; | 84 finalText.value += `${finalText.value ? " " : ""}${event.text}`; |
| 85 publishDictation("final", event.text); | |
| 50 partial.textContent = "Waiting for speech..."; | 86 partial.textContent = "Waiting for speech..."; |
| 51 break; | 87 break; |
| 52 case "speech.ended": | 88 case "speech.ended": |
| 53 setStatus("Listening", "ready"); | 89 setStatus(captureActive ? "Listening" : "Idle", "ready"); |
| 54 stopResolver?.(); | 90 stopResolver?.(); |
| 55 stopResolver = null; | 91 stopResolver = null; |
| 56 break; | 92 break; |
| 57 case "error": | 93 case "error": |
| 58 setStatus(event.message || "Transcription failed", "error"); | 94 setStatus(event.message || "Transcription failed", "error"); |
| 59 break; | 95 break; |
| 60 } | 96 } |
| 61 } | 97 } |
| 62 | 98 |
| 63 async function start() { | 99 async function createConnection() { |
| 64 if (peer) return; | 100 if (peer && !["closed", "failed"].includes(peer.connectionState)) return; |
| 65 startButton.disabled = true; | 101 if (!stream) { |
| 66 setStatus("Requesting microphone access...", "working"); | |
| 67 try { | |
| 68 stream = await navigator.mediaDevices.getUserMedia({ | 102 stream = await navigator.mediaDevices.getUserMedia({ |
| 69 audio: { | 103 audio: { |
| 70 channelCount: 1, | 104 channelCount: 1, |
| 71 echoCancellation: true, | 105 echoCancellation: true, |
| 72 noiseSuppression: true, | 106 noiseSuppression: true, |
| 73 }, | 107 }, |
| 74 video: false, | 108 video: false, |
| 75 }); | 109 }); |
| 110 } | |
| 111 for (const track of stream.getTracks()) track.enabled = false; | |
| 112 try { | |
| 76 peer = new RTCPeerConnection(); | 113 peer = new RTCPeerConnection(); |
| 77 channel = peer.createDataChannel("transcripts", { ordered: true }); | 114 channel = peer.createDataChannel("transcripts", { ordered: true }); |
| 78 channel.addEventListener("message", handleTranscript); | 115 channel.addEventListener("message", handleTranscript); |
| 79 channel.addEventListener("close", () => { | 116 channel.addEventListener("close", () => { |
| 80 if (peer) setStatus("Transcript channel closed", "error"); | 117 if (peer) setStatus("Transcript channel closed", "error"); |
| 81 }); | 118 }); |
| 82 peer.addEventListener("connectionstatechange", () => { | 119 peer.addEventListener("connectionstatechange", () => { |
| 83 if (peer?.connectionState === "failed") { | 120 if (peer?.connectionState === "failed") { |
| 84 setStatus("WebRTC connection failed", "error"); | 121 setStatus("WebRTC connection failed", "error"); |
| 85 void stop(); | 122 void stop(false, true); |
| 86 } | 123 } |
| 87 }); | 124 }); |
| 88 for (const track of stream.getTracks()) peer.addTrack(track, stream); | 125 for (const track of stream.getTracks()) peer.addTrack(track, stream); |
| 89 | 126 |
| 90 const offer = await peer.createOffer(); | 127 const offer = await peer.createOffer(); |
| 103 sessionId = answer.sessionId; | 140 sessionId = answer.sessionId; |
| 104 await peer.setRemoteDescription({ | 141 await peer.setRemoteDescription({ |
| 105 sdp: answer.sdp, | 142 sdp: answer.sdp, |
| 106 type: answer.type, | 143 type: answer.type, |
| 107 }); | 144 }); |
| 145 await waitForTranscriptChannel(channel); | |
| 108 stopButton.disabled = false; | 146 stopButton.disabled = false; |
| 109 setStatus("Connecting...", "working"); | |
| 110 } catch (error) { | 147 } catch (error) { |
| 148 await stop(false, true); | |
| 149 throw error; | |
| 150 } | |
| 151 } | |
| 152 | |
| 153 async function ensureConnection() { | |
| 154 if (peer && ["connected", "completed"].includes(peer.connectionState)) return; | |
| 155 if (!connectionPromise) { | |
| 156 connectionPromise = createConnection().finally(() => { | |
| 157 connectionPromise = null; | |
| 158 }); | |
| 159 } | |
| 160 await connectionPromise; | |
| 161 } | |
| 162 | |
| 163 async function start() { | |
| 164 captureActive = true; | |
| 165 startButton.disabled = true; | |
| 166 setStatus("Starting microphone...", "working"); | |
| 167 try { | |
| 168 await ensureConnection(); | |
| 169 for (const track of stream.getTracks()) track.enabled = true; | |
| 170 setStatus( | |
| 171 channel?.readyState === "open" ? "Listening" : "Connecting...", | |
| 172 channel?.readyState === "open" ? "ready" : "working", | |
| 173 ); | |
| 174 } catch (error) { | |
| 175 captureActive = false; | |
| 111 setStatus(error.message || "Unable to start dictation", "error"); | 176 setStatus(error.message || "Unable to start dictation", "error"); |
| 112 await stop(); | 177 } |
| 113 } | 178 } |
| 114 } | 179 |
| 115 | 180 async function stop(waitForFinal = true, releaseStream = !canvasTransport) { |
| 116 async function stop(waitForFinal = true) { | |
| 117 const closingSession = sessionId; | 181 const closingSession = sessionId; |
| 118 sessionId = null; | |
| 119 const tracks = stream?.getTracks() || []; | 182 const tracks = stream?.getTracks() || []; |
| 183 captureActive = false; | |
| 120 if (channel?.readyState === "open" && waitForFinal) { | 184 if (channel?.readyState === "open" && waitForFinal) { |
| 121 const ended = new Promise(resolve => { | 185 const ended = new Promise(resolve => { |
| 122 stopResolver = resolve; | 186 stopResolver = resolve; |
| 123 }); | 187 }); |
| 124 channel.send("stop"); | 188 channel.send(releaseStream ? "stop" : "pause"); |
| 125 for (const track of tracks) track.enabled = false; | 189 for (const track of tracks) track.enabled = false; |
| 126 await Promise.race([ | 190 await Promise.race([ |
| 127 ended, | 191 ended, |
| 128 new Promise(resolve => window.setTimeout(resolve, 10000)), | 192 new Promise(resolve => window.setTimeout(resolve, 10000)), |
| 129 ]); | 193 ]); |
| 130 stopResolver = null; | 194 stopResolver = null; |
| 131 } | 195 } |
| 132 for (const track of tracks) track.stop(); | 196 for (const track of tracks) { |
| 133 stream = null; | 197 track.enabled = false; |
| 198 if (releaseStream) track.stop(); | |
| 199 } | |
| 200 if (!releaseStream) { | |
| 201 startButton.disabled = false; | |
| 202 stopButton.disabled = true; | |
| 203 setStatus("Idle"); | |
| 204 return; | |
| 205 } | |
| 206 sessionId = null; | |
| 207 if (releaseStream) stream = null; | |
| 134 channel?.close(); | 208 channel?.close(); |
| 135 channel = null; | 209 channel = null; |
| 136 peer?.close(); | 210 peer?.close(); |
| 137 peer = null; | 211 peer = null; |
| 138 if (closingSession) { | 212 if (closingSession) { |
| 146 setStatus("Idle"); | 220 setStatus("Idle"); |
| 147 } | 221 } |
| 148 | 222 |
| 149 startButton.addEventListener("click", () => void start()); | 223 startButton.addEventListener("click", () => void start()); |
| 150 stopButton.addEventListener("click", () => void stop()); | 224 stopButton.addEventListener("click", () => void stop()); |
| 151 window.addEventListener("pagehide", () => void stop(false)); | 225 window.ZenbuDictationStart = () => start(); |
| 226 window.ZenbuDictationStop = () => stop(); | |
| 227 window.ZenbuDictationCommit = () => { | |
| 228 if (channel?.readyState === "open") channel.send("commit"); | |
| 229 }; | |
| 230 window.ZenbuDictationToggle = () => captureActive ? stop() : start(); | |
| 231 window.addEventListener("message", event => { | |
| 232 if (event.data?.type === "zenbu.dictation.set-active") { | |
| 233 void (event.data.active ? start() : stop()); | |
| 234 } else if (event.data?.type === "zenbu.dictation.commit") { | |
| 235 window.ZenbuDictationCommit(); | |
| 236 } | |
| 237 }); | |
| 238 window.addEventListener("pagehide", () => void stop(false, true)); | |
| 239 | |
| 240 async function prepareCanvasMicrophone() { | |
| 241 if (!canvasTransport || peer) return; | |
| 242 try { | |
| 243 await ensureConnection(); | |
| 244 for (const track of stream.getTracks()) track.enabled = false; | |
| 245 setStatus("Idle"); | |
| 246 } catch (error) { | |
| 247 setStatus(error.message || "Unable to prepare microphone", "error"); | |
| 248 } | |
| 249 } | |
| 250 | |
| 251 void prepareCanvasMicrophone(); |