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));