// Le voci dei personaggi (make voci): ogni battuta della storia diventa un mp3 in assets/voices/, con la voce // scelta per chi parla in tools/voci.json; assets/voices/index.json dice al gioco quale file e quanto dura. // Il nome del file contiene un'impronta di testo e voce: si rigenera solo quello che è cambiato, e gli mp3 che // nessuna battuta usa più si cancellano. // node tools/voci.mjs [--dry] (--dry: elenca cosa genererebbe, senza toccare niente) // node tools/voci.mjs --rifai 'Ci sei?' --rifai 'altro testo' (rifà le battute che contengono quel testo: // XTTS a volte storpia una frase, e un altro tentativo di solito la azzecca) // Le parole che XTTS legge male (tools/pronuncia.json) si riscrivono come vanno lette solo nel testo per la voce: // il fumetto resta com'è scritto. // XTTS gira nel suo Python (XTTS_PY, TTS_HOME): di default quello di ~/Workspaces/qemu/qemu-iso-lab. import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { story } from '../src/story/story.js'; import { voiceLines, voiceKey } from '../src/engine/voices.js'; import { spoken } from './pronuncia.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(ROOT, 'assets', 'voices'); const LAB = join(process.env.HOME, 'Workspaces/qemu/qemu-iso-lab/artifacts/tts'); const XTTS_PY = process.env.XTTS_PY ?? join(LAB, 'xtts/bin/python'); const TTS_HOME = process.env.TTS_HOME ?? join(LAB, 'models'); const CACHE = join(process.env.XDG_CACHE_HOME ?? join(process.env.HOME, '.cache'), 'leggenda-regolo-voci'); const dry = process.argv.includes('--dry'); const redo = process.argv.flatMap((a, i, all) => a === '--rifai' && all[i + 1] ? [all[i + 1]] : []); // Cambia quando cambia il modo di sintetizzare (tools/voci_xtts.py): rigenera tutto. const SINTESI = 2; const config = JSON.parse(readFileSync(join(ROOT, 'tools', 'voci.json'), 'utf8')); const lines = voiceLines(story); const missing = [...new Set(lines.map(l => l.who))].filter(w => !config[w]); if (missing.length) throw new Error(`manca la voce in tools/voci.json per: ${missing.join(', ')}`); const index = {}, todo = []; for (const { who, text } of lines) { const voice = config[who], said = spoken(text); // L'impronta segue il testo letto: una regola nuova in pronuncia.json rifà solo le battute che tocca. const hash = createHash('sha1').update(JSON.stringify(said === text ? [SINTESI, who, text, voice] : [SINTESI, who, text, voice, said])).digest('hex').slice(0, 10); const file = `${who}-${hash}.mp3`; index[voiceKey(who, text)] = { file }; const again = redo.some(r => text.includes(r)); if (again) { rmSync(join(OUT, file), { force: true }); rmSync(join(CACHE, `${who}-${hash}.wav`), { force: true }); } if (again || !existsSync(join(OUT, file))) todo.push({ who, text, said, voice, file, wav: join(CACHE, `${who}-${hash}.wav`) }); } console.log(`${lines.length} battute, ${todo.length} da generare`); if (dry) { for (const t of todo) console.log(` ${t.who}: ${t.said}${t.said !== t.text ? ` (scritto: ${t.text})` : ''}`); process.exit(0); } mkdirSync(OUT, { recursive: true }); mkdirSync(CACHE, { recursive: true }); if (todo.length) { const jobs = join(CACHE, 'lavori.json'); writeFileSync(jobs, JSON.stringify(todo.map(t => ({ text: t.said, speaker: t.voice.speaker, out: t.wav })))); execFileSync(XTTS_PY, ['-I', join(ROOT, 'tools', 'voci_xtts.py'), jobs], { stdio: 'inherit', cwd: CACHE, env: { ...process.env, TTS_HOME } }); // Silenzio tagliato ai due capi, poi il filtro del personaggio; mp3 mono, leggero per il telefono. const trim = 'silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse'; for (const t of todo) { const af = [trim, t.voice.filtro].filter(Boolean).join(','); execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', t.wav, '-af', af, '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '56k', join(OUT, t.file)]); } } // La durata di ogni file: il fumetto resta a schermo almeno quanto la voce. for (const entry of Object.values(index)) { const sec = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', join(OUT, entry.file)], { encoding: 'utf8' }); entry.sec = Math.round(Number(sec) * 100) / 100; } const used = new Set(Object.values(index).map(e => e.file)); const orphans = readdirSync(OUT).filter(f => f.endsWith('.mp3') && !used.has(f)); for (const f of orphans) rmSync(join(OUT, f)); writeFileSync(join(OUT, 'index.json'), JSON.stringify(index, null, 1) + '\n'); console.log(`-> assets/voices/index.json (${Object.keys(index).length} voci${orphans.length ? `, ${orphans.length} mp3 vecchi tolti` : ''})`);