PageSourceSearch

https://gcls.ai/rgmbs/voice/voice-ui.js

js gcls.ai collected 2026-10-05 13:58:41 UTC 16,071 bytes, 442 lines download raw bytes

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.