From: taran nathan Date: Wed, 22 Jul 2026 20:52:04 +0000 (-0700) Subject: renders speakers X-Git-Url: https://git.taranathan.com/?a=commitdiff_plain;h=06628857b27e79a0a4037fc4dbe544a861c7fc1e;p=audio-stream.git renders speakers --- diff --git a/script.js b/script.js index 04e0dfb..5f9e3e2 100644 --- 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({