Skip to content
← 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 · TypeScript
Raw
export 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')
}