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
This commit is contained in:
2026-10-23 15:23:08 +02:00
co-authored by Claude Opus 5.5
parent b467bcc1d9
commit 470d6e7f7b
128 changed files with 753 additions and 7 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"_nota": "Una voce XTTS-v2 per personaggio (make voci). speaker: uno dei 58 di XTTS; filtro: catena ffmpeg -af applicata dopo (altezza, velocità, eco). Cambiare una voce rigenera solo le sue battute.",
"andrea": { "speaker": "Andrew Chipper" },
"vecchietta": { "speaker": "Tammy Grit" },
"vecchietto": { "speaker": "Viktor Eka" },
"castello": { "speaker": "Damien Black", "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15" },
"leone": { "speaker": "Baldur Sanjin", "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" }
}
+68
View File
@@ -0,0 +1,68 @@
// 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` : ''})`);
+39
View File
@@ -0,0 +1,39 @@
"""Sintesi delle battute con XTTS-v2 (gira nel Python di XTTS, lo lancia tools/voci.mjs):
voci_xtts.py lavori.json
lavori.json: [{"text": ..., "speaker": ..., "out": "/assoluto/x.wav"}, ...]; un lavoro già fatto si salta.
In italiano XTTS legge i punti a fine frase («Buongiorno, punto») e inciampa sui caporali: ogni battuta si
divide in frasi, senza punti finali né caporali, e le frasi si uniscono con una pausa breve.
Il modello è sotto licenza Coqui Public Model License: solo uso non commerciale."""
import json
import os
import re
import sys
PAUSA = 0.22 # secondi fra una frase e l'altra
os.environ["COQUI_TOS_AGREED"] = "1"
def frasi(testo: str) -> list[str]:
testo = re.sub(r"[«»\"“”]", "", testo)
parti = re.split(r"(?<=[.!?…])\s+", testo.strip())
pulite = [re.sub(r"(\.\.\.|…|\.)+$", "", p).strip() for p in parti]
return [p for p in pulite if re.search(r"\w", p)]
lavori = [j for j in json.load(open(sys.argv[1])) if not os.path.exists(j["out"])]
if lavori:
import torch
import torchaudio
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
sr = tts.synthesizer.output_sample_rate
for i, j in enumerate(lavori, 1):
pezzi = []
for f in frasi(j["text"]):
if pezzi:
pezzi.append(torch.zeros(int(sr * PAUSA)))
pezzi.append(torch.tensor(tts.tts(f, language="it", speaker=j["speaker"], split_sentences=False), dtype=torch.float32))
torchaudio.save(j["out"] + ".tmp.wav", torch.cat(pezzi).unsqueeze(0), sr, format="wav")
os.replace(j["out"] + ".tmp.wav", j["out"])
print(f"{i}/{len(lavori)}", flush=True)
print(f"{len(lavori)} battute sintetizzate")