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 -2
View File
@@ -47,8 +47,10 @@ export function restoreState(story, data) {
}
export class Game {
constructor(story, { state, onSave } = {}) {
// voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima).
constructor(story, { state, onSave, voiceTime } = {}) {
this.story = story;
this.voiceTime = voiceTime ?? (() => undefined);
this.state = state ? restoreState(story, state) : newState(story);
this.onSave = onSave ?? (() => {});
this.queue = [];
@@ -189,6 +191,7 @@ export class Game {
speakerPoint(who) {
if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)];
if (this.scene.speakers?.[who]) return this.scene.speakers[who];
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
if (!h) return [this.state.x, this.state.y - 62];
return h.talkAt ?? [h.box[0] + h.box[2] / 2, h.box[1] - 4];
@@ -232,7 +235,10 @@ export class Game {
run(e) {
const s = this.state;
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', pose: e.pose, left: sayTime(e.say) };
if (e.say != null) {
const who = e.who ?? 'andrea', voice = this.voiceTime(who, e.say);
this.speech = { text: e.say, who, pose: e.pose, left: Math.max(sayTime(e.say), voice ? voice + 0.35 : 0) };
}
else if (e.walk) { this.task = e; this.startWalk(e.walk); }
// `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo.
else if (e.go) this.go(e.go, e.at, e.face);
+2 -1
View File
@@ -20,6 +20,7 @@ export class Music {
this.want = null;
this.current = null; // { src, audio }
this.old = [];
this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno
}
// Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma.
@@ -30,7 +31,7 @@ export class Music {
this.current = src ? { src, audio: this.start(src) } : null;
}
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero.
const step = dt / FADE, target = this.muted ? 0 : this.volume;
const step = dt / FADE, target = this.muted ? 0 : this.volume * this.duck;
if (this.current) {
const a = this.current.audio;
a.volume = a.volume < target ? Math.min(target, a.volume + step) : Math.max(target, a.volume - step);
+49
View File
@@ -0,0 +1,49 @@
// Voci dei personaggi: un mp3 per battuta (generati da tools/voci.mjs), cercati per chi parla e testo.
// Senza assets/voices/index.json, o per una battuta nuova non ancora generata, il gioco resta muto e va avanti.
export const voiceKey = (who, text) => `${who}|${text}`;
// Tutte le battute della storia, ovunque stiano (scene, oggetti, dialoghi, rami `if`), più le righe dell'introduzione
// che dice Andrea. Una battuta è un oggetto con `say`; senza `who` parla Andrea.
export function voiceLines(story) {
const seen = new Map();
const add = (who, text) => { if (text.trim() && !/^[.…!?\s]+$/.test(text)) seen.set(voiceKey(who, text), { who, text }); };
const visit = value => {
if (Array.isArray(value)) return value.forEach(visit);
if (!value || typeof value !== 'object') return;
if (typeof value.say === 'string') add(value.who ?? 'andrea', value.say);
Object.values(value).forEach(visit);
};
for (const line of story.intro ?? []) add('andrea', line);
visit(story.scenes);
visit(story.items);
visit(story.dialogues);
return [...seen.values()];
}
export class Voices {
constructor(index = {}, base = 'assets/voices/') {
this.index = index;
this.base = base;
this.audio = null;
}
seconds(who, text) { return this.index[voiceKey(who, text)]?.sec; }
play(who, text, volume = 1) {
this.stop();
const entry = this.index[voiceKey(who, text)];
if (!entry || volume <= 0) return false;
this.audio = new Audio(this.base + entry.file);
this.audio.volume = Math.min(1, volume);
this.audio.play().catch(() => {});
return true;
}
stop() {
if (this.audio) this.audio.pause();
this.audio = null;
}
get playing() { return !!this.audio && !this.audio.paused && !this.audio.ended; }
}
+17 -2
View File
@@ -5,6 +5,7 @@ import { Command } from './engine/command.js';
import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
const $ = id => document.getElementById(id);
const canvas = $('scene'), ctx = canvas.getContext('2d');
@@ -28,8 +29,15 @@ $('volume').addEventListener('input', e => { music.setVolume(e.target.value / 10
showSound();
window.music = music; // per i test nel browser
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica.
const voices = new Voices();
fetch('assets/voices/index.json').then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {});
window.voices = voices;
let voiced = null;
const say = (who, text) => voices.play(who, text, music.muted ? 0 : 1);
function start(state) {
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
command = new Command(game);
if (!state) game.go(story.start.scene, story.start.at);
$('title').hidden = true;
@@ -49,9 +57,10 @@ function showIntro() {
}
function nextIntro() {
introLine++;
if (introLine >= story.intro.length) { $('intro').hidden = true; return start(null); }
if (introLine >= story.intro.length) { voices.stop(); $('intro').hidden = true; return start(null); }
const p = $('intro-text');
p.textContent = story.intro[introLine];
say('andrea', story.intro[introLine]);
p.style.animation = 'none';
void p.offsetWidth; // riavvia la comparsa
p.style.animation = '';
@@ -162,6 +171,11 @@ window.addEventListener('keydown', e => {
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() {
const box = $('speech'), sp = game.speech;
// Una battuta nuova fa partire la sua voce; saltata o finita, la voce si ferma.
if (sp !== voiced) {
voiced = sp;
if (sp) say(sp.who, sp.text); else voices.stop();
}
if (!sp) { if (box.textContent) box.textContent = ''; return; }
const [x, y] = game.speakerPoint(sp.who);
box.textContent = sp.text;
@@ -192,6 +206,7 @@ function frame(now) {
const dt = Math.max(0, Math.min(0.1, (now - last) / 1000));
last = now;
const screen = !$('title').hidden || !$('splash').hidden ? 'title' : !$('intro').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
music.duck = voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene }), dt);
if (game) {
if (running) game.update(dt);
+4 -1
View File
@@ -19,6 +19,7 @@ export const story = {
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
castello: { name: 'Il Castello', color: '#c8f0a0' },
leone: { name: 'Il leone', color: '#f0a070' },
},
items: {
squama: {
@@ -384,12 +385,13 @@ export const story = {
walk: [[0, 318], [100, 312], [282, 318], [300, 300], [318, 262], [336, 252], [420, 266], [640, 296], [640, 360], [0, 360]],
depth: [[252, 0.5], [360, 1.1]],
entry: [340, 352],
speakers: { leone: [429, 62] },
hotspots: [
{
id: 'leone', name: 'leone di terracotta', box: [412, 66, 34, 30], at: [400, 290], face: 'up',
actions: {
look: [{ say: 'Un leone di terracotta che fa la guardia alla salita. Di serpenti, dice, non ne ha visti passare.' }],
talk: [{ say: 'Ciao leone. Visto passare un serpente con le ali?' }, { say: 'Il leone fa finta di niente. Come tutti, in questo paese.' }],
talk: [{ say: 'Ciao leone. Visto passare un serpente con le ali?' }, { say: 'Io fo la guardia alla salita, mica ai serpenti.', who: 'leone' }, { say: 'E poi di notte dormo. Come tutti, in questo paese.', who: 'leone' }, { say: 'Un leone di terracotta che parla. Devo dormire di più anch\'io.' }],
take: [{ say: 'Pesa più di me. E poi ha un\'aria permalosa.' }],
},
},
@@ -438,6 +440,7 @@ export const story = {
walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]],
depth: [[306, 0.75], [360, 1.05]],
entry: [110, 345],
speakers: { castello: [389, 22] },
enter: [{ if: '!trebbio_visto', then: [{ set: 'trebbio_visto' }, { say: 'Il Trebbio. Quassù anche i pensieri vanno più piano.' }] }],
hotspots: [
{