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:
@@ -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" }
|
||||
}
|
||||
@@ -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` : ''})`);
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user