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");
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) {
}
}
-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(
} 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);
}
});
startButton.addEventListener("click", async () => {
try {
- committedTranscription = "";
- partialTranscription = "";
- lastCommittedTurn = -1;
+ turns = new Map();
renderTranscription();
microphoneStream = await navigator.mediaDevices.getUserMedia({