tts-voice-creator-clone-and.../scripts/render_trial_chapters.mjs
mARTin-B78 f7264a8c07 chore: release 1.21.0 - Consistent ComfyUI Character Renders and Audiobook Export
- Deterministic hash-based character portraits and reference sheets
- Implemented strict early Renaissance aesthetic controls and role-specific headwear rules
- Added audiobook batch export scripts and progress tooling
- Verified book profiles with a new 'confirmed' state tracking mechanism
- Updated Pillow dependency for robust image handling
- Configured .gitignore for audiobook and audio cache exports
2026-08-23 09:12:57 +02:00

138 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/** Render six dialogue-focused audiobook chapter trials through the live API.
*
* This deliberately reuses each saved character's assigned voice. Unknown
* one-off speakers use the book's Narrator voice rather than creating a new
* voice during a render.
*/
import fs from 'node:fs';
import path from 'node:path';
const base = process.env.VOICE_CREATOR_URL || 'http://127.0.0.1:7890';
const selections = [
{ cast: '/tmp/b1-cast.json', chapter: 6, slug: 'book-1-chapter-6' },
{ cast: '/tmp/b1-cast.json', chapter: 9, slug: 'book-1-chapter-9' },
{ cast: '/tmp/b2-cast.json', chapter: 5, slug: 'book-2-chapter-5' },
{ cast: '/tmp/b2-cast.json', chapter: 13, slug: 'book-2-chapter-13' },
{ cast: '/tmp/b3-cast.json', chapter: 4, slug: 'book-3-chapter-4' },
{ cast: '/tmp/b3-cast.json', chapter: 9, slug: 'book-3-chapter-9' },
];
const outDir = '/tmp/voice-creator-trials';
fs.mkdirSync(outDir, { recursive: true });
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const key = value => String(value || '').trim().toLocaleLowerCase('de-DE');
function chapterBounds(segments, chapter) {
const starts = [];
for (let i = 0; i < segments.length; i++) {
const match = String(segments[i].text || '').match(/(?:^|\s)(\d{1,2})\.\s*Kapitel\b/i);
if (match) starts.push({ index: i, chapter: Number(match[1]) });
}
const startAt = starts.findIndex(s => s.chapter === chapter);
if (startAt < 0) throw new Error(`Chapter ${chapter} was not found`);
return [starts[startAt].index, starts[startAt + 1]?.index ?? segments.length];
}
function splitText(text, limit = 620) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
if (!clean) return [];
const pieces = [];
let remaining = clean;
while (remaining.length > limit) {
const candidates = [remaining.lastIndexOf('. ', limit), remaining.lastIndexOf('! ', limit), remaining.lastIndexOf('? ', limit), remaining.lastIndexOf(', ', limit), remaining.lastIndexOf(' ', limit)];
const cut = Math.max(...candidates);
const end = cut > 80 ? cut + 1 : limit;
pieces.push(remaining.slice(0, end).trim());
remaining = remaining.slice(end).trim();
}
if (remaining) pieces.push(remaining);
return pieces;
}
function wavData(buffer) {
const b = Buffer.from(buffer);
if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('TTS response was not a WAV file');
let p = 12;
while (p + 8 <= b.length) {
const id = b.toString('ascii', p, p + 4);
const size = b.readUInt32LE(p + 4);
if (id === 'data') return b.subarray(p + 8, p + 8 + size);
p += 8 + size + (size % 2);
}
throw new Error('WAV data chunk was not found');
}
function wavFile(data) {
const header = Buffer.alloc(44);
header.write('RIFF', 0); header.writeUInt32LE(36 + data.length, 4); header.write('WAVEfmt ', 8);
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write('data', 36); header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}
async function charactersFor(title) {
const response = await fetch(`${base}/api/characters`);
if (!response.ok) throw new Error(`Cannot read character library: ${response.status}`);
const all = (await response.json()).characters || [];
const relevant = all.filter(c => c.book === title);
const voices = new Map();
for (const c of relevant) {
if (!c.voice) continue;
voices.set(key(c.name), c.voice);
for (const a of String(c.sheet?.aliases || '').split(/[,;|]/)) if (a.trim()) voices.set(key(a), c.voice);
}
return voices;
}
async function tts(voice, text) {
const response = await fetch(`${base}/api/tts-preview`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice, text, response_format: 'wav', backend: 'voice_clone' }),
});
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return wavData(await response.arrayBuffer());
}
async function render(selection) {
const cast = JSON.parse(fs.readFileSync(selection.cast, 'utf8'));
const [from, to] = chapterBounds(cast.segments, selection.chapter);
const voiceMap = await charactersFor(cast.title);
const narrator = voiceMap.get('narrator');
if (!narrator) throw new Error(`${cast.title}: narrator has no voice`);
const chunks = [];
let fallbackCount = 0;
for (const segment of cast.segments.slice(from, to)) {
const speaker = key(segment.speaker || 'Narrator');
const voice = voiceMap.get(speaker) || narrator;
if (!voiceMap.has(speaker) && speaker !== 'narrator') fallbackCount++;
for (const text of splitText(segment.text)) chunks.push({ voice, text });
}
const chunkDir = path.join(outDir, selection.slug);
fs.mkdirSync(chunkDir, { recursive: true });
let start = 0;
while (start < chunks.length && fs.existsSync(path.join(chunkDir, `${String(start).padStart(4, '0')}.pcm`))) start++;
console.log(`[${selection.slug}] chapter ${selection.chapter}, segments ${to - from}, requests ${chunks.length}, resume ${start}, narrator fallbacks ${fallbackCount}`);
for (let i = start; i < chunks.length; i++) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt++) {
try { fs.writeFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`), await tts(chunks[i].voice, chunks[i].text)); lastError = null; break; }
catch (err) { lastError = err; console.warn(`[${selection.slug}] request ${i + 1}/${chunks.length} attempt ${attempt}: ${err.message}`); await pause(1500 * attempt); }
}
if (lastError) throw lastError;
if ((i + 1) % 10 === 0 || i + 1 === chunks.length) console.log(`[${selection.slug}] ${i + 1}/${chunks.length}`);
}
const silence = Buffer.alloc(24000 / 4 * 2); // 250 ms 24kHz / mono / 16-bit
const raw = chunks.map((_, i) => fs.readFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`)));
const wav = wavFile(Buffer.concat(raw.flatMap((x, i) => i ? [silence, x] : [x])));
const file = `${selection.slug}.wav`;
fs.writeFileSync(path.join(outDir, file), wav);
const upload = await fetch(`${base}/api/audiobook-export/${encodeURIComponent(cast.title)}/${file}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav });
if (!upload.ok) throw new Error(`Export upload failed: ${upload.status} ${await upload.text()}`);
console.log(`[${selection.slug}] complete ${(wav.length / 1024 / 1024).toFixed(1)} MiB ${await upload.text()}`);
}
for (const selection of selections) await render(selection);
console.log('All six trial chapters are complete.');