Files
manzoloandClaude Opus 5.5 8ff18acf28 Primo capitolo: la torcia nella fessura del bastione mostra il palio e due occhi gialli
- 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
2026-10-24 13:20:31 +02:00

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` : ''})`);