Mercurial
comparison dictation/web/dictation.js @ 275:78699f810817
Add Qwen3-VL and WebRTC dictation services
Add Bazel targets for the CUDA-backed Qwen3-VL server and a local WebRTC faster-whisper dictation service.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3d8cb06-6c95-4ae0-9757-651d3796ab00
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 10:58:47 -0700 |
| parents | |
| children | 49e9e591c9bb |
comparison
equal
deleted
inserted
replaced
| 274:c9be578316a6 | 275:78699f810817 |
|---|---|
| 1 const startButton = document.querySelector("[data-start]"); | |
| 2 const stopButton = document.querySelector("[data-stop]"); | |
| 3 const status = document.querySelector("[data-status]"); | |
| 4 const partial = document.querySelector("[data-partial]"); | |
| 5 const finalText = document.querySelector("[data-final]"); | |
| 6 | |
| 7 let peer = null; | |
| 8 let channel = null; | |
| 9 let stream = null; | |
| 10 let sessionId = null; | |
| 11 let stopResolver = null; | |
| 12 | |
| 13 function setStatus(message, state = "") { | |
| 14 status.textContent = message; | |
| 15 status.dataset.state = state; | |
| 16 } | |
| 17 | |
| 18 function waitForIceGathering(connection) { | |
| 19 if (connection.iceGatheringState === "complete") return Promise.resolve(); | |
| 20 return new Promise(resolve => { | |
| 21 const listener = () => { | |
| 22 if (connection.iceGatheringState !== "complete") return; | |
| 23 connection.removeEventListener("icegatheringstatechange", listener); | |
| 24 resolve(); | |
| 25 }; | |
| 26 connection.addEventListener("icegatheringstatechange", listener); | |
| 27 }); | |
| 28 } | |
| 29 | |
| 30 function handleTranscript(message) { | |
| 31 let event; | |
| 32 try { | |
| 33 event = JSON.parse(message.data); | |
| 34 } catch { | |
| 35 setStatus("Received an invalid server event", "error"); | |
| 36 return; | |
| 37 } | |
| 38 switch (event.type) { | |
| 39 case "ready": | |
| 40 setStatus("Listening", "ready"); | |
| 41 break; | |
| 42 case "speech.started": | |
| 43 setStatus("Speech detected", "working"); | |
| 44 break; | |
| 45 case "transcript.partial": | |
| 46 partial.textContent = event.text; | |
| 47 break; | |
| 48 case "transcript.final": | |
| 49 finalText.value += `${finalText.value ? " " : ""}${event.text}`; | |
| 50 partial.textContent = "Waiting for speech..."; | |
| 51 break; | |
| 52 case "speech.ended": | |
| 53 setStatus("Listening", "ready"); | |
| 54 stopResolver?.(); | |
| 55 stopResolver = null; | |
| 56 break; | |
| 57 case "error": | |
| 58 setStatus(event.message || "Transcription failed", "error"); | |
| 59 break; | |
| 60 } | |
| 61 } | |
| 62 | |
| 63 async function start() { | |
| 64 if (peer) return; | |
| 65 startButton.disabled = true; | |
| 66 setStatus("Requesting microphone access...", "working"); | |
| 67 try { | |
| 68 stream = await navigator.mediaDevices.getUserMedia({ | |
| 69 audio: { | |
| 70 channelCount: 1, | |
| 71 echoCancellation: true, | |
| 72 noiseSuppression: true, | |
| 73 }, | |
| 74 video: false, | |
| 75 }); | |
| 76 peer = new RTCPeerConnection(); | |
| 77 channel = peer.createDataChannel("transcripts", { ordered: true }); | |
| 78 channel.addEventListener("message", handleTranscript); | |
| 79 channel.addEventListener("close", () => { | |
| 80 if (peer) setStatus("Transcript channel closed", "error"); | |
| 81 }); | |
| 82 peer.addEventListener("connectionstatechange", () => { | |
| 83 if (peer?.connectionState === "failed") { | |
| 84 setStatus("WebRTC connection failed", "error"); | |
| 85 void stop(); | |
| 86 } | |
| 87 }); | |
| 88 for (const track of stream.getTracks()) peer.addTrack(track, stream); | |
| 89 | |
| 90 const offer = await peer.createOffer(); | |
| 91 await peer.setLocalDescription(offer); | |
| 92 await waitForIceGathering(peer); | |
| 93 const response = await fetch("/api/webrtc/offer", { | |
| 94 method: "POST", | |
| 95 headers: { "Content-Type": "application/json" }, | |
| 96 body: JSON.stringify(peer.localDescription), | |
| 97 }); | |
| 98 if (!response.ok) { | |
| 99 const detail = await response.text(); | |
| 100 throw new Error(`Signaling failed (${response.status}): ${detail}`); | |
| 101 } | |
| 102 const answer = await response.json(); | |
| 103 sessionId = answer.sessionId; | |
| 104 await peer.setRemoteDescription({ | |
| 105 sdp: answer.sdp, | |
| 106 type: answer.type, | |
| 107 }); | |
| 108 stopButton.disabled = false; | |
| 109 setStatus("Connecting...", "working"); | |
| 110 } catch (error) { | |
| 111 setStatus(error.message || "Unable to start dictation", "error"); | |
| 112 await stop(); | |
| 113 } | |
| 114 } | |
| 115 | |
| 116 async function stop(waitForFinal = true) { | |
| 117 const closingSession = sessionId; | |
| 118 sessionId = null; | |
| 119 const tracks = stream?.getTracks() || []; | |
| 120 if (channel?.readyState === "open" && waitForFinal) { | |
| 121 const ended = new Promise(resolve => { | |
| 122 stopResolver = resolve; | |
| 123 }); | |
| 124 channel.send("stop"); | |
| 125 for (const track of tracks) track.enabled = false; | |
| 126 await Promise.race([ | |
| 127 ended, | |
| 128 new Promise(resolve => window.setTimeout(resolve, 10000)), | |
| 129 ]); | |
| 130 stopResolver = null; | |
| 131 } | |
| 132 for (const track of tracks) track.stop(); | |
| 133 stream = null; | |
| 134 channel?.close(); | |
| 135 channel = null; | |
| 136 peer?.close(); | |
| 137 peer = null; | |
| 138 if (closingSession) { | |
| 139 await fetch(`/api/webrtc/session/${encodeURIComponent(closingSession)}/close`, { | |
| 140 method: "POST", | |
| 141 keepalive: true, | |
| 142 }).catch(() => {}); | |
| 143 } | |
| 144 startButton.disabled = false; | |
| 145 stopButton.disabled = true; | |
| 146 setStatus("Idle"); | |
| 147 } | |
| 148 | |
| 149 startButton.addEventListener("click", () => void start()); | |
| 150 stopButton.addEventListener("click", () => void stop()); | |
| 151 window.addEventListener("pagehide", () => void stop(false)); |