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
This commit is contained in:
2026-10-24 13:20:31 +02:00
co-authored by Claude Opus 5.5
parent 605e9a6dd1
commit 8ff18acf28
46 changed files with 353 additions and 38 deletions
+9 -5
View File
@@ -5,6 +5,8 @@
// 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';
@@ -13,6 +15,7 @@ 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');
@@ -32,22 +35,23 @@ if (missing.length) throw new Error(`manca la voce in tools/voci.json per: ${mis
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 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, voice, file, wav: join(CACHE, `${who}-${hash}.wav`) });
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.text}`); process.exit(0); }
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.text, speaker: t.voice.speaker, out: t.wav }))));
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';