import { useEffect, useRef, useState } from "react"; import { onFinalTranscript, onPartialTranscript, } from "../lib/transcription-engine"; type Source = "mic" | "system"; export interface FinalLine { text: string; source: Source; startMs?: number; } /** Format ms since meeting start as m:ss. */ function formatTimestamp(ms: number): string { const total = Math.floor(ms / 1000); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${s.toString().padStart(2, "0")}`; } /** * Auto-scrolling live-transcript view. Subscribes to the Tauri events that * the local Whisper meeting engine (`whisper_speech.rs`) emits for both the * mic and system-audio streams: * * - `voice:partial-transcript` `{ text, source: "mic" | "system" }` * - `voice:final-transcript` `{ text, source: "mic" | "system" }` * * Locked-in segments are tagged with a small "You" (mic) / "Them" (system) * pill so the user can see who said what during a meeting. The in-flight * partial for each source is rendered separately so the two streams don't * clobber each other. */ export function LiveTranscript({ onLinesChange, initialLines, }: { onLinesChange?: (lines: FinalLine[]) => void; initialLines?: FinalLine[]; } = {}) { const [finals, setFinals] = useState(initialLines ?? []); const [micPartial, setMicPartial] = useState(""); const [sysPartial, setSysPartial] = useState(""); const scrollRef = useRef(null); // Whether the current session's preloaded history has been merged in. Guards // against the preload event firing more than once (the host emits it on both // the get-meeting fetch and clips:pill-ready), which would duplicate lines. const preloadAppliedRef = useRef((initialLines?.length ?? 0) > 0); useEffect(() => { if (onLinesChange) onLinesChange(finals); }, [finals, onLinesChange]); useEffect(() => { const lines = initialLines ?? []; // Empty = pill context reset for a new meeting: clear everything. if (lines.length === 0) { preloadAppliedRef.current = false; setFinals([]); setMicPartial(""); setSysPartial(""); return; } // Preloaded history arrived. Apply once, and PREPEND so any live lines that // were captured before the async preload resolved are kept (after the // older history), instead of being overwritten. if (preloadAppliedRef.current) return; preloadAppliedRef.current = true; setFinals((prev) => [...lines, ...prev]); }, [initialLines]); useEffect(() => { const unlistens: Array<() => void> = []; let stopped = false; const trackListen = (p: Promise<() => void>) => { p.then((u) => { if (stopped) { try { u(); } catch { // ignore } return; } unlistens.push(u); }).catch(() => {}); }; trackListen( onPartialTranscript(({ text, source }) => { if (source === "system") setSysPartial(text); else setMicPartial(text); }), ); trackListen( onFinalTranscript(({ text, source, segments }) => { const txt = text.trim(); if (!txt) return; const startMs = segments[0]?.startMs; setFinals((prev) => [...prev, { text: txt, source, startMs }]); if (source === "system") setSysPartial(""); else setMicPartial(""); }), ); return () => { stopped = true; unlistens.forEach((u) => { try { u(); } catch { // ignore } }); }; }, []); // Auto-scroll the container to the bottom whenever new text lands. useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [finals, micPartial, sysPartial]); return (
{finals.length === 0 && !micPartial && !sysPartial ? (
Listening…
) : null} {finals.map((line, i) => ( ))} {sysPartial ? ( ) : null} {micPartial ? ( ) : null}
); } /** * A single chat-style transcript bubble. The mic stream ("You") is aligned to * the right; the system-audio stream ("Them") sits on the left. Both use * neutral greys — side, not hue, is what distinguishes the speakers. In-flight * partials render dimmed. */ function ChatBubble({ source, text, pending = false, startMs, }: { source: Source; text: string; pending?: boolean; startMs?: number; }) { const isYou = source === "mic"; const label = isYou ? "You" : "Them"; const rowClass = `lt-row ${isYou ? "lt-row-you" : "lt-row-them"}${ pending ? " lt-row-pending" : "" }`; return (
{label} {typeof startMs === "number" && !pending ? ( {formatTimestamp(startMs)} ) : null} {text}
); }