#!/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.');