// ── Audiobook cast serialization ──────────────────────────────────────────── // Live casting remains in audiobook.js; these deterministic export/migration // helpers have no casting-state side effects and can be loaded independently. function _abCastMarkdown(data) { const payload = data || {}; const segments = Array.isArray(payload.segments) ? payload.segments : []; const speakers = [...new Set(segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)))].sort(); const lines = [`# ${payload.title || 'Cast Script'}`, '']; const metaBits = []; if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' ')); metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`); metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`); lines.push(`*${metaBits.join(' · ')}*`, ''); if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, ''); let lastPage = null; for (const seg of segments) { const text = String(seg?.text || '').trim(); if (!text) continue; if (seg.page != null && seg.page !== lastPage) { lines.push('---', '', `## Page ${seg.page}`, ''); lastPage = seg.page; } if (seg.type === 'dialogue') { const speaker = String(seg.speaker || 'Narrator'); const tag = seg.emotion ? ` *(${seg.emotion})*` : ''; lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`); } else lines.push(text); lines.push(''); } return lines.join('\n'); } function _abStampSegmentPages(segs, pageMarks, text) { if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return; const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0)); const src = text || ''; if (!marks.length || !src) return; let markIdx = 0, searchPos = 0, cur = null; if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; } for (const s of segs) { const probe = String(s.text || '').trim().slice(0, 24); const at = probe ? src.indexOf(probe, searchPos) : -1; const pos = at >= 0 ? at : searchPos; if (at >= 0) searchPos = at + probe.length; while (markIdx < marks.length && pos >= marks[markIdx].offset) { cur = marks[markIdx].page + 1; markIdx++; } if (cur != null) s.page = cur; } } // Merge adjacent segments assigned to the same speaker without crossing a // page boundary. Kept separate from the live casting orchestration because it // is a deterministic post-cast normalization pass. function _audiobookMergeAdjacentSameSpeaker(segments) { const out = []; for (const s of segments) { const last = out[out.length - 1]; if (last && last.type === s.type && last.page === s.page) { const lastSpeaker = (last.speaker || 'Narrator').trim().toLowerCase(); const curSpeaker = (s.speaker || 'Narrator').trim().toLowerCase(); // Dialogue keeps its emotion tag distinct so one mood is not silently // discarded when two adjacent rows are combined. const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase(); const unknownSpeaker = !lastSpeaker || lastSpeaker === 'unknown' || lastSpeaker === 'unbekannt'; if (lastSpeaker === curSpeaker && sameEmotion && !(s.type === 'dialogue' && unknownSpeaker)) { const lastText = last.text.trimEnd(), curText = s.text.trimStart(); last.text = /[.!?…»«”"’']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText; continue; } } out.push(Object.assign({}, s)); } return out; } // Merge consecutive segments spoken by the same named character. This is a // dialogue-only normalization pass; narration is intentionally left alone. function _audiobookMergeAdjacentDialogue(segments) { const out = []; let merged = 0; for (const s of segments) { if (out.length) { const last = out[out.length - 1]; const spk = String(s.speaker || '').trim().toLowerCase(); const known = spk && !/^(unknown|unbekannt|narrator)$/i.test(spk); if (last.type === 'dialogue' && s.type === 'dialogue' && known && String(last.speaker || '').trim().toLowerCase() === spk) { const a = String(last.text || '').trimEnd(), b = String(s.text || '').trimStart(); last.text = a + (/[.!?…»«"']$/.test(a) ? '\n\n' : ' ') + b; if (!String(last.emotion || '').trim() && String(s.emotion || '').trim()) last.emotion = s.emotion; merged++; continue; } } out.push(s); } return { segments: out, merged }; } window.TTVC = window.TTVC || {}; window.TTVC.audiobook = window.TTVC.audiobook || {}; window.TTVC.audiobook.cast = { toMarkdown: _abCastMarkdown, stampPages: _abStampSegmentPages, mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker, mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue, };