Docs/Skills and code/skill video-script-production

.claude/skills/video-script-production/scripts/tts-gemini.js

Code118 lines9.6 KBSkill: video-script-production

What the file says about itself

Builds the per-beat Gemini TTS prompt files from a finished script and a small spec of where the tags go. node tts-gemini.js --script SCRIPT-v7-hinglish.md --spec tts/spec-v7-hinglish.json --out tts --name v7-hinglish Writes…

Mentioned on: page 10, page 11, page 12, page 13

#!/usr/bin/env node
// Builds the per-beat Gemini TTS prompt files from a finished script and a small spec of where the tags go.
//   node tts-gemini.js --script SCRIPT-v7-hinglish.md --spec tts/spec-v7-hinglish.json --out tts --name v7-hinglish
// Writes <out>/TTS-<name>-gemini-3.1.md (square-bracket tags, a direction block above the transcript) and
// <out>/TTS-<name>-gemini-3.8.md (style line separate from a verbatim transcript, only pause tags, in angle brackets).
// Why this is a tool and not a hand edit: the words must stay the script's words. Everything this tool adds is a tag, a stress
// capital or a rule-based normalisation (rupees, dollars, spaced acronyms), and it refuses to write a beat unless stripping those gives
// the normalised script back word for word, every digit survives, no two tags touch, and every tag is from the documented list.
// Spec: { lang: "hi"|"en", profile, performance, title, beats: { B0: { direction, style38, tags: [[beforeSnippet, tag], ...], stress: [[from, to], ...] } } }
//   tags     insert [tag] immediately before the first occurrence of the snippet that follows the previous tag
//   stress   replace a snippet with the same text with one word in CAPITALS (one word per pair, a word that carries the argument)
//   lead_in  optional string, {beat} replaced by the beat id; added as the first transcript paragraph (no tags) after the integrity checks, and
//            writes <out>/align-<name>-<beat>.txt per beat (line [P1] = lead-in, then one [P2], [P3]... line per script paragraph, no tags)
// Research and the rules used: research/<topic>/script/tts/TTS-NOTES-gemini.md.

const fs = require('fs');
const path = require('path');

const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
if (!args.script || !args.spec || !args.out || !args.name) { console.error('usage: node tts-gemini.js --script <SCRIPT.md> --spec <spec.json> --out <dir> --name <v7-hinglish>'); process.exit(2); }
const spec = JSON.parse(fs.readFileSync(args.spec, 'utf8'));

// tags documented for 3.1 (Google Cloud blog and the speech-generation guide); anything else is refused
const TAGS = new Set(['determination', 'enthusiasm', 'adoration', 'interest', 'awe', 'admiration', 'nervousness', 'frustration', 'excitement', 'curiosity', 'hope', 'annoyance', 'amusement', 'aggression', 'tension', 'agitation', 'confusion', 'anger', 'positive', 'neutral', 'negative', 'whispers', 'laughs', 'slow', 'fast', 'short pause', 'long pause', 'seriousness', 'cautious', 'gasp', 'panic', 'anxiety', 'relief', 'alarm', 'happy']);
const PAUSES = new Set(['short pause', 'long pause']);

const clean = (s) => s.replace(/\*\*\[[^\]]*\]\*\*/g, ' ').replace(/\[(?:V|CHECK)[^\]]*\]/gi, ' ').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*+/g, '').replace(/\s+/g, ' ').trim();
const beats = []; let cur = null;
for (const l of fs.readFileSync(args.script, 'utf8').split(/\r?\n/)) {
  const h = l.match(/^###\s*\[(B\d+)\]\s*(.*?)(?:\s*\(\d+:\d+ to \d+:\d+\))?\s*$/);
  if (h) { cur = { id: h[1], title: h[2], vo: [] }; beats.push(cur); continue; }
  if (/^#{1,3}\s/.test(l)) { cur = null; continue; }
  const m = l.match(/^\*\*VO:\*\*\s*(.*)$/);
  if (m && cur && clean(m[1])) cur.vo.push(clean(m[1]));
}

// normalisations applied by rule, never by hand: a leading currency sign can be read in English order or skipped, and Google spaces letters in its own examples
const rupee = spec.lang === 'en' ? 'rupees' : 'rupaye';
const dollar = spec.lang === 'en' ? 'dollars' : 'dollar';
const NORMS = [
  [/₹(\d[\d,.]*) aur ₹(\d[\d,.]*)/g, (m, a, b) => `${a} aur ${b} ${rupee}`],
  [/₹(\d[\d,.]*)( crore| lakh| million| billion)?/g, (m, n, u) => `${n}${u || ''} ${rupee}`],
  [/\$(\d[\d,.]*)( million| billion)?/g, (m, n, u) => `${n}${u || ''} ${dollar}`],
  [/\bCBI\b/g, 'C B I'], [/\bHR round\b/g, 'H R round'], [/\bCD-R\b/g, 'C D R'],
  [/\bVCDs\b/g, 'V C Ds'], [/\bDVDs\b/g, 'D V Ds'],
];
const normalise = (s) => NORMS.reduce((t, [re, rep]) => t.replace(re, rep), s);

const digitsOf = (s) => (s.replace(/(\d),(\d)/g, '$1$2').match(/\d+/g) || []).join(' ');
const errors = [];
const built = [];
for (const b of beats) {
  const sp = (spec.beats || {})[b.id];
  if (!sp) { errors.push(`${b.id}: not in the spec`); continue; }
  const paras = b.vo.map(normalise);
  let text = paras.join('\n\n');
  // tags
  let from = 0, nTags = 0, prevEnd = -1;
  for (const [snip, tag] of sp.tags || []) {
    if (!TAGS.has(tag)) { errors.push(`${b.id}: "${tag}" is not a documented tag`); continue; }
    const at = text.indexOf(snip, from);
    if (at < 0) { errors.push(`${b.id}: snippet not found after the previous tag: "${snip.slice(0, 40)}"`); continue; }
    if (at === prevEnd) errors.push(`${b.id}: two tags touch at "${snip.slice(0, 30)}"`);
    const ins = `[${tag}] `;
    text = text.slice(0, at) + ins + text.slice(at);
    from = at + ins.length; prevEnd = from; nTags++;
  }
  // stress capitals (one word per pair)
  for (const [a, z] of sp.stress || []) {
    if (a.toLowerCase() !== z.toLowerCase()) { errors.push(`${b.id}: stress pair changes more than case: "${a}" / "${z}"`); continue; }
    if (!text.includes(a)) { errors.push(`${b.id}: stress snippet not found: "${a}"`); continue; }
    text = text.replace(a, z);
  }
  // integrity: strip the tags and compare to the normalised script, ignoring case
  const stripped = text.replace(/\[[a-z ]+\] /g, '');
  if (stripped.toLowerCase() !== paras.join('\n\n').toLowerCase()) errors.push(`${b.id}: the words changed (not only tags and capitals)`);
  if (digitsOf(stripped) !== digitsOf(b.vo.join(' ').replace(/[₹$]/g, ''))) errors.push(`${b.id}: digits changed`);
  if (/\]\s*\[/.test(text)) errors.push(`${b.id}: two tags adjacent`);
  // plain paragraphs as the TTS receives them (tags removed, stress capitals kept), for the alignment file
  const plain = text.replace(/\[[a-z ]+\] /g, '').split('\n\n');
  const lead = spec.lead_in ? spec.lead_in.replace(/\{beat\}/g, b.id) : null;
  if (lead) text = lead + '\n\n' + text;
  built.push({ b, text, nTags, words: (paras.join(' ').match(/\S+/g) || []).length, sp, lead, plain });
}
if (errors.length) { console.error('NOT WRITTEN:\n  - ' + errors.join('\n  - ')); process.exit(1); }

fs.mkdirSync(args.out, { recursive: true });
const title = spec.title || args.name;
const totalTags = built.reduce((a, x) => a + x.nTags, 0), totalWords = built.reduce((a, x) => a + x.words, 0);

const head31 = [`# ${title}: Gemini 3.1 Flash TTS prompts, one per beat`, '',
  'Paste one fenced block at a time into AI Studio (model `gemini-3.1-flash-tts-preview`, a single speaker, the same voice for every beat). Each block is the complete prompt: direction, then the transcript. Save each result as `audio/beats/B#-slug.wav`.', '',
  `Rules used (reasons and sources in \`TTS-NOTES-gemini.md\`): square-bracket English tags only, placed where the change should happen, never two in a row; \`[short pause]\` / \`[long pause]\` for deliberate pauses; commas for flow; CAPITALS on a few words for stress; one blank line between voice-over paragraphs and no line breaks inside a paragraph; the script's words are unchanged, only tags, stress capitals and the rule-based normalisations (${spec.lang === 'en' ? 'rupees, dollars' : 'rupaye, dollar'}, spaced acronyms) were added.`, '',
  `Generated by \`scripts/tts-gemini.js\` from \`${path.basename(args.script)}\` and \`${path.basename(args.spec)}\` (${totalTags} tags across ${totalWords} words). If the script changes, regenerate this file; do not hand-edit it, or the two will drift.`, ''];
const body31 = built.flatMap(({ b, text, nTags, words, sp }) => [`## ${b.id} ${b.title}  (${words} words, ${nTags} tags)`, '', '```',
  'Synthesize speech from the transcript below. The PROFILE and PERFORMANCE lines are direction only and are not to be spoken.', '',
  `PROFILE: ${spec.profile}`, `PERFORMANCE: ${spec.performance} ${sp.direction}`, '', '#### TRANSCRIPT', text, '```', '']);
fs.writeFileSync(path.join(args.out, `TTS-${args.name}-gemini-3.1.md`), head31.concat(body31).join('\n'), 'utf8');

const head38 = [`# ${title}: Gemini 3.8 Flash TTS variant (for when the 3.1 preview is retired)`, '',
  '3.8 (`gemini-3.8-flash-tts`, `gemini-3.8-flash-lite-tts`) treats the text field as a verbatim transcript, so there is no direction block in the text: the **style** line goes in `speech_metadata.style` (AI Studio: the style or director field), the **transcript** goes in the text field. Only vocal and pause tags stay, in angle brackets. Emotion and pace tags from the 3.1 file are dropped because 3.8 documents no equivalent; pace comes from punctuation and the style line.', ''];
const body38 = built.flatMap(({ b, text, sp }) => {
  const t38 = text.replace(/\[([a-z ]+)\] /g, (m, tag) => (PAUSES.has(tag) ? `<${tag}> ` : ''));
  return [`## ${b.id} ${b.title}`, '', 'Style:', '', '```', sp.style38 || sp.direction, '```', '', 'Transcript:', '', '```', t38, '```', ''];
});
fs.writeFileSync(path.join(args.out, `TTS-${args.name}-gemini-3.8.md`), head38.concat(body38).join('\n'), 'utf8');

// alignment text per beat (only when lead_in is set): [P1] = lead-in, then one line per script paragraph; ASCII only
const asciiSafe = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/[–—]/g, '-').replace(/…/g, '...').replace(/[^\x20-\x7E]/g, '?');
if (spec.lead_in) {
  for (const { b, lead, plain } of built) {
    const lines = [`[P1] ${lead}`, ...plain.map((p, i) => `[P${i + 2}] ${p}`)];
    fs.writeFileSync(path.join(args.out, `align-${args.name}-${b.id}.txt`), lines.map(asciiSafe).join('\n') + '\n', 'utf8');
  }
}
console.log(`wrote TTS-${args.name}-gemini-3.1.md and -3.8.md in ${args.out}: ${built.length} beats, ${totalWords} words, ${totalTags} tags`);

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.