Files
leggenda-del-regolo/tools/voci.mjs
T
manzoloandClaude Opus 5.5 470d6e7f7b Voci dei personaggi con XTTS: una per battuta, castello e leone con voci proprie
tools/voci.mjs (make voci) genera un mp3 per ogni battuta con la voce del personaggio scelta in
tools/voci.json, solo per i testi nuovi o cambiati; tools/voci_xtts.py divide le frasi e toglie
punti finali e caporali, che XTTS in italiano leggeva («Buongiorno, punto»). Nel gioco il fumetto
dura quanto la voce, la musica si abbassa mentre si parla, il muto spegne anche le voci.
Il leone ora parla e i personaggi parlano dalla posizione che la scena dichiara (speakers),
che prima era ignorata. Tutte le voci verificate con Whisper; test: ogni battuta ha la sua
voce, il fumetto non finisce prima della voce, la musica si abbassa (provati con errori iniettati).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
2026-10-23 15:23:08 +02:00

69 lines
4.5 KiB
JavaScript

// 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)
// 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';
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];
const hash = createHash('sha1').update(JSON.stringify([SINTESI, who, text, voice])).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, 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.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.text, 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` : ''})`);