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

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

Code169 lines6.3 KBSkill: video-script-production

What the file says about itself

Sends one beat's Gemini TTS prompt to the API and saves the audio as a WAV file. Usage: node tts-gemini-record.js --prompt <TTS-...-gemini-3.1.md> --beat B2 --out <file.wav> [--model gemini-3.1-flash-tts-preview] [--voice Kore] The prompt…

#!/usr/bin/env node
// Sends one beat's Gemini TTS prompt to the API and saves the audio as a WAV file.
// Usage: node tts-gemini-record.js --prompt <TTS-...-gemini-3.1.md> --beat B2 --out <file.wav> [--model gemini-3.1-flash-tts-preview] [--voice Kore]
// The prompt is the first fenced block under the heading "## <beat> " in the prompt file, sent unchanged.
// The API key comes from GEMINI_API_KEY or from the repo .env (GEMINI_API_KEY=...). It is never printed, logged or written.
'use strict';
const fs = require('node:fs');
const path = require('node:path');

const ENV_FILE = '<home>/OneDrive/Desktop/ecosystem-generic/.env';
const DEFAULT_MODEL = 'gemini-3.1-flash-tts-preview';
const DEFAULT_VOICE = 'Kore';
const DEFAULT_RATE = 24000;
const DEFAULT_RETRY_SECONDS = 65;

function fail(msg, key) {
  let text = String(msg);
  if (key) text = text.split(key).join('[REDACTED]');
  console.error(`tts-gemini-record: ${text}`);
  process.exit(1);
}

function parseArgs(argv) {
  const out = {};
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (!a.startsWith('--')) fail(`unexpected argument: ${a}`);
    const name = a.slice(2);
    const val = argv[i + 1];
    if (val === undefined || val.startsWith('--')) fail(`missing value for --${name}`);
    out[name] = val;
    i++;
  }
  return out;
}

function readKey() {
  if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
    return process.env.GEMINI_API_KEY.trim();
  }
  if (!fs.existsSync(ENV_FILE)) fail('no GEMINI_API_KEY in the environment and the .env file is missing');
  const lines = fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/);
  for (const line of lines) {
    if (!line.startsWith('GEMINI_API_KEY=')) continue;
    const v = line.slice('GEMINI_API_KEY='.length).trim().replace(/^["']+|["']+$/g, '').trim();
    if (v) return v;
  }
  fail('GEMINI_API_KEY is not set in the environment or in the .env file');
}

// Returns the text of the first fenced block (a line of exactly three backticks) after "## <beat> ".
function extractPrompt(md, beat) {
  const lines = md.split(/\r?\n/);
  const heading = `## ${beat} `;
  const start = lines.findIndex((l) => l.startsWith(heading));
  if (start < 0) fail(`beat "${beat}" not found (no heading starting "${heading}")`);
  let open = -1;
  for (let i = start + 1; i < lines.length; i++) {
    if (lines[i].startsWith('## ')) break;
    if (lines[i] === '```') { open = i; break; }
  }
  if (open < 0) fail(`no fenced block found under beat ${beat}`);
  const body = [];
  for (let i = open + 1; i < lines.length; i++) {
    if (lines[i] === '```') return body.join('\n');
    body.push(lines[i]);
  }
  fail(`fenced block under beat ${beat} is not closed`);
}

function wavHeader(dataLen, rate, channels = 1, bits = 16) {
  const h = Buffer.alloc(44);
  const blockAlign = channels * bits / 8;
  h.write('RIFF', 0, 'ascii');
  h.writeUInt32LE(36 + dataLen, 4);
  h.write('WAVE', 8, 'ascii');
  h.write('fmt ', 12, 'ascii');
  h.writeUInt32LE(16, 16);
  h.writeUInt16LE(1, 20);
  h.writeUInt16LE(channels, 22);
  h.writeUInt32LE(rate, 24);
  h.writeUInt32LE(rate * blockAlign, 28);
  h.writeUInt16LE(blockAlign, 32);
  h.writeUInt16LE(bits, 34);
  h.write('data', 36, 'ascii');
  h.writeUInt32LE(dataLen, 40);
  return h;
}

function retryDelaySeconds(bodyText) {
  const m = /"retryDelay"\s*:\s*"([\d.]+)s"/.exec(bodyText) || /retry in ([\d.]+)s/i.exec(bodyText);
  const n = m ? parseFloat(m[1]) : NaN;
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETRY_SECONDS;
}

async function callApi(url, key, body) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'x-goog-api-key': key, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  return { status: res.status, text };
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  for (const req of ['prompt', 'beat', 'out']) {
    if (!args[req]) fail(`usage: node tts-gemini-record.js --prompt <file.md> --beat B2 --out <file.wav> [--model M] [--voice V]`);
  }
  const model = args.model || DEFAULT_MODEL;
  const voice = args.voice || DEFAULT_VOICE;
  const key = readKey();

  const promptFile = path.resolve(args.prompt);
  if (!fs.existsSync(promptFile)) fail(`prompt file not found: ${promptFile}`, key);
  const promptText = extractPrompt(fs.readFileSync(promptFile, 'utf8'), args.beat);

  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
  const body = {
    contents: [{ parts: [{ text: promptText }] }],
    generationConfig: {
      responseModalities: ['AUDIO'],
      speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } },
    },
  };

  let res;
  try {
    res = await callApi(url, key, body);
    if (res.status === 429 || res.status === 503) {
      const wait = retryDelaySeconds(res.text);
      console.error(`HTTP ${res.status}; retrying once after ${wait} s`);
      await new Promise((r) => setTimeout(r, wait * 1000));
      res = await callApi(url, key, body);
    }
  } catch (e) {
    fail(`request failed: ${e && e.message ? e.message : e}`, key);
  }
  if (res.status !== 200) {
    fail(`HTTP ${res.status}: ${res.text.slice(0, 300)}`, key);
  }

  let json;
  try { json = JSON.parse(res.text); } catch (e) { fail('response is not JSON', key); }
  const parts = (json.candidates && json.candidates[0] && json.candidates[0].content && json.candidates[0].content.parts) || [];
  const part = parts.find((p) => p.inlineData && p.inlineData.data);
  if (!part) fail(`no audio in response: ${res.text.slice(0, 300)}`, key);

  const mime = part.inlineData.mimeType || '';
  const rateMatch = /rate=(\d+)/.exec(mime);
  const rate = rateMatch ? parseInt(rateMatch[1], 10) : DEFAULT_RATE;
  const pcm = Buffer.from(part.inlineData.data, 'base64');
  if (pcm.length % 2 !== 0) fail('audio byte count is odd; expected 16-bit samples', key);

  const outPath = path.resolve(args.out);
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, Buffer.concat([wavHeader(pcm.length, rate), pcm]));

  const seconds = pcm.length / (rate * 2);
  console.log(`file: ${outPath}`);
  console.log(`model: ${model}`);
  console.log(`voice: ${voice}`);
  console.log(`sample rate: ${rate} Hz (mime ${mime || 'none'})`);
  console.log(`duration: ${seconds.toFixed(2)} s`);
}

main().catch((e) => fail(e && e.message ? e.message : e));

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.