view dictation/web/dictation.js @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents 78699f810817
children 49e9e591c9bb
line wrap: on
line source

const startButton = document.querySelector("[data-start]");
const stopButton = document.querySelector("[data-stop]");
const status = document.querySelector("[data-status]");
const partial = document.querySelector("[data-partial]");
const finalText = document.querySelector("[data-final]");

let peer = null;
let channel = null;
let stream = null;
let sessionId = null;
let stopResolver = null;

function setStatus(message, state = "") {
  status.textContent = message;
  status.dataset.state = state;
}

function waitForIceGathering(connection) {
  if (connection.iceGatheringState === "complete") return Promise.resolve();
  return new Promise(resolve => {
    const listener = () => {
      if (connection.iceGatheringState !== "complete") return;
      connection.removeEventListener("icegatheringstatechange", listener);
      resolve();
    };
    connection.addEventListener("icegatheringstatechange", listener);
  });
}

function handleTranscript(message) {
  let event;
  try {
    event = JSON.parse(message.data);
  } catch {
    setStatus("Received an invalid server event", "error");
    return;
  }
  switch (event.type) {
    case "ready":
      setStatus("Listening", "ready");
      break;
    case "speech.started":
      setStatus("Speech detected", "working");
      break;
    case "transcript.partial":
      partial.textContent = event.text;
      break;
    case "transcript.final":
      finalText.value += `${finalText.value ? " " : ""}${event.text}`;
      partial.textContent = "Waiting for speech...";
      break;
    case "speech.ended":
      setStatus("Listening", "ready");
      stopResolver?.();
      stopResolver = null;
      break;
    case "error":
      setStatus(event.message || "Transcription failed", "error");
      break;
  }
}

async function start() {
  if (peer) return;
  startButton.disabled = true;
  setStatus("Requesting microphone access...", "working");
  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true,
      },
      video: false,
    });
    peer = new RTCPeerConnection();
    channel = peer.createDataChannel("transcripts", { ordered: true });
    channel.addEventListener("message", handleTranscript);
    channel.addEventListener("close", () => {
      if (peer) setStatus("Transcript channel closed", "error");
    });
    peer.addEventListener("connectionstatechange", () => {
      if (peer?.connectionState === "failed") {
        setStatus("WebRTC connection failed", "error");
        void stop();
      }
    });
    for (const track of stream.getTracks()) peer.addTrack(track, stream);

    const offer = await peer.createOffer();
    await peer.setLocalDescription(offer);
    await waitForIceGathering(peer);
    const response = await fetch("/api/webrtc/offer", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(peer.localDescription),
    });
    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Signaling failed (${response.status}): ${detail}`);
    }
    const answer = await response.json();
    sessionId = answer.sessionId;
    await peer.setRemoteDescription({
      sdp: answer.sdp,
      type: answer.type,
    });
    stopButton.disabled = false;
    setStatus("Connecting...", "working");
  } catch (error) {
    setStatus(error.message || "Unable to start dictation", "error");
    await stop();
  }
}

async function stop(waitForFinal = true) {
  const closingSession = sessionId;
  sessionId = null;
  const tracks = stream?.getTracks() || [];
  if (channel?.readyState === "open" && waitForFinal) {
    const ended = new Promise(resolve => {
      stopResolver = resolve;
    });
    channel.send("stop");
    for (const track of tracks) track.enabled = false;
    await Promise.race([
      ended,
      new Promise(resolve => window.setTimeout(resolve, 10000)),
    ]);
    stopResolver = null;
  }
  for (const track of tracks) track.stop();
  stream = null;
  channel?.close();
  channel = null;
  peer?.close();
  peer = null;
  if (closingSession) {
    await fetch(`/api/webrtc/session/${encodeURIComponent(closingSession)}/close`, {
      method: "POST",
      keepalive: true,
    }).catch(() => {});
  }
  startButton.disabled = false;
  stopButton.disabled = true;
  setStatus("Idle");
}

startButton.addEventListener("click", () => void start());
stopButton.addEventListener("click", () => void stop());
window.addEventListener("pagehide", () => void stop(false));