Project Files
dist / narrative.js
"use strict";
/**
* @file narrative.ts
* @description Narrative structure detection for vibevoice-tts.
*
* Analyzes text to identify:
* - Dialogue vs narration segments
* - Scene changes and transitions
* - Chapter/section boundaries
* - Emotional intensity (climax detection)
*
* Results are used to insert appropriate pauses and direction tags.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectNarrativeStructure = detectNarrativeStructure;
exports.insertNarrativePauses = insertNarrativePauses;
exports.getAutoDirections = getAutoDirections;
// ---------------------------------------------------------------------------
// Regex Patterns for Narrative Detection
// ---------------------------------------------------------------------------
// Speaker prefix patterns (dialogue detection)
const RE_SPEAKER_PREFIX = /^(Speaker\s*\d+)\s*:\s*/im;
const RE_QUOTED_SPEECH = /["\u201e\u201d\u00bb\u00ab].*?["\u201e\u201d\u00bb\u00ab]/g;
// Chapter/section markers
const RE_CHAPTER_MARKER = /^(Chapter|Rozdział|Part|Część|Book|Księga)\s+(\d+|[IVXLC]+)/im;
const RE_SECTION_MARKER = /^(Section|Sekcja|Act|Akt|Scene|Scena)\s+(\d+|[IVXLC]+)/im;
const RE_NUMBERED_HEADING = /^#{1,3}\s+\d+[\.\)]\s+/m;
// Scene change indicators
const RE_TIME_SHIFT = /\b(Meanwhile|Tymczasem|Later|Później|The next day|Następnego dnia|Years later|Lata później|Suddenly|Nagle|At that moment|W tej chwili)\b/gi;
const RE_LOCATION_SHIFT = /\b(In (?:the )?(?:city|forest|castle|room|house|garden|mountain|river|sea|ocean|desert|village|town)|W (?:lesie|zamku|pokoju|domu|ogrodzie|górach|rzece|morzu|oceanie|puustyni|wiosce|mieście))\b/gi;
// Emotional intensity markers (climax detection)
const RE_INTENSITY_HIGH = /\b(screamed|krzyczał|shouted|wrzasnął|yelled|krzyknął|roared|ryknął|thundered|grzmiał|exploded|wybuchł|desperate|zdesperowany|terrified|przerażony|furious|wściekły|panicked|spanikował)\b/gi;
const RE_INTENSITY_LOW = /\b(whispered|szepnął|murmured|mruknął|softly|delikatnie|gently|łagodnie|quietly|cicho|calmly|spokojnie|peacefully|spokojnie)\b/gi;
// Transition phrases
const RE_TRANSITION = /\b(However|Jednak|Nevertheless|Niemniej|Therefore|Dlatego|Thus|Tak więc|Meanwhile|Tymczasem|Meanwhile|In contrast|W przeciwieństwie|On the other hand|Z drugiej strony|As a result|W rezultacie|Consequently|W konsekwencji)\b/gi;
// ---------------------------------------------------------------------------
// Narrative Structure Detection
// ---------------------------------------------------------------------------
/**
* Detect narrative structure elements in text.
* Returns an array of detected elements with positions and confidence scores.
*/
function detectNarrativeStructure(text) {
const elements = [];
// Detect chapter/section boundaries
detectChapters(text, elements);
// Detect scene changes
detectSceneChanges(text, elements);
// Detect dialogue segments
detectDialogue(text, elements);
// Detect transitions
detectTransitions(text, elements);
// Detect emotional intensity (climax)
detectClimax(text, elements);
// Sort by start position
elements.sort((a, b) => a.start - b.start);
return elements;
}
/**
* Detect chapter and section boundaries.
*/
function detectChapters(text, elements) {
const lines = text.split("\n");
let charOffset = 0;
for (const line of lines) {
const chapterMatch = RE_CHAPTER_MARKER.exec(line);
if (chapterMatch) {
elements.push({
type: "chapter",
start: charOffset,
end: charOffset + line.length,
label: chapterMatch[0].trim(),
confidence: 0.95,
});
}
const sectionMatch = RE_SECTION_MARKER.exec(line);
if (sectionMatch) {
elements.push({
type: "scene-change",
start: charOffset,
end: charOffset + line.length,
label: sectionMatch[0].trim(),
confidence: 0.85,
});
}
const headingMatch = RE_NUMBERED_HEADING.exec(line);
if (headingMatch) {
elements.push({
type: "chapter",
start: charOffset,
end: charOffset + line.length,
label: line.trim(),
confidence: 0.8,
});
}
charOffset += line.length + 1; // +1 for newline
}
}
/**
* Detect scene changes based on time/location shifts.
*/
function detectSceneChanges(text, elements) {
let match;
// Time shifts
const timeRe = new RegExp(RE_TIME_SHIFT.source, RE_TIME_SHIFT.flags);
while ((match = timeRe.exec(text)) !== null) {
elements.push({
type: "scene-change",
start: match.index,
end: match.index + match[0].length,
label: `time-shift: "${match[0]}"`,
confidence: 0.7,
});
}
// Location shifts
const locRe = new RegExp(RE_LOCATION_SHIFT.source, RE_LOCATION_SHIFT.flags);
while ((match = locRe.exec(text)) !== null) {
elements.push({
type: "scene-change",
start: match.index,
end: match.index + match[0].length,
label: `location-shift: "${match[0]}"`,
confidence: 0.65,
});
}
// Double blank lines as implicit scene changes
const doubleBlankRe = /\n{3,}/g;
while ((match = doubleBlankRe.exec(text)) !== null) {
elements.push({
type: "scene-change",
start: match.index,
end: match.index + match[0].length,
label: "paragraph-break",
confidence: 0.5,
});
}
}
/**
* Detect dialogue segments (Speaker N: patterns and quoted speech).
*/
function detectDialogue(text, elements) {
const lines = text.split("\n");
let charOffset = 0;
let dialogueStart = -1;
let dialogueEnd = -1;
for (const line of lines) {
const trimmed = line.trim();
if (RE_SPEAKER_PREFIX.test(trimmed)) {
if (dialogueStart === -1) {
dialogueStart = charOffset;
}
dialogueEnd = charOffset + line.length;
}
else if (trimmed.length === 0 && dialogueStart !== -1) {
// End of dialogue block
if (dialogueEnd - dialogueStart > 10) {
elements.push({
type: "dialogue",
start: dialogueStart,
end: dialogueEnd,
confidence: 0.8,
});
}
dialogueStart = -1;
dialogueEnd = -1;
}
charOffset += line.length + 1;
}
// Close any remaining dialogue
if (dialogueStart !== -1 && dialogueEnd - dialogueStart > 10) {
elements.push({
type: "dialogue",
start: dialogueStart,
end: dialogueEnd,
confidence: 0.8,
});
}
// Detect quoted speech as dialogue
const quoteRe = new RegExp(RE_QUOTED_SPEECH.source, RE_QUOTED_SPEECH.flags);
let match;
while ((match = quoteRe.exec(text)) !== null) {
// Only add if not already covered by speaker dialogue
const overlaps = elements.some((e) => e.type === "dialogue" &&
match.index >= e.start &&
match.index + match[0].length <= e.end);
if (!overlaps) {
elements.push({
type: "dialogue",
start: match.index,
end: match.index + match[0].length,
label: "quoted-speech",
confidence: 0.6,
});
}
}
}
/**
* Detect transition phrases.
*/
function detectTransitions(text, elements) {
const re = new RegExp(RE_TRANSITION.source, RE_TRANSITION.flags);
let match;
while ((match = re.exec(text)) !== null) {
elements.push({
type: "transition",
start: match.index,
end: match.index + match[0].length,
label: `transition: "${match[0]}"`,
confidence: 0.6,
});
}
}
/**
* Detect emotional intensity (climax) markers.
*/
function detectClimax(text, elements) {
// High intensity
const highRe = new RegExp(RE_INTENSITY_HIGH.source, RE_INTENSITY_HIGH.flags);
let match;
while ((match = highRe.exec(text)) !== null) {
elements.push({
type: "climax",
start: match.index,
end: match.index + match[0].length,
label: `high-intensity: "${match[0]}"`,
confidence: 0.7,
});
}
// Low intensity (contrast)
const lowRe = new RegExp(RE_INTENSITY_LOW.source, RE_INTENSITY_LOW.flags);
while ((match = lowRe.exec(text)) !== null) {
elements.push({
type: "narration",
start: match.index,
end: match.index + match[0].length,
label: `low-intensity: "${match[0]}"`,
confidence: 0.5,
});
}
}
// ---------------------------------------------------------------------------
// Narrative-Aware Pause Insertion
// ---------------------------------------------------------------------------
/**
* Insert pauses based on detected narrative structure.
* Returns the text with additional empty lines for pauses.
*/
function insertNarrativePauses(text, elements, options) {
const { scenePauseLines = 2, chapterPauseLines = 4, climaxPauseLines = 3, transitionPauseLines = 1, } = options;
// Sort elements by position (reverse for insertion)
const sorted = [...elements].sort((a, b) => b.start - a.start);
let result = text;
for (const element of sorted) {
let pauseLines = 0;
switch (element.type) {
case "chapter":
pauseLines = chapterPauseLines;
break;
case "scene-change":
pauseLines = scenePauseLines;
break;
case "climax":
pauseLines = climaxPauseLines;
break;
case "transition":
pauseLines = transitionPauseLines;
break;
default:
continue;
}
// Insert empty lines at the element's position
const pause = "\n".repeat(pauseLines);
result = result.slice(0, element.end) + pause + result.slice(element.end);
}
// Clean up excessive blank lines
result = result.replace(/\n{5,}/g, "\n\n\n\n");
return result;
}
/**
* Get auto direction tags for a narrative element type.
*/
function getAutoDirections(type) {
switch (type) {
case "chapter":
return ["slower", "emphasis"];
case "scene-change":
return ["pause", "slower"];
case "climax":
return ["emphasis", "louder"];
case "transition":
return ["slower"];
case "dialogue":
return [];
case "narration":
return ["softer"];
default:
return [];
}
}
//# sourceMappingURL=narrative.js.map