- le impronte portano a una fessura nel bastione; con la torcia si vede un angolo del palio (l'omino con la maglia rossa del quadro vero, già descritto sul manifesto) e due occhi gialli: fine del primo capitolo - la soluzione passa per indovinello, oratorio e catena del ghiacciolo; tragitti calcolati dalle uscite - il Trebbio ha un indizio per ogni tappa della torcia - tools/pronuncia.json: le parole che XTTS legge male, riscritte solo per la voce («celo» → «ce l'ho») - voci delle battute nuove Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
73 lines
4.9 KiB
JavaScript
73 lines
4.9 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)
|
|
// 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` : ''})`);
|