- 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
138 lines
6.6 KiB
JavaScript
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.');
|