Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
6.0 KiB
JavaScript
143 lines
6.0 KiB
JavaScript
/**
|
|
* Audiobook script helpers (#1217) — pure, testable text→data functions for the
|
|
* Cast panel, live stats bar, and pre-flight validation. No React, no i18n: the
|
|
* UI supplies labels; these only compute.
|
|
*
|
|
* The bracket-token regexes here MIRROR the canonical grammar in
|
|
* `longformParser.js` / `backend/services/longform_parser.py` (voice + pause +
|
|
* SSML-lite). They exist for name extraction / stats / warnings only — the
|
|
* golden-corpus parser stays the single grammar source of truth for rendering.
|
|
*/
|
|
import { TAGS } from './constants';
|
|
|
|
// [voice:NAME] — content excludes BOTH brackets (mirrors _VOICE_RE). Global.
|
|
const VOICE_RE = /\[voice:([^\][]*)\]/g;
|
|
// H1 chapter heading (mirrors _HEADING_RE): `# <non-space>…`, multiline.
|
|
const HEADING_RE = /^[ \t]*#[ \t]+(\S.*)$/gm;
|
|
// Any bracket token — used to strip markup before the word count and to
|
|
// enumerate tokens for validation. Non-greedy, no nested brackets.
|
|
const BRACKET_RE = /\[[^\][]*\]/g;
|
|
|
|
// Recognized (non-voice) bracket tokens, so validation can flag the rest.
|
|
const PAUSE_TOKEN_RE = /^\[\s*pause(?:\s+\d+(?:\.\d+)?(?:\s*(?:ms|s))?)?\s*\]$/i;
|
|
const SSML_TOKEN_RE = /^\[\/?(?:slow|fast|emphasis|spell)\]$/i;
|
|
const REACTION_TOKENS = new Set(TAGS.map((s) => s.toLowerCase()));
|
|
|
|
/** ~ words a listener hears per minute at an audiobook narration pace. */
|
|
export const AUDIOBOOK_WPM = 155;
|
|
|
|
/**
|
|
* Distinct `[voice:NAME]` names present in the script, in first-seen order.
|
|
* Empty `[voice:]` (reset-to-default) is skipped. Names are trimmed.
|
|
*/
|
|
export function parseCastNames(text) {
|
|
if (!text) return [];
|
|
const seen = new Set();
|
|
const names = [];
|
|
const re = new RegExp(VOICE_RE.source, VOICE_RE.flags);
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
const name = (m[1] || '').trim();
|
|
if (re.lastIndex === m.index) re.lastIndex++; // zero-width guard
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
names.push(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** Text with every bracket markup token removed (for a spoken-word count). */
|
|
function stripMarkup(text) {
|
|
return (text || '').replace(BRACKET_RE, ' ');
|
|
}
|
|
|
|
/**
|
|
* Live script stats: chapters (`# ` H1 count, ≥1 so a title-less script still
|
|
* reads as one chapter), spoken word count (whitespace split, markup stripped),
|
|
* and an estimated runtime in seconds at {@link AUDIOBOOK_WPM}.
|
|
*/
|
|
export function scriptStats(text) {
|
|
const norm = (text || '').replace(/\r\n?/g, '\n');
|
|
const headings = norm.match(new RegExp(HEADING_RE.source, HEADING_RE.flags)) || [];
|
|
// Spoken words only: drop `# heading` lines (titles aren't narrated as body,
|
|
// mirroring the parser) AND bracket markup, then whitespace-split.
|
|
const spoken = stripMarkup(norm.replace(new RegExp(HEADING_RE.source, HEADING_RE.flags), ' '));
|
|
const words = spoken.split(/\s+/).filter(Boolean).length;
|
|
const chapters = Math.max(1, headings.length);
|
|
const runtimeSec = words > 0 ? (words / AUDIOBOOK_WPM) * 60 : 0;
|
|
return { chapters, words, runtimeSec };
|
|
}
|
|
|
|
/** Format seconds as a clock string: `H:MM` (≥1h) or `M:SS` (<1h). Pure. */
|
|
export function formatRuntimeClock(sec) {
|
|
const total = Math.max(0, Math.round(sec));
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}`;
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Pre-flight warnings for a script (non-blocking hints). Returns an array of
|
|
* `{ type, ... }`:
|
|
* - `unknown_voice` `{ name }` — a `[voice:NAME]` neither mapped in the cast
|
|
* nor an exact profile id → will use the default voice.
|
|
* - `empty_chapter` `{ title }` — a `# heading` with no spoken body.
|
|
* - `unknown_tag` `{ tag }` — a bracket token outside the known grammar
|
|
* (voice/pause/SSML-lite/reactions) → read aloud literally.
|
|
*
|
|
* @param {string} text
|
|
* @param {object} opts
|
|
* @param {Set<string>|string[]} [opts.mappedNames] cast names with a voice mapped
|
|
* @param {Set<string>|string[]} [opts.profileIds] known profile ids (exact match)
|
|
*/
|
|
export function validateScript(text, { mappedNames = [], profileIds = [] } = {}) {
|
|
const warnings = [];
|
|
const norm = (text || '').replace(/\r\n?/g, '\n');
|
|
const mapped = mappedNames instanceof Set ? mappedNames : new Set(mappedNames);
|
|
const ids = profileIds instanceof Set ? profileIds : new Set(profileIds);
|
|
|
|
// 1. Unknown voices — a name with no cast mapping and no exact profile match.
|
|
for (const name of parseCastNames(norm)) {
|
|
if (!mapped.has(name) && !ids.has(name)) warnings.push({ type: 'unknown_voice', name });
|
|
}
|
|
|
|
// 2. Empty chapters — a `# heading` whose body has no spoken text. Split on
|
|
// headings (mirroring the parser) and check each body for any word.
|
|
const heads = [];
|
|
const hre = new RegExp(HEADING_RE.source, HEADING_RE.flags);
|
|
let hm;
|
|
while ((hm = hre.exec(norm)) !== null) {
|
|
heads.push({ index: hm.index, end: hm.index + hm[0].length, title: (hm[1] || '').trim() });
|
|
if (hre.lastIndex === hm.index) hre.lastIndex++;
|
|
}
|
|
for (let i = 0; i < heads.length; i++) {
|
|
const bodyEnd = i + 1 < heads.length ? heads[i + 1].index : norm.length;
|
|
const body = norm.slice(heads[i].end, bodyEnd);
|
|
if (!stripMarkup(body).trim()) warnings.push({ type: 'empty_chapter', title: heads[i].title });
|
|
}
|
|
|
|
// 3. Unrecognized bracket tokens — anything outside the known grammar.
|
|
const seenTags = new Set();
|
|
const bre = new RegExp(BRACKET_RE.source, BRACKET_RE.flags);
|
|
let bm;
|
|
while ((bm = bre.exec(norm)) !== null) {
|
|
const tok = bm[0];
|
|
if (bre.lastIndex === bm.index) bre.lastIndex++;
|
|
const lower = tok.toLowerCase();
|
|
const known =
|
|
VOICE_RE.test(tok) ||
|
|
PAUSE_TOKEN_RE.test(tok) ||
|
|
SSML_TOKEN_RE.test(tok) ||
|
|
REACTION_TOKENS.has(lower);
|
|
VOICE_RE.lastIndex = 0; // test() advances a /g regex — reset it
|
|
if (!known && !seenTags.has(lower)) {
|
|
seenTags.add(lower);
|
|
warnings.push({ type: 'unknown_tag', tag: tok });
|
|
}
|
|
}
|
|
|
|
return warnings;
|
|
}
|