Project Files
src / narrator.ts
/**
* @file narrator.ts
* @description Full narration pipeline for vibevoice-tts.
*
* Combines:
* - Style presets (storytelling, children, christmas-tale, etc.)
* - Mood modifiers (warm, mysterious, energetic, etc.)
* - Language packs (Polish abbreviations, foreign words, acronyms)
* - Narrative structure detection (dialogue, scenes, chapters)
*
* Pipeline stages:
* 1. Load style preset + apply mood modifier
* 2. Load language pack
* 3. Expand language-specific abbreviations
* 4. Insert phonetic hints for foreign words
* 5. Detect narrative structure
* 6. Insert narrative-aware pauses
* 7. Insert auto direction tags
* 8. Chunk the script
* 9. Generate TTS recommendations
*/
import type {
NarrateParams,
NarrateResult,
ScriptChunk,
TTSRecommendations,
PhoneticHint,
NarrativeElement,
StylePreset,
LanguageCode,
} from "./types";
import {
getStylePreset,
applyMoodToPreset,
getMoodModifier,
} from "./language-packs";
import { getLanguagePack } from "./language-packs";
import {
detectNarrativeStructure,
insertNarrativePauses,
getAutoDirections,
} from "./narrative";
import { countWords, chunkScript, splitParagraphs } from "./preparer";
// ---------------------------------------------------------------------------
// Phonetic Hint Markers
// ---------------------------------------------------------------------------
/**
* Insert phonetic hint markers for foreign words in the text.
* Uses [lang:pronunciation] format that the LLM can interpret.
*/
function insertPhoneticHints(
text: string,
language: LanguageCode,
): { text: string; hints: PhoneticHint[] } {
const pack = getLanguagePack(language);
const hints: PhoneticHint[] = [];
if (pack.foreignWords.length === 0 && pack.acronyms.length === 0) {
return { text, hints };
}
// Combine foreign words and acronyms
const allWords = [...new Set([...pack.foreignWords, ...pack.acronyms])];
// Sort by length (longest first) to avoid partial matches
allWords.sort((a, b) => b.length - a.length);
let result = text;
for (const word of allWords) {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`\\b${escaped}\\b`, "g");
let match: RegExpExecArray | null;
const matches: Array<{ index: number; text: string }> = [];
while ((match = regex.exec(result)) !== null) {
matches.push({ index: match.index, text: match[0] });
}
// Process matches in reverse to preserve indices
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const isAcronym = pack.acronyms.includes(m.text);
const pronunciation = isAcronym
? m.text.split("").join(" ") // Spell out letter by letter
: `[en]${m.text}[/en]`; // Mark as English
hints.push({
original: m.text,
pronunciation,
position: m.index,
reason: isAcronym ? "acronym" : "foreign-word",
});
// Insert hint marker
const hintMarker = ` [pronounce:${pronunciation}]`;
result =
result.slice(0, m.index + m.text.length) +
hintMarker +
result.slice(m.index + m.text.length);
}
}
return { text: result, hints };
}
// ---------------------------------------------------------------------------
// Abbreviation Expansion (Language-Specific)
// ---------------------------------------------------------------------------
/**
* Expand language-specific abbreviations in text.
*/
function expandLanguageAbbreviations(
text: string,
language: LanguageCode,
): string {
const pack = getLanguagePack(language);
let result = text;
// Sort by length (longest first) to avoid partial matches
const entries = Object.entries(pack.abbreviations).sort(
(a, b) => b[0].length - a[0].length,
);
for (const [abbr, expansion] of entries) {
const escaped = abbr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Only expand first occurrence (like the prepare tool)
const regex = new RegExp(escaped);
const firstMatch = regex.exec(result);
if (firstMatch) {
result =
result.slice(0, firstMatch.index) +
expansion +
result.slice(firstMatch.index + abbr.length);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Auto Direction Tag Insertion
// ---------------------------------------------------------------------------
/**
* Insert auto direction tags based on narrative structure.
*/
function insertAutoDirections(
text: string,
elements: ReadonlyArray<NarrativeElement>,
stylePreset: StylePreset,
): string {
if (!stylePreset.autoDirections || elements.length === 0) {
return text;
}
// Sort by position (reverse for insertion)
const sorted = [...elements].sort((a, b) => b.start - a.start);
let result = text;
for (const element of sorted) {
const directions = getAutoDirections(element.type);
if (directions.length === 0) continue;
// Insert direction tag before the element
const tag = `[${directions[0]}]`;
result =
result.slice(0, element.start) + tag + " " + result.slice(element.start);
}
return result;
}
// ---------------------------------------------------------------------------
// Full Narration Pipeline
// ---------------------------------------------------------------------------
/**
* Run the full narration preparation pipeline.
*
* This is the comprehensive version of prepareScript that adds:
* - Style presets (storytelling, children, christmas-tale, etc.)
* - Mood modifiers (warm, mysterious, energetic, etc.)
* - Language-specific abbreviation expansion
* - Phonetic hints for foreign words
* - Narrative structure detection
* - Auto direction tag insertion
*/
export function narrateScript(params: NarrateParams): NarrateResult {
const {
text,
style = "storytelling",
mood = "neutral",
language = "en",
chunkSize = 500,
expandAbbreviations = true,
phoneticHints = true,
detectStructure = true,
output = "script",
} = params;
const warnings: string[] = [];
let processed = text;
// Step 1: Get style preset + apply mood
const basePreset = getStylePreset(style);
const adjusted = applyMoodToPreset(basePreset, mood);
// Step 2: Expand language-specific abbreviations
if (expandAbbreviations) {
processed = expandLanguageAbbreviations(processed, language);
}
// Step 3: Insert phonetic hints for foreign words
const phoneticResult = phoneticHints
? insertPhoneticHints(processed, language)
: { text: processed, hints: [] as PhoneticHint[] };
processed = phoneticResult.text;
if (phoneticResult.hints.length > 0) {
warnings.push(
`Found ${phoneticResult.hints.length} foreign word(s)/acronym(s) with pronunciation hints.`,
);
}
// Step 4: Detect narrative structure
const structure = detectStructure ? detectNarrativeStructure(processed) : [];
if (structure.length > 0) {
const typeCounts = structure.reduce(
(acc, el) => {
acc[el.type] = (acc[el.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
warnings.push(
`Detected narrative structure: ${Object.entries(typeCounts)
.map(([t, c]) => `${c}x ${t}`)
.join(", ")}.`,
);
}
// Step 5: Insert narrative-aware pauses
if (detectStructure && structure.length > 0) {
processed = insertNarrativePauses(processed, structure, {
scenePauseLines: Math.round(adjusted.scenePause * 2),
chapterPauseLines: Math.round(adjusted.scenePause * 4),
climaxPauseLines: Math.round(adjusted.scenePause * 3),
transitionPauseLines: Math.round(adjusted.sentencePause * 2),
});
}
// Step 6: Split long paragraphs based on style
if (basePreset.maxSentences < 4) {
const splitParas = splitParagraphs(processed, basePreset.maxSentences);
processed = splitParas.join("\n\n");
}
// Step 7: Insert auto direction tags
if (basePreset.autoDirections && structure.length > 0) {
processed = insertAutoDirections(processed, structure, basePreset);
}
// Step 8: Chunk the script
const chunks = chunkScript(processed, chunkSize);
// Step 9: Generate TTS recommendations
const recommendations: TTSRecommendations = {
temperature: adjusted.temperature,
speed: adjusted.speed,
wpm: basePreset.wpm,
chunkSize,
};
// Calculate totals
const totalWords = countWords(processed);
const estimatedMinutes = totalWords / recommendations.wpm;
// Additional warnings
if (totalWords === 0) {
warnings.push("Prepared script is empty after processing.");
}
if (chunks.length > 1) {
warnings.push(
`Script split into ${chunks.length} chunks. Generate each separately and concatenate with 200ms crossfade.`,
);
}
if (totalWords > 4000) {
warnings.push(
`Very long script (${totalWords} words, ~${estimatedMinutes.toFixed(1)} min). ` +
"Consider splitting into multiple episodes.",
);
}
// Build the output
let script = processed;
if (output === "json") {
script = JSON.stringify(chunks, null, 2);
}
return {
original: text,
script,
chunks,
structure,
phoneticHints: phoneticResult.hints,
expressions: [], // TODO: auto-insert expression tags
speakerChanges: [], // TODO: dynamic speaker emotion timeline
recommendations,
appliedStyle: {
...basePreset,
speed: adjusted.speed,
temperature: adjusted.temperature,
sentencePause: adjusted.sentencePause,
paragraphPause: adjusted.paragraphPause,
scenePause: adjusted.scenePause,
},
warnings,
totalWords,
estimatedMinutes,
language,
};
}