/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom / src / transcribe / store.ts
200 lines · 5.9 KBBlameHistoryRaw
1// The transcript itself, and its persistence.
2//
3// Unlike the chat, which is deliberately ephemeral, a transcript is something
4// people come back to, so it outlives the call: it is kept in localStorage
5// under the room it belongs to and restored when you next enter that room.
6// This is per browser and never leaves it — the transcript is not shared with
7// the other participants (see the transcription section of network.ts), so
8// what is stored here is only ever what this browser transcribed.
9//
10// Because it does persist, it can be cleared, and the panel asks before doing
11// so. Note that transcripts of meetings sitting in localStorage in plain text
12// is a real consideration on a shared machine; the Clear button is the answer,
13// and the panel says as much.
15const KEY_PREFIX = 'commonroom:transcript:'
16const VERSION = 1
18/** Hard caps. The first bounds memory in a marathon call, the second bounds
19 * what is written back, since localStorage is a small shared budget. */
20const ITEM_CAP = 2000
21const MAX_STORED_CHARS = 200_000
23/** Consecutive results from one speaker join into a paragraph if they arrive
24 * within this long of each other. */
25const MERGE_GAP_MS = 15_000
26const MERGE_MAX_CHARS = 700
28/** Writes are coalesced: speech produces results every couple of seconds and
29 * the whole transcript is rewritten each time. */
30const SAVE_DEBOUNCE_MS = 2000
32export interface TranscriptItem {
33 /** Monotonic per-session sequence number; stable React key. */
34 seq: number
35 /** Whose speech this is (peer ID, or the local peer's own ID). */
36 peerId: string
37 name: string
38 text: string
39 /** Local time the paragraph started (epoch ms). */
40 time: number
43interface StoredItem {
44 p: string
45 n: string
46 t: string
47 ts: number
50export class TranscriptStore {
51 readonly items: TranscriptItem[] = []
52 private seq = 0
53 private key: string
54 private saveTimer: number | null = null
55 private lastAppendAt = 0
56 private lastAppendId: string | null = null
57 /** Audio sent to the transcription service for this room, in seconds. */
58 private seconds = 0
60 constructor(
61 roomId: string,
62 private onChange: () => void
63 ) {
64 this.key = KEY_PREFIX + roomId
65 this.load()
66 }
68 get audioSeconds(): number {
69 return this.seconds
70 }
72 addAudioSeconds(s: number) {
73 this.seconds += s
74 }
76 append(peerId: string, name: string, text: string) {
77 const t = text.trim()
78 if (!t) return
79 const now = Date.now()
80 const last = this.items[this.items.length - 1]
81 if (
82 last &&
83 this.lastAppendId === peerId &&
84 now - this.lastAppendAt < MERGE_GAP_MS &&
85 last.text.length < MERGE_MAX_CHARS
86 ) {
87 this.items[this.items.length - 1] = {
88 ...last,
89 name: name || last.name,
90 text: `${last.text} ${t}`
91 }
92 } else {
93 this.items.push({seq: this.seq++, peerId, name, text: t, time: now})
94 if (this.items.length > ITEM_CAP) {
95 this.items.splice(0, this.items.length - ITEM_CAP)
96 }
97 }
98 this.lastAppendAt = now
99 this.lastAppendId = peerId
100 this.scheduleSave()
101 this.onChange()
102 }
104 clear() {
105 this.items.length = 0
106 this.seconds = 0
107 this.lastAppendId = null
108 if (this.saveTimer !== null) clearTimeout(this.saveTimer)
109 this.saveTimer = null
110 try {
111 localStorage.removeItem(this.key)
112 } catch {
113 /* storage unavailable; nothing was written in the first place */
114 }
115 this.onChange()
116 }
118 /** Write out any pending changes now — on leaving the room, or on unload. */
119 flush() {
120 if (this.saveTimer === null) return
121 clearTimeout(this.saveTimer)
122 this.saveTimer = null
123 this.save()
124 }
126 private scheduleSave() {
127 if (this.saveTimer !== null) return
128 this.saveTimer = window.setTimeout(() => {
129 this.saveTimer = null
130 this.save()
131 }, SAVE_DEBOUNCE_MS)
132 }
134 private save() {
135 // Trim from the front until the payload fits: the recent end of a
136 // transcript is the part worth keeping.
137 let from = 0
138 let chars = this.items.reduce((n, i) => n + i.text.length + i.name.length, 0)
139 while (from < this.items.length && chars > MAX_STORED_CHARS) {
140 chars -= this.items[from].text.length + this.items[from].name.length
141 from++
142 }
143 const stored: StoredItem[] = this.items.slice(from).map(i => ({
144 p: i.peerId,
145 n: i.name,
146 t: i.text,
147 ts: i.time
148 }))
149 try {
150 localStorage.setItem(
151 this.key,
152 JSON.stringify({v: VERSION, seconds: this.seconds, items: stored})
153 )
154 } catch {
155 // Quota exceeded, or storage disabled. The transcript is still on
156 // screen and can be saved to a file; failing the call over it would be
157 // worse than losing the persistence.
158 }
159 }
161 private load() {
162 let raw: string | null
163 try {
164 raw = localStorage.getItem(this.key)
165 } catch {
166 return
167 }
168 if (!raw) return
169 let data: {v?: unknown; seconds?: unknown; items?: unknown}
170 try {
171 data = JSON.parse(raw)
172 } catch {
173 return
174 }
175 if (data.v !== VERSION || !Array.isArray(data.items)) return
176 if (typeof data.seconds === 'number' && data.seconds >= 0) {
177 this.seconds = data.seconds
178 }
179 for (const entry of data.items as StoredItem[]) {
180 if (typeof entry?.t !== 'string' || typeof entry.ts !== 'number') continue
181 this.items.push({
182 seq: this.seq++,
183 peerId: typeof entry.p === 'string' ? entry.p : '',
184 name: typeof entry.n === 'string' ? entry.n : '',
185 text: entry.t,
186 time: entry.ts
187 })
188 }
189 // A restored transcript never merges into: the next thing said belongs to
190 // a new sitting, whatever the clock says.
191 this.lastAppendId = null
192 }
195const stamp = (t: number): string =>
196 new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
198/** The transcript as plain text, for copying or saving. */
199export const formatTranscript = (items: TranscriptItem[]): string =>
200 items.map(i => `[${stamp(i.time)}] ${i.name}: ${i.text}`).join('\n\n')
moveopenescclose