// The transcript itself, and its persistence. // // Unlike the chat, which is deliberately ephemeral, a transcript is something // people come back to, so it outlives the call: it is kept in localStorage // under the room it belongs to and restored when you next enter that room. // This is per browser and never leaves it — the transcript is not shared with // the other participants (see the transcription section of network.ts), so // what is stored here is only ever what this browser transcribed. // // Because it does persist, it can be cleared, and the panel asks before doing // so. Note that transcripts of meetings sitting in localStorage in plain text // is a real consideration on a shared machine; the Clear button is the answer, // and the panel says as much. const KEY_PREFIX = 'commonroom:transcript:' const VERSION = 1 /** Hard caps. The first bounds memory in a marathon call, the second bounds * what is written back, since localStorage is a small shared budget. */ const ITEM_CAP = 2000 const MAX_STORED_CHARS = 200_000 /** Consecutive results from one speaker join into a paragraph if they arrive * within this long of each other. */ const MERGE_GAP_MS = 15_000 const MERGE_MAX_CHARS = 700 /** Writes are coalesced: speech produces results every couple of seconds and * the whole transcript is rewritten each time. */ const SAVE_DEBOUNCE_MS = 2000 export interface TranscriptItem { /** Monotonic per-session sequence number; stable React key. */ seq: number /** Whose speech this is (peer ID, or the local peer's own ID). */ peerId: string name: string text: string /** Local time the paragraph started (epoch ms). */ time: number } interface StoredItem { p: string n: string t: string ts: number } export class TranscriptStore { readonly items: TranscriptItem[] = [] private seq = 0 private key: string private saveTimer: number | null = null private lastAppendAt = 0 private lastAppendId: string | null = null /** Audio sent to the transcription service for this room, in seconds. */ private seconds = 0 constructor( roomId: string, private onChange: () => void ) { this.key = KEY_PREFIX + roomId this.load() } get audioSeconds(): number { return this.seconds } addAudioSeconds(s: number) { this.seconds += s } append(peerId: string, name: string, text: string) { const t = text.trim() if (!t) return const now = Date.now() const last = this.items[this.items.length - 1] if ( last && this.lastAppendId === peerId && now - this.lastAppendAt < MERGE_GAP_MS && last.text.length < MERGE_MAX_CHARS ) { this.items[this.items.length - 1] = { ...last, name: name || last.name, text: `${last.text} ${t}` } } else { this.items.push({seq: this.seq++, peerId, name, text: t, time: now}) if (this.items.length > ITEM_CAP) { this.items.splice(0, this.items.length - ITEM_CAP) } } this.lastAppendAt = now this.lastAppendId = peerId this.scheduleSave() this.onChange() } clear() { this.items.length = 0 this.seconds = 0 this.lastAppendId = null if (this.saveTimer !== null) clearTimeout(this.saveTimer) this.saveTimer = null try { localStorage.removeItem(this.key) } catch { /* storage unavailable; nothing was written in the first place */ } this.onChange() } /** Write out any pending changes now — on leaving the room, or on unload. */ flush() { if (this.saveTimer === null) return clearTimeout(this.saveTimer) this.saveTimer = null this.save() } private scheduleSave() { if (this.saveTimer !== null) return this.saveTimer = window.setTimeout(() => { this.saveTimer = null this.save() }, SAVE_DEBOUNCE_MS) } private save() { // Trim from the front until the payload fits: the recent end of a // transcript is the part worth keeping. let from = 0 let chars = this.items.reduce((n, i) => n + i.text.length + i.name.length, 0) while (from < this.items.length && chars > MAX_STORED_CHARS) { chars -= this.items[from].text.length + this.items[from].name.length from++ } const stored: StoredItem[] = this.items.slice(from).map(i => ({ p: i.peerId, n: i.name, t: i.text, ts: i.time })) try { localStorage.setItem( this.key, JSON.stringify({v: VERSION, seconds: this.seconds, items: stored}) ) } catch { // Quota exceeded, or storage disabled. The transcript is still on // screen and can be saved to a file; failing the call over it would be // worse than losing the persistence. } } private load() { let raw: string | null try { raw = localStorage.getItem(this.key) } catch { return } if (!raw) return let data: {v?: unknown; seconds?: unknown; items?: unknown} try { data = JSON.parse(raw) } catch { return } if (data.v !== VERSION || !Array.isArray(data.items)) return if (typeof data.seconds === 'number' && data.seconds >= 0) { this.seconds = data.seconds } for (const entry of data.items as StoredItem[]) { if (typeof entry?.t !== 'string' || typeof entry.ts !== 'number') continue this.items.push({ seq: this.seq++, peerId: typeof entry.p === 'string' ? entry.p : '', name: typeof entry.n === 'string' ? entry.n : '', text: entry.t, time: entry.ts }) } // A restored transcript never merges into: the next thing said belongs to // a new sitting, whatever the clock says. this.lastAppendId = null } } const stamp = (t: number): string => new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) /** The transcript as plain text, for copying or saving. */ export const formatTranscript = (items: TranscriptItem[]): string => items.map(i => `[${stamp(i.time)}] ${i.name}: ${i.text}`).join('\n\n')