/** * Transcript grouping: sentence merging, speaker detection, and chapter support. * Ported and simplified from Defuddle's YouTube extractor. * * Raw segments (2-3 second fragments) are grouped into readable paragraphs: * - Sentence boundaries: merge until sentence-ending punctuation (.!?) * - Speaker turns: detect ">>" markers from YouTube auto-captions * - Chapters: optional chapter headings inserted at appropriate timestamps */ // Include CJK sentence-ending punctuation: 。!? (fullwidth: .!?) const SENTENCE_END = /[.!?\u3002\uFF01\uFF1F\uFF0E]["'\u2019\u201D)]*\s*$/; const QUESTION_END = /[?\uFF1F]["'\u2019\u201D)]*\s*$/; const TRANSCRIPT_GROUP_GAP_SECONDS = 20; const TURN_MERGE_MAX_WORDS = 80; const TURN_MERGE_MAX_SPAN_SECONDS = 45; const SHORT_UTTERANCE_MAX_WORDS = 3; const FIRST_GROUP_MERGE_MIN_WORDS = 8; function countWords(text) { return text.split(/\s+/).filter(Boolean).length; } /** * Group raw transcript segments into readable blocks. * If speaker markers (>>) are present, groups by speaker turn. * Otherwise, groups by sentence boundaries. */ export function groupTranscriptSegments(segments) { if (segments.length === 0) return []; const hasSpeakerMarkers = segments.some(s => /^>>/.test(s.text)); return hasSpeakerMarkers ? groupBySpeaker(segments) : groupBySentence(segments); } /** * Format grouped segments + chapters into a final text output. */ export function formatGroupedTranscript(segments, chapters = []) { const sortedChapters = [...chapters].sort((a, b) => a.start - b.start); let chapterIdx = 0; const rows = []; const textParts = []; for (const segment of segments) { // Insert chapter headings while (chapterIdx < sortedChapters.length && sortedChapters[chapterIdx].start <= segment.start) { const title = sortedChapters[chapterIdx].title; rows.push({ timestamp: fmtTime(sortedChapters[chapterIdx].start), speaker: '', text: `[Chapter] ${title}` }); if (textParts.length > 0) textParts.push(''); textParts.push(`### ${title}`); textParts.push(''); chapterIdx++; } const timestamp = fmtTime(segment.start); const speaker = segment.speaker !== undefined ? `Speaker ${segment.speaker + 1}` : ''; rows.push({ timestamp, speaker, text: segment.text }); if (segment.speakerChange && textParts.length > 0) { textParts.push(''); } textParts.push(`${timestamp} ${segment.text}`); } return { rows, plainText: textParts.join('\n') }; } function fmtTime(sec) { const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); if (h > 0) { return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } return `${m}:${String(s).padStart(2, '0')}`; } // ── Sentence grouping ───────────────────────────────────────────────────── // Max time span (seconds) for a single group when no sentence boundaries are found. // Prevents unbounded merging for languages without punctuation (Chinese, etc.). const MAX_GROUP_SPAN_SECONDS = 30; function groupBySentence(segments) { const groups = []; let buffer = ''; let bufferStart = 0; let lastStart = 0; const flush = () => { if (buffer.trim()) { groups.push({ start: bufferStart, text: buffer.trim(), speakerChange: false }); buffer = ''; } }; for (const seg of segments) { // Large gap between segments — always flush if (buffer && seg.start - lastStart > TRANSCRIPT_GROUP_GAP_SECONDS) { flush(); } // Time-based flush: prevent unbounded groups for unpunctuated languages if (buffer && seg.start - bufferStart > MAX_GROUP_SPAN_SECONDS) { flush(); } if (!buffer) bufferStart = seg.start; buffer += (buffer ? ' ' : '') + seg.text; lastStart = seg.start; if (SENTENCE_END.test(seg.text)) flush(); } flush(); return groups; } // ── Speaker grouping ────────────────────────────────────────────────────── function groupBySpeaker(segments) { const turns = []; let currentTurn = null; let speakerIndex = -1; let prevSegText = ''; for (const seg of segments) { const isSpeakerChange = /^>>/.test(seg.text); const cleanText = seg.text.replace(/^>>\s*/, '').replace(/^-\s+/, ''); const prevEndsWithComma = /,\s*$/.test(prevSegText); const prevEndedSentence = (SENTENCE_END.test(prevSegText) || !prevSegText) && !prevEndsWithComma; const isRealSpeakerChange = isSpeakerChange && prevEndedSentence; if (isRealSpeakerChange) { if (currentTurn) turns.push(currentTurn); speakerIndex = (speakerIndex + 1) % 2; currentTurn = { start: seg.start, segments: [{ start: seg.start, text: cleanText }], speakerChange: true, speaker: speakerIndex, }; } else { if (!currentTurn) { currentTurn = { start: seg.start, segments: [], speakerChange: false }; } currentTurn.segments.push({ start: seg.start, text: cleanText }); } prevSegText = cleanText; } if (currentTurn) turns.push(currentTurn); splitAffirmativeTurns(turns); const groups = []; for (const turn of turns) { const sentenceGroups = turn.speaker === undefined ? groupBySentence(turn.segments) : mergeSentenceGroupsWithinTurn(groupBySentence(turn.segments)); for (let i = 0; i < sentenceGroups.length; i++) { groups.push({ ...sentenceGroups[i], speakerChange: i === 0 && turn.speakerChange, speaker: turn.speaker, }); } } return groups; } function splitAffirmativeTurns(turns) { const affirmativePattern = /^(mhm|yeah|yes|yep|right|okay|ok|absolutely|sure|exactly|uh-huh|mm-hmm)[.!,]?\s+/i; for (let i = 0; i < turns.length; i++) { const turn = turns[i]; if (turn.speaker === undefined || turn.segments.length === 0) continue; const firstSeg = turn.segments[0]; const match = affirmativePattern.exec(firstSeg.text); if (!match) continue; if (/,\s*$/.test(match[0])) continue; const remainder = firstSeg.text.slice(match[0].length).trim(); const restSegments = turn.segments.slice(1); const restWords = countWords(remainder) + restSegments.reduce((sum, s) => sum + countWords(s.text), 0); if (restWords < 30) continue; const affirmativeText = match[0].trimEnd(); const newRestSegments = remainder ? [{ start: firstSeg.start, text: remainder }, ...restSegments] : restSegments; turns.splice(i, 1, { start: turn.start, segments: [{ start: firstSeg.start, text: affirmativeText }], speakerChange: turn.speakerChange, speaker: turn.speaker, }, { start: newRestSegments[0].start, segments: newRestSegments, speakerChange: true, speaker: turn.speaker === 0 ? 1 : 0, }); i++; } } function mergeSentenceGroupsWithinTurn(groups) { if (groups.length <= 1) return groups; const merged = []; let current = { ...groups[0] }; let currentIsFirstInTurn = true; for (let i = 1; i < groups.length; i++) { const next = groups[i]; if (shouldMergeSentenceGroups(current, next, currentIsFirstInTurn)) { current.text = `${current.text} ${next.text}`; continue; } merged.push(current); current = { ...next }; currentIsFirstInTurn = false; } merged.push(current); return merged; } function shouldMergeSentenceGroups(current, next, currentIsFirstInTurn) { const currentWords = countWords(current.text); const nextWords = countWords(next.text); if (isShortStandaloneUtterance(current.text, currentWords) || isShortStandaloneUtterance(next.text, nextWords)) return false; if (currentIsFirstInTurn && currentWords < FIRST_GROUP_MERGE_MIN_WORDS) return false; if (QUESTION_END.test(current.text) || QUESTION_END.test(next.text)) return false; if (currentWords + nextWords > TURN_MERGE_MAX_WORDS) return false; if (next.start - current.start > TURN_MERGE_MAX_SPAN_SECONDS) return false; return true; } function isShortStandaloneUtterance(text, words) { const w = words ?? countWords(text); return w > 0 && w <= SHORT_UTTERANCE_MAX_WORDS && SENTENCE_END.test(text); }