// Transcript timeline assembly and rendering, shared by the offline // `transcribe` subcommand and live transcription during recording. export type Item = | {timeMs: number; type: 'speech'; speaker: string; text: string; endMs: number} | {timeMs: number; type: 'chat'; speaker: string; text: string} | {timeMs: number; type: 'system'; text: string} export interface RenderMeta { room: string startedAtMs: number endedAtMs: number | null speakers: string[] engine: string model: string | null } /** Merge consecutive same-speaker utterances separated by < this many ms. */ export const TURN_GAP_MS = 3000 export const localTime = (ms: number): string => { const d = new Date(ms) const p = (n: number) => String(n).padStart(2, '0') return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` } export const localDateTime = (ms: number): string => { const d = new Date(ms) const p = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${localTime(ms)}` } /** Sort by time and merge adjacent same-speaker utterances (with nothing in * between) into conversational turns. */ export const mergeTurns = (items: Item[]): Item[] => { const sorted = [...items].sort((a, b) => a.timeMs - b.timeMs) const merged: Item[] = [] for (const item of sorted) { const prev = merged[merged.length - 1] if ( item.type === 'speech' && prev?.type === 'speech' && prev.speaker === item.speaker && item.timeMs - prev.endMs < TURN_GAP_MS ) { prev.text += ' ' + item.text prev.endMs = item.endMs } else { merged.push({...item}) } } return merged } export const renderMarkdown = (meta: RenderMeta, merged: Item[]): string => { const md: string[] = [] md.push(`# Transcript: ${meta.room}`) md.push('') md.push( `- **Recorded:** ${localDateTime(meta.startedAtMs)}${ meta.endedAtMs ? ` – ${localTime(meta.endedAtMs)}` : ' – (in progress)' }` ) md.push(`- **Speakers:** ${meta.speakers.join(', ') || '(none yet)'}`) md.push( `- **Transcribed with:** ${meta.engine}${meta.model ? `, model ${meta.model}` : ''}` ) md.push('') md.push('---') md.push('') for (const item of merged) { const t = localTime(item.timeMs) if (item.type === 'speech') { md.push(`**[${t}] ${item.speaker}:** ${item.text}`) } else if (item.type === 'chat') { md.push(`> [${t}] 💬 **${item.speaker}:** ${item.text}`) } else { md.push(`*[${t}] — ${item.text}*`) } md.push('') } if (merged.length === 0) md.push('*(nothing yet)*', '') return md.join('\n') } export const renderJson = (meta: RenderMeta, merged: Item[]): string => JSON.stringify( { room: meta.room, startedAt: new Date(meta.startedAtMs).toISOString(), endedAt: meta.endedAtMs ? new Date(meta.endedAtMs).toISOString() : null, engine: meta.engine, model: meta.model, items: merged.map(item => ({ time: new Date(item.timeMs).toISOString(), ...item, timeMs: undefined, endMs: undefined })) }, null, 2 ) + '\n'