1// Transcript timeline assembly and rendering, shared by the offline
2// `transcribe` subcommand and live transcription during recording.
4export type Item =
5 | {timeMs: number; type: 'speech'; speaker: string; text: string; endMs: number}
6 | {timeMs: number; type: 'chat'; speaker: string; text: string}
7 | {timeMs: number; type: 'system'; text: string}
9export interface RenderMeta {
10 room: string
11 startedAtMs: number
12 endedAtMs: number | null
13 speakers: string[]
14 engine: string
15 model: string | null
16}
18/** Merge consecutive same-speaker utterances separated by < this many ms. */
19export const TURN_GAP_MS = 3000
21export const localTime = (ms: number): string => {
22 const d = new Date(ms)
23 const p = (n: number) => String(n).padStart(2, '0')
24 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
25}
27export const localDateTime = (ms: number): string => {
28 const d = new Date(ms)
29 const p = (n: number) => String(n).padStart(2, '0')
30 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${localTime(ms)}`
31}
33/** Sort by time and merge adjacent same-speaker utterances (with nothing in
34 * between) into conversational turns. */
35export const mergeTurns = (items: Item[]): Item[] => {
36 const sorted = [...items].sort((a, b) => a.timeMs - b.timeMs)
37 const merged: Item[] = []
38 for (const item of sorted) {
39 const prev = merged[merged.length - 1]
40 if (
41 item.type === 'speech' &&
42 prev?.type === 'speech' &&
43 prev.speaker === item.speaker &&
44 item.timeMs - prev.endMs < TURN_GAP_MS
45 ) {
46 prev.text += ' ' + item.text
47 prev.endMs = item.endMs
48 } else {
49 merged.push({...item})
50 }
51 }
52 return merged
53}
55export const renderMarkdown = (meta: RenderMeta, merged: Item[]): string => {
56 const md: string[] = []
57 md.push(`# Transcript: ${meta.room}`)
58 md.push('')
59 md.push(
60 `- **Recorded:** ${localDateTime(meta.startedAtMs)}${
61 meta.endedAtMs ? ` – ${localTime(meta.endedAtMs)}` : ' – (in progress)'
62 }`
63 )
64 md.push(`- **Speakers:** ${meta.speakers.join(', ') || '(none yet)'}`)
65 md.push(
66 `- **Transcribed with:** ${meta.engine}${meta.model ? `, model ${meta.model}` : ''}`
67 )
68 md.push('')
69 md.push('---')
70 md.push('')
71 for (const item of merged) {
72 const t = localTime(item.timeMs)
73 if (item.type === 'speech') {
74 md.push(`**[${t}] ${item.speaker}:** ${item.text}`)
75 } else if (item.type === 'chat') {
76 md.push(`> [${t}] 💬 **${item.speaker}:** ${item.text}`)
77 } else {
78 md.push(`*[${t}] — ${item.text}*`)
79 }
80 md.push('')
81 }
82 if (merged.length === 0) md.push('*(nothing yet)*', '')
83 return md.join('\n')
84}
86export const renderJson = (meta: RenderMeta, merged: Item[]): string =>
87 JSON.stringify(
88 {
89 room: meta.room,
90 startedAt: new Date(meta.startedAtMs).toISOString(),
91 endedAt: meta.endedAtMs ? new Date(meta.endedAtMs).toISOString() : null,
92 engine: meta.engine,
93 model: meta.model,
94 items: merged.map(item => ({
95 time: new Date(item.timeMs).toISOString(),
96 ...item,
97 timeMs: undefined,
98 endMs: undefined
99 }))
100 },
101 null,
102 2
103 ) + '\n'