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:
@@ -59,6 +59,10 @@ try {
|
||||
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
||||
await page.waitForFunction(() => game.speech);
|
||||
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
|
||||
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
|
||||
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3$/);
|
||||
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
|
||||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||
|
||||
@@ -28,3 +28,16 @@ test('i brani della storia esistono', () => {
|
||||
assert.ok(files.length);
|
||||
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
||||
});
|
||||
|
||||
test('mentre parla qualcuno la musica si abbassa, poi risale', () => {
|
||||
globalThis.Audio ??= class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
|
||||
const m = new Music({ volume: 0.6 });
|
||||
for (let i = 0; i < 40; i++) m.update('g.mp3', 0.1);
|
||||
assert.equal(m.current.audio.volume, 0.6);
|
||||
m.duck = 0.45;
|
||||
for (let i = 0; i < 40; i++) m.update('g.mp3', 0.1);
|
||||
assert.ok(Math.abs(m.current.audio.volume - 0.27) < 1e-9, 'abbassata sotto la voce');
|
||||
m.duck = 1;
|
||||
for (let i = 0; i < 40; i++) m.update('g.mp3', 0.1);
|
||||
assert.equal(m.current.audio.volume, 0.6);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { voiceLines, voiceKey, Voices } from '../src/engine/voices.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
const index = JSON.parse(readFileSync(new URL('../assets/voices/index.json', import.meta.url), 'utf8'));
|
||||
const voci = JSON.parse(readFileSync(new URL('../tools/voci.json', import.meta.url), 'utf8'));
|
||||
|
||||
test('le battute si trovano ovunque: introduzione, scene, oggetti, dialoghi e rami if', () => {
|
||||
const lines = voiceLines(story), has = (who, part) => lines.some(l => l.who === who && l.text.includes(part));
|
||||
assert.ok(has('andrea', 'Fine maggio 2007'), 'introduzione');
|
||||
assert.ok(has('andrea', 'Il palio è sparito!'), 'ingresso in una scena, dentro un if');
|
||||
assert.ok(has('vecchietta', 'cadde dal cielo'), 'dialogo, con chi parla');
|
||||
assert.ok(has('castello', 'vicino alle sue impronte'), 'ramo else annidato');
|
||||
assert.ok(has('andrea', 'pagina sola'), 'azione di un oggetto');
|
||||
assert.ok(!lines.some(l => /^[.…\s]+$/.test(l.text)), 'i silenzi («...») non hanno voce');
|
||||
});
|
||||
|
||||
test('ogni battuta ha la sua voce (sennò: make voci) e ogni personaggio una voce in tools/voci.json', () => {
|
||||
for (const { who, text } of voiceLines(story)) {
|
||||
assert.ok(voci[who]?.speaker, `manca la voce di ${who} in tools/voci.json`);
|
||||
const entry = index[voiceKey(who, text)];
|
||||
assert.ok(entry, `senza voce: ${who}: ${text}`);
|
||||
assert.ok(existsSync(new URL(`../assets/voices/${entry.file}`, import.meta.url)), `manca il file ${entry.file}`);
|
||||
assert.ok(entry.sec > 0.3 && entry.sec < 30, `durata strana: ${entry.sec} s per «${text}»`);
|
||||
}
|
||||
});
|
||||
|
||||
test('il castello e il leone hanno voci diverse da tutti gli altri', () => {
|
||||
for (const special of ['castello', 'leone'])
|
||||
for (const [who, v] of Object.entries(voci))
|
||||
if (who !== special && !who.startsWith('_')) assert.notEqual(v.speaker, voci[special].speaker, `${special} e ${who}`);
|
||||
});
|
||||
|
||||
test('il fumetto resta a schermo almeno quanto la voce', () => {
|
||||
const g = new Game(story, { voiceTime: (who, text) => (text.startsWith('Il palio') ? 9 : undefined) });
|
||||
g.go(story.start.scene, story.start.at);
|
||||
g.update(0.05);
|
||||
assert.ok(g.speech.text.startsWith('Il palio'));
|
||||
assert.ok(g.speech.left > 9, 'la voce lunga allunga il fumetto');
|
||||
settle(g);
|
||||
const quiet = new Game(story);
|
||||
quiet.go(story.start.scene, story.start.at);
|
||||
quiet.update(0.05);
|
||||
assert.ok(quiet.speech.left < 9, 'senza voce vale la durata di lettura');
|
||||
});
|
||||
|
||||
test('le voci si cercano per chi parla e testo; senza indice il gioco resta muto', () => {
|
||||
const v = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } });
|
||||
assert.equal(v.seconds('leone', 'Grr'), 1.5);
|
||||
assert.equal(v.seconds('andrea', 'Grr'), undefined);
|
||||
assert.equal(new Voices().play('andrea', 'Ciao'), false);
|
||||
assert.equal(v.playing, false);
|
||||
});
|
||||
|
||||
test('il leone e il castello parlano dalla loro posizione, non da quella di Andrea', () => {
|
||||
const g = new Game(story);
|
||||
g.go('salita_villa_adami', [340, 352]);
|
||||
assert.deepEqual(g.speakerPoint('leone'), story.scenes.salita_villa_adami.speakers.leone);
|
||||
g.go('trebbio', [110, 345]);
|
||||
assert.deepEqual(g.speakerPoint('castello'), story.scenes.trebbio.speakers.castello);
|
||||
});
|
||||
Reference in New Issue
Block a user