← Files CastReaderARCHIVED FILE
assets/voice-app/generate.mjs
7.03 KB · Oct 5, 2026 · 18:34 UTC
import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
const digest = value => createHash('sha256').update(value).digest('hex');
const terminal = new Set(['succeeded', 'failed', 'expired', 'cancelled']);
const languages = new Set(['en', 'zh', 'de', 'ja', 'fr', 'es', 'ko', 'pt', 'ru', 'it']);
const sleep = ms => new Promise(done => setTimeout(done, ms));
export function validateCards(input) {
if (!Array.isArray(input) || !input.length || input.length > 200) throw new Error('Use 1–200 card rows.');
const ids = new Set();
return input.map(row => {
if (!row || !/^[a-z0-9][a-z0-9-]{0,70}$/.test(row.id) || ids.has(row.id)) throw new Error('Use unique, filename-safe card IDs.');
if (!languages.has(row.language) || typeof row.text !== 'string' || !row.text.isWellFormed()) throw new Error('Check the text and supported language for every card.');
ids.add(row.id);
const text = row.text.replace(/\r\n/g, '\n').normalize('NFC').trim();
if (!text || Array.from(text).length > 500) throw new Error(`${row.id}: use 1–500 normalized characters.`);
return { id: row.id, language: row.language, text };
});
}
async function json(path) {
try { return JSON.parse(await readFile(path, 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
async function atomicJson(path, value) {
const temporary = `${path}.tmp-${randomUUID()}`;
await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
await rename(temporary, path);
}
/** Sequential jobs; checkpoints precede writes so uncertain calls reuse their key. */
export async function runCards({ client, cards: input, output, voiceId, waitMs = 3000, timeoutMs = 660000, log = console.log }) {
const cards = validateCards(input);
if (!/^voice_[a-f0-9]{32}$/.test(voiceId)) throw new Error('Use a ready voice ID.');
const dir = resolve(output);
await mkdir(dir, { recursive: true });
const lockPath = join(dir, '.running');
let lock;
try { lock = await open(lockPath, 'wx', 0o600); }
catch (error) { if (error.code === 'EEXIST') throw new Error('This output folder is locked. Ensure its earlier process has stopped before removing .running.'); throw error; }
try {
await lock.writeFile(String(process.pid));
const statePath = join(dir, 'state.json');
const inputHash = digest(JSON.stringify({ cards, voiceId, model: 'clone-v1', output_format: 'mp3' }));
let state = await json(statePath);
if (state && state.inputHash !== inputHash) throw new Error('Inputs or voice changed. Keep this folder to recover old jobs; choose a new output folder for new generations.');
state ||= { version: 1, runId: randomUUID(), inputHash, rows: {} };
await atomicJson(statePath, state);
const checkpoint = () => atomicJson(statePath, state);
for (const card of cards) {
const row = state.rows[card.id] ||= { idempotencyKey: `cards-${state.runId}-${card.id}` };
if (row.sha256) {
try { if (digest(await readFile(join(dir, `${card.id}.mp3`))) === row.sha256) { log(`${card.id}: saved, no API request`); continue; } }
catch (error) { if (error.code !== 'ENOENT') throw error; }
}
await checkpoint();
let job = row.jobId ? await client.getJob(row.jobId) : await client.createJob({
model: 'clone-v1', voice_id: voiceId, text: card.text,
language: card.language, output_format: 'mp3',
}, { idempotencyKey: row.idempotencyKey });
row.jobId = job.id;
await checkpoint();
const deadline = Date.now() + timeoutMs;
let lastStatus;
while (true) {
if (lastStatus !== job.status) { log(`${card.id}: ${job.status}`); lastStatus = job.status; }
if (terminal.has(job.status)) break;
if (Date.now() >= deadline) throw new Error(`${card.id}: waiting stopped; rerun this folder to follow the saved job, not create a replacement.`);
await sleep(waitMs);
job = await client.getJob(job.id);
}
row.status = job.status;
await checkpoint();
if (job.status !== 'succeeded') throw new Error(`${card.id}: ${job.status} (${job.errorCode || 'no code'}). Inspect the saved job; no replacement generation was submitted.`);
const result = await client.getJobAudio(job.id);
if (!result.audio?.length) throw new Error(`${card.id}: empty audio response.`);
await writeFile(join(dir, `${card.id}.mp3`), result.audio);
Object.assign(row, {
sha256: digest(result.audio), bytes: result.audio.length,
chargedUSD: result.chargedUSD, trialCharacters: result.trialCharacters,
billableCharacters: result.billableCharacters,
});
await checkpoint();
}
const manifest = {
version: 1, completedAt: new Date().toISOString(), voice: 'Your selected voice',
model: 'clone-v1', outputFormat: 'mp3',
rows: cards.map(card => {
const row = state.rows[card.id];
return { ...card, file: `${card.id}.mp3`, sha256: row.sha256, bytes: row.bytes, billableCharacters: row.billableCharacters, trialCharacters: row.trialCharacters, chargedUSD: row.chargedUSD };
}),
};
await atomicJson(join(dir, 'manifest.json'), manifest);
log(`Saved ${cards.length} MP3 files and manifest.json. Keep state.json private for recovery.`);
return manifest;
} finally { await lock.close(); await unlink(lockPath); }
}
async function main() {
const cards = validateCards(JSON.parse(await readFile(process.env.CARDS_FILE || 'cards.json', 'utf8')));
const characters = cards.reduce((total, card) => total + Array.from(card.text).length, 0);
console.log(`${cards.length} files; ${characters} normalized characters; estimated USD ${(characters * 8 / 1000000).toFixed(6)} before any available trial credit, at USD 8 / million. Confirm current pricing before running.`);
if (!process.argv.includes('--run')) { console.log('Plan only. No API calls. Use --run to generate and incur normal usage charges.'); return; }
const { VoiceAPI } = await import('@castreader/voice-api');
const client = new VoiceAPI();
const models = await client.models();
const enabled = models.data.find(model => model.id === 'clone-v1')?.languages || [];
for (const card of cards) if (!enabled.includes(card.language)) throw new Error(`${card.language} is not enabled in this processing region.`);
const voices = await client.voices();
const voice = process.env.VOICE_ID
? voices.data.find(item => item.id === process.env.VOICE_ID && item.status === 'ready')
: voices.data.find(item => item.default_voice_key === 'narrator' && item.status === 'ready');
if (!voice) throw new Error('The selected voice is not ready or available in your project.');
await runCards({ client, cards, voiceId: voice.id, output: process.env.OUTPUT_DIR || 'output' });
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch(error => { console.error(error.code || error.name, error.message); process.exitCode = 1; });
}
SHA-256: 121795868cf42c81ad88a84ddb4d2b6c06a5c12c7ff49bb6c6fe9d43c47a025d