← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
src/lib/transcript.ts
65 lines · 2.6 KB · TypeScriptexport type TranscriptItem = {
speaker?: { source?: string; attribution?: string; diarization_label?: string; name?: string }
text?: string
start_time?: string
end_time?: string
}
/** Human label for a transcript line: name, then diarization bucket, then me/them, then audio source. */
/**
* Granola can append meeting-app badges to speaker names ("Jane Doe External unfamiliar", "Jane Doe Unverified").
* Strip those trailing badges for readable text; raw items keep the original name.
*/
export function cleanSpeakerName(name: string): string {
let n = name.trim()
for (let i = 0; i < 3; i++) {
const next = n.replace(/\s+(?:external(?:\s+unfamiliar)?|unfamiliar|unverified|guest)$/i, '').trim()
if (next === n || !next) break
n = next
}
return n || name
}
export function speakerLabel(item: TranscriptItem): string {
const s = item.speaker ?? {}
if (s.name) return cleanSpeakerName(s.name)
if (s.diarization_label) return s.diarization_label
if (s.attribution === 'me') return 'Me'
if (s.attribution === 'them') return 'Them'
if (s.source === 'microphone') return 'Microphone'
if (s.source === 'speaker') return 'Other participants'
return 'Unknown'
}
function clock(iso: string | undefined, origin: number | null): string {
if (!iso) return ''
const t = Date.parse(iso)
if (!Number.isFinite(t)) return iso
if (origin === null) return iso
const secs = Math.max(0, Math.round((t - origin) / 1000))
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
return `${h ? `${h}:` : ''}${String(m).padStart(h ? 2 : 1, '0')}:${String(s).padStart(2, '0')}`
}
/**
* Plain-text transcript: one line per item, `[m:ss] Speaker: text`, with consecutive lines by the
* same speaker merged. Offsets are relative to the first item's start_time.
*/
export function transcriptToText(items: Array<TranscriptItem>, opts: { timestamps?: boolean; merge?: boolean } = {}): string {
const timestamps = opts.timestamps ?? true
const merge = opts.merge ?? true
const firstStart = items.find((i) => i.start_time && Number.isFinite(Date.parse(i.start_time)))?.start_time
const origin = firstStart ? Date.parse(firstStart) : null
const lines: Array<{ label: string; at: string; text: string }> = []
for (const item of items) {
const text = (item.text ?? '').trim()
if (!text) continue
const label = speakerLabel(item)
const last = lines[lines.length - 1]
if (merge && last && last.label === label) last.text += ` ${text}`
else lines.push({ label, at: clock(item.start_time, origin), text })
}
return lines.map((l) => `${timestamps && l.at ? `[${l.at}] ` : ''}${l.label}: ${l.text}`).join('\n')
}