1const css = "/rgmbs/voice/voice.css"; 2export function mountVoice( 3 root, 4 context, 5 { 6 language = "en", 7 expanded = false, 8 onActive = () => {}, 9 onTranscript = () => {}, 10 onUseText, 11 } = {}, 12) { 13 if (!document.querySelector("link[data-rg-voice]")) { 14 const link = document.createElement("link"); 15 link.rel = "stylesheet"; 16 link.href = css; 17 link.dataset.rgVoice = "1"; 18 document.head.append(link); 19 } 20 const ar = language === "ar", 21 t = (en, arabic) => (ar ? arabic : en); 22 root.classList.add("rg-voice"); 23 root.dataset.active = "false"; 24 root.dir = ar ? "rtl" : "ltr"; 25 root.lang = language; 26 const heading = document.createElement("summary"); 27 heading.textContent = t("Talk with your tutor", "ØªØØ¯ÙØ« ٠ع ٠عÙÙÙ Ù"); 28 const panel = document.createElement("details"); 29 panel.open = expanded; 30 heading.hidden = expanded; 31 panel.append(heading); 32 root.append(panel); 33 const note = document.createElement("p"); 34 note.textContent = t( 35 "Speak in English or Arabic. Start shares your microphone and selected context with Gemini. Audio is not saved by this workshop.", 36 "ØªØØ¯Ø« Ø¨Ø§ÙØ¹Ø±Ø¨ÙØ© Ø£Ù Ø§ÙØ¥ÙجÙÙØ²ÙØ©. Ø¹ÙØ¯ Ø§ÙØ¨Ø¯Ø¡ ÙÙØ±Ø³Ù ØµÙØªÙ ÙØ§ÙØ³ÙØ§Ù اÙ٠ختار Ø¥ÙÙ Gemini. ÙØ§ تØÙظ اÙÙØ±Ø´Ø© Ø§ÙØµÙت.", 37 ); 38 if (expanded) note.textContent = t( 39 "Uses your mic and selected sources with Gemini. Audio isnât saved here.", 40 "ÙØ³ØªØ®Ø¯Ù اÙÙ ÙÙØ±ÙÙÙÙ Ù٠صادر٠اÙ٠ختارة ٠ع Gemini. ÙØ§ ÙÙØÙØ¸ Ø§ÙØµÙت ÙÙØ§." 41 ); 42 note.className = "rg-voice-privacy"; 43 panel.append(note); 44 const scope = document.createElement("p"); 45 scope.textContent = t( 46 "Voice practice uses selected sources. Use text for research tools, diagrams and assessment.", 47 "ÙØ³ØªØ®Ø¯Ù Ø§ÙØªØ¯Ø±Ùب Ø§ÙØµÙت٠اÙ٠صادر اÙ٠ختارة. استخد٠اÙÙØµ ÙØ£Ø¯Ùات Ø§ÙØ¨ØØ« ÙØ§ÙرسÙÙ ÙØ§ÙتÙÙÙÙ .", 48 ); 49 const info = document.createElement("details"); 50 const infoTitle = document.createElement("summary"); 51 infoTitle.textContent = t("Details", "Ø§ÙØªÙاصÙÙ"); 52 info.className = "rg-voice-info"; 53 info.append(infoTitle, scope); 54 if (!expanded) panel.append(scope); 55 const controls = document.createElement("div"); 56 controls.className = "rg-voice-controls"; 57 panel.append(controls); 58 const button = (text, fn) => { 59 const b = document.createElement("button"); 60 b.type = "button"; 61 b.textContent = text; 62 b.addEventListener("click", fn); 63 controls.append(b); 64 return b; 65 }; 66 const status = document.createElement("p"); 67 status.className = "rg-voice-status"; 68 status.setAttribute("role", "status"); 69 panel.append(status); 70 const trace = document.createElement("details"), 71 traceTitle = document.createElement("summary"), 72 traceBody = document.createElement("div"); 73 traceTitle.textContent = t("What voice used", "٠ا Ø§Ø³ØªØ®Ø¯Ù Ù Ø§ÙØµÙت"); 74 trace.append(traceTitle, traceBody); 75 trace.hidden = true; 76 panel.append(trace); 77 const transcript = document.createElement("div"); 78 transcript.className = "rg-voice-transcript"; 79 transcript.dir = "auto"; 80 panel.append(transcript); 81 if (expanded) panel.append(info); 82 let socket, 83 stream, 84 audio, 85 worklet, 86 source, 87 timeout, 88 abort, 89 starting = false, 90 disposed = false, 91 paused = false, 92 externalDisabled = false, 93 model = "", 94 currentRole = "", 95 currentLine, 96 transcriptText = "", 97 inputText = "", 98 lastInput = "", 99 nextTime = 0; 100 const playing = new Set(); 101 function clearAudio() { 102 for (const node of playing) { 103 try { 104 node.stop(); 105 } catch {} 106 } 107 playing.clear(); 108 nextTime = 0; 109 } 110 function stop( 111 message = t("Stopped. Microphone off.", "تÙÙÙ. اÙÙ ÙÙØ±ÙÙÙ٠٠غÙÙ."), 112 ) { 113 const wasActive = starting; 114 starting = false; 115 root.dataset.active = "false"; 116 start.disabled = externalDisabled; 117 end.disabled = true; 118 mute.disabled = true; 119 clearTimeout(timeout); 120 abort?.abort(); 121 stream?.getTracks().forEach((track) => track.stop()); 122 stream = undefined; 123 worklet?.disconnect(); 124 source?.disconnect(); 125 worklet = undefined; 126 source = undefined; 127 if (socket) { 128 socket.onmessage = null; 129 socket.onclose = null; 130 socket.onerror = null; 131 socket.close(); 132 socket = undefined; 133 } 134 clearAudio(); 135 void audio?.close().catch(() => {}); 136 audio = undefined; 137 if (wasActive) onActive(false); 138 status.textContent = message; 139 save.disabled = !transcriptText; 140 } 141 const start = button(t("Start voice", "ابدأ Ø¨Ø§ÙØµÙت"), async () => { 142 if (starting || disposed || externalDisabled) return; 143 starting = true; 144 root.dataset.active = "true"; 145 start.disabled = true; 146 end.disabled = false;
147 onActive(true); 148 transcript.replaceChildren(); 149 traceBody.replaceChildren(); 150 trace.hidden = true; 151 transcriptText = ""; 152 inputText = ""; 153 lastInput = ""; 154 currentRole = ""; 155 save.disabled = true; 156 if (useText) useText.hidden = true; 157 status.textContent = t("Connectingâ¦", "Ø¬Ø§Ø±Ù Ø§ÙØ§ØªØµØ§Ùâ¦"); 158 try { 159 if (!navigator.mediaDevices?.getUserMedia || !window.AudioWorkletNode) 160 throw Error("browser"); 161 // Resume output during the user's tap (required by mobile browsers). 162 audio = new (window.AudioContext || window.webkitAudioContext)(); 163 await audio.resume(); 164 if (!starting || disposed) return; 165 abort = new AbortController(); 166 timeout = setTimeout( 167 () => 168 stop( 169 t("Could not connect. Try again.", "ØªØ¹Ø°ÙØ± Ø§ÙØ§ØªØµØ§Ù. ØØ§ÙÙ Ù Ø¬Ø¯Ø¯ÙØ§."), 170 ), 171 25000, 172 ); 173 const selected = context(); 174 const response = await fetch("/api/rgmbs/voice/ticket", { 175 method: "POST", 176 headers: { "Content-Type": "application/json" }, 177 body: JSON.stringify(selected), 178 signal: abort.signal, 179 }); 180 const body = await response.json(); 181 if (!response.ok) throw Error(body.reason || "unavailable"); 182 const acquired = await navigator.mediaDevices.getUserMedia({ 183 audio: { 184 channelCount: 1, 185 echoCancellation: true, 186 noiseSuppression: true, 187 autoGainControl: true, 188 }, 189 video: false, 190 }); 191 if (!starting || disposed) { 192 acquired.getTracks().forEach((t) => t.stop()); 193 return; 194 } 195 stream = acquired; 196 for (const track of stream.getTracks()) 197 track.addEventListener("ended", () => { 198 if (starting) 199 stop(t("Microphone disconnected.", "اÙÙØ·Ø¹ اÙÙ ÙÙØ±ÙÙÙÙ.")); 200 }); 201 await audio.audioWorklet.addModule("/rgmbs/voice/pcm-worklet.js"); 202 if (!starting || disposed) return; 203 socket = new WebSocket( 204 `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/rgmbs/voice/stream`, 205 ); 206 socket.onopen = () => 207 socket.send( 208 JSON.stringify({ 209 type: "start", 210 ticket: body.ticket, 211 context: selected, 212 }), 213 ); 214 socket.onerror = () => 215 stop( 216 t( 217 "Voice could not connect. Text is still available.", 218 "ØªØ¹Ø°ÙØ± Ø§ÙØ§ØªØµØ§Ù Ø§ÙØµÙتÙ. ÙÙ ÙÙ٠استخدا٠اÙÙØµ.", 219 ), 220 ); 221 socket.onclose = () => 222 stop( 223 t( 224 "Voice connection ended. Microphone off.", 225 "Ø§ÙØªÙÙ Ø§ÙØ§ØªØµØ§Ù Ø§ÙØµÙتÙ. اÙÙ ÙÙØ±ÙÙÙ٠٠غÙÙ.", 226 ), 227 ); 228 socket.onmessage = (event) => { 229 try { 230 const m = JSON.parse(event.data); 231 if (m.type === "ready") { 232 clearTimeout(timeout); 233 model = m.model; 234 paused = false; 235 mute.disabled = false; 236 mute.textContent = t("Mute microphone", "ÙØªÙ اÙÙ ÙÙØ±ÙÙÙÙ"); 237 status.textContent = 238 t( 239 "Listening · up to 3 minutes · ", 240 "أست٠ع Ø¥ÙÙ٠· ØØªÙ Ù£ Ø¯ÙØ§Ø¦Ù · ", 241 ) + model; 242 source = audio.createMediaStreamSource(stream); 243 worklet = new AudioWorkletNode(audio, "workshop-pcm"); 244 worklet.port.onmessage = ({ data }) => { 245 if (paused || socket?.readyState !== 1) return; 246 if (socket.bufferedAmount > 128000) { 247 stop( 248 t( 249 "Connection is slow. Please restart voice.", 250 "Ø§ÙØ§ØªØµØ§Ù Ø¨Ø·ÙØ¡. أعد تشغÙÙ Ø§ÙØµÙت.", 251 ), 252 ); 253 return; 254 } 255 let s = ""; 256 for (const byte of new Uint8Array(data)) 257 s += String.fromCharCode(byte); 258 socket.send(JSON.stringify({ type: "audio", data: btoa(s) })); 259 }; 260 source.connect(worklet); 261 worklet.connect(audio.destination); 262 timeout = setTimeout( 263 () => 264 stop( 265 t( 266 "Three minutes complete. Start again when ready.", 267 "Ø§ÙØªÙت Ø«ÙØ§Ø« Ø¯ÙØ§Ø¦Ù. ابدأ Ù Ø¬Ø¯Ø¯ÙØ§ Ø¹ÙØ¯Ù ا تÙÙÙ Ù Ø³ØªØ¹Ø¯ÙØ§.", 268 ), 269 ), 270 m.maxSeconds * 1000 + 1000, 271 ); 272 } else if (m.type === "audio" && audio) { 273 const bytes = Uint8Array.from(atob(m.data), (c) =>
273 c.charCodeAt(0)), 274 view = new DataView(bytes.buffer); 275 const buffer = audio.createBuffer(1, bytes.length / 2, 24000); 276 const floats = buffer.getChannelData(0); 277 for (let i = 0; i < floats.length; i++) 278 floats[i] = view.getInt16(i * 2, true) / 32768; 279 const node = audio.createBufferSource(); 280 node.buffer = buffer; 281 node.connect(audio.destination); 282 playing.add(node); 283 node.onended = () => playing.delete(node); 284 nextTime = Math.max(nextTime, audio.currentTime + 0.04); 285 if (nextTime - audio.currentTime > 30) { 286 stop( 287 t( 288 "Audio fell behind. Please restart.", 289 "تأخر Ø§ÙØµÙت. أعد Ø§ÙØªØ´ØºÙÙ.", 290 ), 291 ); 292 return; 293 } 294 node.start(nextTime); 295 nextTime += buffer.duration; 296 } else if (m.type === "trace") { 297 trace.hidden = false; 298 const line = document.createElement("p"); 299 line.textContent = m.text + " · " + m.model; 300 traceBody.append(line); 301 } else if (m.type === "interrupted") clearAudio(); 302 else if (m.type === "transcript") { 303 if (transcriptText.length > 24000) return; 304 if (currentRole !== m.role) { 305 if (m.role === "user") lastInput = ""; 306 currentRole = m.role; 307 currentLine = document.createElement("p"); 308 const label = 309 m.role === "user" 310 ? t("You: ", "Ø£ÙØª: ") 311 : t("Tutor: ", "اÙ٠عÙÙÙ : "); 312 currentLine.textContent = label; 313 transcript.append(currentLine); 314 transcriptText += "\n" + label; 315 } 316 currentLine.append(document.createTextNode(m.text)); 317 transcriptText += m.text; 318 if (m.role === "user") { 319 inputText += m.text; 320 lastInput += m.text; 321 if (useText) useText.hidden = false; 322 } 323 transcript.scrollTop = transcript.scrollHeight; 324 onTranscript({ role: m.role, text: m.text }); 325 } else if (m.type === "turn_end") { 326 currentRole = ""; 327 inputText += "\n"; 328 } else if (m.type === "ended") 329 stop( 330 m.reason === "time_limit" 331 ? t( 332 "Three minutes complete. Start again when ready.", 333 "Ø§ÙØªÙت Ø«ÙØ§Ø« Ø¯ÙØ§Ø¦Ù. ابدأ Ù Ø¬Ø¯Ø¯ÙØ§ Ø¹ÙØ¯Ù ا تÙÙÙ Ù Ø³ØªØ¹Ø¯ÙØ§.", 334 ) 335 : t( 336 "Voice ended. Microphone off. You can retry or use text.", 337 "Ø§ÙØªÙت اÙÙ ØØ§Ø¯Ø«Ø©. اÙÙ ÙÙØ±ÙÙÙ٠٠غÙÙ. ÙÙ ÙÙ٠اÙÙ ØØ§ÙÙØ© Ù Ø¬Ø¯Ø¯ÙØ§ أ٠استخدا٠اÙÙØµ.", 338 ), 339 ); 340 } catch { 341 stop( 342 t( 343 "Voice interrupted. Your text remains available.", 344 "اÙÙØ·Ø¹ Ø§ÙØµÙت. ٠ا زا٠اÙÙØµ ٠تاØÙا.", 345 ), 346 ); 347 } 348 }; 349 } catch (error) { 350 if (!starting || disposed) return; 351 const denied = error.name === "NotAllowedError"; 352 stop( 353 denied 354 ? t( 355 "Microphone permission was declined. You can still type.", 356 "ÙÙ ÙÙØ³Ù Ø Ø¨Ø§Ø³ØªØ®Ø¯Ø§Ù Ø§ÙÙ ÙÙØ±ÙÙÙÙ. ÙÙ ÙÙ٠اÙÙØªØ§Ø¨Ø©.", 357 ) 358 : t( 359 "Voice is unavailable. Enter the workshop if needed, or continue in text.", 360 "Ø§ÙØµÙت ØºÙØ± ٠تاØ. ادخ٠اÙÙØ±Ø´Ø© Ø¹ÙØ¯ Ø§ÙØØ§Ø¬Ø© أ٠تابع باÙÙØµ.", 361 ), 362 ); 363 } 364 }); 365 start.className = "rg-voice-start"; 366 if (expanded) start.textContent = t("Start conversation", "ابدأ اÙÙ ØØ§Ø¯Ø«Ø©"); 367 const mute = button(t("Mute microphone", "ÙØªÙ اÙÙ ÙÙØ±ÙÙÙÙ"), () => { 368 paused = !paused; 369 stream?.getAudioTracks().forEach((t) => (t.enabled = !paused)); 370 if (paused && socket?.readyState === 1) 371 socket.send(JSON.stringify({ type: "pause" })); 372 mute.textContent = paused 373 ? t("Resume microphone", "تشغÙ٠اÙÙ ÙÙØ±ÙÙÙÙ") 374 : t("Mute microphone", "ÙØªÙ اÙÙ ÙÙØ±ÙÙÙÙ"); 375 status.textContent = paused 376 ? t("Microphone muted.", "اÙÙ ÙÙØ±ÙÙÙÙ Ù ÙØªÙÙ .") 377 : t("Listeningâ¦", "أست٠ع Ø¥ÙÙÙâ¦"); 378 }); 379 mute.className = "rg-voice-mute"; 380 mute.disabled = true; 381 const end = button(t("Stop", "Ø¥ÙÙØ§Ù"), () => stop()); 382 end.className = "rg-voice-end"; 383 if (expanded) end.textContent = t("End", "Ø¥ÙÙØ§Ø¡"); 384 end.disabled = true; 385 const save = button(t("Save transcript", "اØÙظ اÙÙØµ"), () => { 386 const url = URL.createObjectURL( 387 new Blob( 388 [ 389 `Workshop voice practice · ${model}\nAI draft; transcription may contain errors. No readiness or learner assessment recorded.\n${transcriptText}`, 390 ], 391 { type: "text/plain;charset=utf-8" }, 392 ), 393 ); 394 const a = document.createElement("a"); 395 a.href = url; 396 a.download = "rgmbs-voice-transcript.txt"; 397 a.click(); 398 setTimeout(() => URL.revokeObjectURL(url), 1000); 399 }); 400 save.disabled = true; 401 const useText = onUseText 402 ? button(t("Use last question as text", "Ø§Ø³ØªØ®Ø¯Ù Ø§ÙØ³Ø¤Ø§Ù Ø§ÙØ£Ø®Ùر ÙÙØµ"), () => { 403 const text = lastInput.trim(); 404 if (text) { 405 stop(); 406 onUseText(text); 407 } 408 }) 409 : null; 410 if (useText) useText.hidden = true; 411 const hide = () => { 412 if (document.hidden && starting) 413 stop( 414 t( 415 "Paused because you left this page. Microphone off.", 416 "تÙÙÙ Ø§ÙØµÙت ÙØ£Ù٠غادرت Ø§ÙØµÙØØ©. اÙÙ ÙÙØ±ÙÙÙ٠٠غÙÙ.", 417 ), 418 ); 419 }; 420 document.addEventListener("visibilitychange", hide); 421 const leave = () => { 422 if (starting) stop(); 423 }; 424 window.addEventListener("pagehide", leave); 425 return { 426 stop, 427 setDisabled: (value) => { 428 externalDisabled = value; 429 start.disabled = value || starting; 430 }, 431 getInput: () => inputText, 432 destroy: () => { 433 disposed = true; 434 stop(); 435 document.removeEventListener("visibilitychange", hide); 436 window.removeEventListener("pagehide", leave); 437 root.replaceChildren(); 438 }, 439 }; 440} 441// The React sandbox loads this shared public module through a script tag. 442window.RgmbsVoiceUI = { mountVoice };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.