]> git.taranathan.com Git - audio-stream.git/commitdiff
renders speakers
authortaran nathan <moogoesmeow123@gmail.com>
Wed, 22 Jul 2026 20:52:04 +0000 (13:52 -0700)
committertaran nathan <moogoesmeow123@gmail.com>
Wed, 22 Jul 2026 20:52:04 +0000 (13:52 -0700)
script.js

index 04e0dfbb091dd7f5f113e9219a8c98f8e999d8de..5f9e3e240ca0969d74d2a249ddf520d90220348c 100644 (file)
--- a/script.js
+++ b/script.js
@@ -10,9 +10,9 @@ let transcriptionSocket;
 let streamSourceNode;
 let streamProcessorNode;
 let streamMuteNode;
-let committedTranscription = "";
-let partialTranscription = "";
-let lastCommittedTurn = -1;
+
+// turn_order -> { speakerLabel: string, text: string, isFinal: boolean }
+let turns = new Map();
 
 const startButton = document.getElementById("btnStart");
 const stopButton = document.getElementById("btnStop");
@@ -24,18 +24,64 @@ const BACKEND_WS_URL = "ws://localhost:3000/ws/transcribe";
 const CHUNK_MS = 3000;
 const TARGET_SAMPLE_RATE = 16000;
 
+function speakerDisplayName(speakerLabel) {
+  if (!speakerLabel || speakerLabel === "PENDING") {
+    return "Speaker \u2026"; // "Speaker …" while diarization is still resolving
+  }
+  return `Speaker ${speakerLabel}`;
+}
+
 function renderTranscription() {
-  const committed = committedTranscription.trim();
-  const partial = partialTranscription.trim();
+  transcriptionElement.innerHTML = "";
 
-  if (!committed && !partial) {
-    transcriptionElement.textContent = "";
+  if (turns.size === 0) {
     return;
   }
 
-  transcriptionElement.textContent = partial
-    ? `${committed} ${partial}`.trim()
-    : committed;
+  const orderedTurns = Array.from(turns.entries()).sort(
+    (a, b) => a[0] - b[0],
+  );
+
+  let lastSpeakerName = null;
+  let currentGroupEl = null;
+
+  orderedTurns.forEach(([, turn]) => {
+    const text = turn.text.trim();
+    if (!text) {
+      return;
+    }
+
+    const speakerName = speakerDisplayName(turn.speakerLabel);
+
+    // Start a new visual group whenever the speaker changes, so consecutive
+    // turns from the same speaker stay together instead of re-labeling each one.
+    if (speakerName !== lastSpeakerName) {
+      currentGroupEl = document.createElement("div");
+      currentGroupEl.className = "transcript-turn";
+      currentGroupEl.style.marginBottom = "0.5em";
+
+      const labelEl = document.createElement("span");
+      labelEl.className = "speaker-label";
+      labelEl.textContent = `${speakerName}: `;
+      labelEl.style.fontWeight = "600";
+
+      currentGroupEl.appendChild(labelEl);
+      transcriptionElement.appendChild(currentGroupEl);
+      lastSpeakerName = speakerName;
+    }
+
+    const textEl = document.createElement("span");
+    textEl.className = turn.isFinal
+      ? "transcript-final"
+      : "transcript-partial";
+    if (!turn.isFinal) {
+      textEl.style.opacity = "0.6";
+      textEl.style.fontStyle = "italic";
+    }
+    textEl.textContent = `${text} `;
+
+    currentGroupEl.appendChild(textEl);
+  });
 }
 
 function cleanupCaptureResources(closeTranscriptionSocket = true) {
@@ -79,56 +125,57 @@ function cleanupCaptureResources(closeTranscriptionSocket = true) {
   }
 }
 
-function appendCommittedTranscription(text) {
-  if (!text) {
+// Handles the live "Turn" events. speaker_label starts as "PENDING" and may
+// resolve to a real label (e.g. "A") once diarization has enough context.
+// The same turn_order is sent repeatedly with growing transcript text until
+// end_of_turn is true.
+function handleTurnEvent(payload) {
+  if (typeof payload.turn_order !== "number") {
     return;
   }
 
-  const trimmedText = text.trim();
-  if (!trimmedText) {
+  const text = typeof payload.transcript === "string" ? payload.transcript : "";
+  if (!text.trim() && !turns.has(payload.turn_order)) {
     return;
   }
 
-  committedTranscription = committedTranscription
-    ? `${committedTranscription} ${trimmedText}`
-    : trimmedText;
-  partialTranscription = "";
-  renderTranscription();
-}
+  turns.set(payload.turn_order, {
+    speakerLabel: payload.speaker_label || "PENDING",
+    text,
+    isFinal: payload.end_of_turn === true,
+  });
 
-function updatePartialTranscription(text) {
-  const trimmedText = text?.trim() || "";
-  partialTranscription = trimmedText;
   renderTranscription();
 }
 
-function extractTranscriptFromRealtimeEvent(payload) {
-  if (!payload || typeof payload !== "object") {
-    return null;
+// Handles "SpeakerRevision" events, which retroactively correct the speaker
+// label for one or more already-seen turns (identified by turn_order).
+// This is what lets us fix a turn that was rendered as "PENDING" or
+// mis-attributed once the diarization model is more confident.
+function handleSpeakerRevisionEvent(payload) {
+  const revisions = Array.isArray(payload.revisions) ? payload.revisions : [];
+  if (revisions.length === 0) {
+    return;
   }
 
-  if (typeof payload.error === "string") {
-    return { kind: "error", text: payload.error };
-  }
+  revisions.forEach((revision) => {
+    if (typeof revision.turn_order !== "number") {
+      return;
+    }
 
-  if (payload.type !== "Turn") {
-    return null;
-  }
+    const existing = turns.get(revision.turn_order);
+    const text = existing
+      ? existing.text
+      : (revision.words || []).map((w) => w.text).join(" ");
 
-  const transcriptText = typeof payload.transcript === "string"
-    ? payload.transcript
-    : "";
-  if (!transcriptText.trim()) {
-    return null;
-  }
+    turns.set(revision.turn_order, {
+      speakerLabel: revision.speaker_label || "PENDING",
+      text,
+      isFinal: true,
+    });
+  });
 
-  return {
-    kind: payload.end_of_turn === true ? "final" : "partial",
-    text: transcriptText,
-    turnOrder: typeof payload.turn_order === "number"
-      ? payload.turn_order
-      : undefined,
-  };
+  renderTranscription();
 }
 
 function downsampleToTargetSampleRate(
@@ -202,27 +249,16 @@ function connectTranscriptionSocket() {
       } catch {
         return;
       }
-      console.log(parsed);
-      console.log(JSON.stringify(parsed, null, 2));
-
-      const transcriptEvent = extractTranscriptFromRealtimeEvent(parsed);
-      console.log(transcriptEvent);
-      if (!transcriptEvent) {
-        return;
-      }
 
-      if (transcriptEvent.kind === "error") {
-        console.error("Realtime transcription error:", transcriptEvent.text);
+      if (typeof parsed.error === "string") {
+        console.error("Realtime transcription error:", parsed.error);
         return;
       }
 
-      if (transcriptEvent.kind === "final") {
-        if (transcriptEvent.turnOrder > lastCommittedTurn) {
-          lastCommittedTurn = transcriptEvent.turnOrder;
-          appendCommittedTranscription(transcriptEvent.text);
-        }
-      } else {
-        updatePartialTranscription(transcriptEvent.text);
+      if (parsed.type === "Turn") {
+        handleTurnEvent(parsed);
+      } else if (parsed.type === "SpeakerRevision") {
+        handleSpeakerRevisionEvent(parsed);
       }
     });
 
@@ -240,9 +276,7 @@ function connectTranscriptionSocket() {
 
 startButton.addEventListener("click", async () => {
   try {
-    committedTranscription = "";
-    partialTranscription = "";
-    lastCommittedTurn = -1;
+    turns = new Map();
     renderTranscription();
 
     microphoneStream = await navigator.mediaDevices.getUserMedia({