Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/tts-gemini-record.js
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.