diff --git a/assets/audio/cartello.mp3 b/assets/audio/cartello.mp3 new file mode 100644 index 0000000..073eff1 Binary files /dev/null and b/assets/audio/cartello.mp3 differ diff --git a/src/engine/music.js b/src/engine/music.js index 9d02d58..875e414 100644 --- a/src/engine/music.js +++ b/src/engine/music.js @@ -2,9 +2,11 @@ // Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo // permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock). -// Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco). +// Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco). Sotto il cartello +// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js). export function trackFor(story, { screen, scene }) { const m = story.music ?? {}; + if (screen === 'splash') return null; if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null; return story.scenes[scene]?.music ?? m.game ?? null; diff --git a/src/main.js b/src/main.js index ed27520..3da9a7e 100644 --- a/src/main.js +++ b/src/main.js @@ -205,7 +205,7 @@ function frame(now) { // sotto zero, il browser lancerebbe un errore e il ciclo dei fotogrammi si fermerebbe (gioco congelato). 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'; + const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('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) { @@ -232,8 +232,16 @@ function showSplash() { if (done) return; done = true; $('splash').hidden = true; + jingle?.pause(); // saltato il cartello, si salta anche la musichetta reveal(); }; + // La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto. + const jingle = story.music?.splash ? new Audio(story.music.splash) : null; + if (jingle) { + jingle.volume = music.muted ? 0 : music.volume; + jingle.play().catch(() => {}); + window.jingle = jingle; // per i test nel browser + } setTimeout(reveal, SPLASH_FADE); setTimeout(end, SPLASH_END); $('splash').addEventListener('click', end); diff --git a/src/story/story.js b/src/story/story.js index b5a8360..3a6bde9 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -13,7 +13,8 @@ export const story = { 'E stamattina, in piazza, è successo qualcosa...', ], // Colonna sonora di Andrea (fatta con Suno): titolo, paese, ricordi (fine e Trebbio). - music: { title: 'assets/audio/titolo.mp3', game: 'assets/audio/paese.mp3', ending: 'assets/audio/ricordi.mp3' }, + // splash: la musichetta del cartello d'apertura (tools/jingle.py), suonata una volta sola. + music: { splash: 'assets/audio/cartello.mp3', title: 'assets/audio/titolo.mp3', game: 'assets/audio/paese.mp3', ending: 'assets/audio/ricordi.mp3' }, characters: { andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, diff --git a/tests/browser.mjs b/tests/browser.mjs index 2d21853..424c95f 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -47,6 +47,8 @@ try { // Il cartello d'apertura, poi da solo il titolo. assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/); assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello'); + assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3$/, 'la musichetta del cartello'); + assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo'); await page.waitForTimeout(2800); await page.screenshot({ path: `${OUT}/00-cartello.png` }); // Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda. diff --git a/tests/music.test.mjs b/tests/music.test.mjs index 7dd12f5..938afa6 100644 --- a/tests/music.test.mjs +++ b/tests/music.test.mjs @@ -6,6 +6,7 @@ import { story } from '../src/story/story.js'; test('il brano giusto per titolo, fine e scena', () => { const s = { music: { title: 't.mp3', game: 'g.mp3', ending: 'e.mp3' }, scenes: { a: {}, b: { music: 'b.mp3' } } }; + assert.equal(trackFor(s, { screen: 'splash', scene: 'a' }), null, 'sotto il cartello suona solo la sua musichetta'); assert.equal(trackFor(s, { screen: 'title', scene: 'a' }), 't.mp3'); assert.equal(trackFor(s, { screen: 'intro', scene: 'a' }), 't.mp3'); assert.equal(trackFor(s, { screen: 'ending', scene: 'a' }), 'e.mp3'); diff --git a/tools/jingle.py b/tools/jingle.py new file mode 100644 index 0000000..6a59c87 --- /dev/null +++ b/tools/jingle.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""La musichetta del cartello «Manzolo (Poca) Art Presents» (assets/audio/cartello.mp3), sintetizzata da zero: +onde quadre e triangolari da 8 bit, a tempo con le scritte di style.css (#splash). + python3 tools/jingle.py # scrive assets/audio/cartello.mp3 (serve ffmpeg) + 0,2 s MANZOLO arpeggio che sale + 1,0 s Art accordo + 1,9 s (Poca) «uah-uaah» da trombone triste: l'arte è poca + 2,6 s PRESENTS accordo finale che si spegne prima della dissolvenza (4,3 s) +""" +import math +import subprocess +import tempfile +import wave +from array import array +from pathlib import Path + +SR = 44100 +DUR = 4.6 +OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "cartello.mp3" +buf = [0.0] * int(SR * DUR) + + +def hz(note: str) -> float: + names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} + semis = names[note[0]] + (1 if "#" in note else -1 if note[1] == "b" else 0) + return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12) + + +def square(phase: float, duty: float = 0.25) -> float: + return 1.0 if phase % 1 < duty else -1.0 + + +def triangle(phase: float) -> float: + return 4 * abs(phase % 1 - 0.5) - 1 + + +def saw(phase: float) -> float: + return 2 * (phase % 1) - 1 + + +def tone(start, length, freq, wave_fn=square, vol=0.18, attack=0.008, release=0.08, slide_to=None, vibrato=0.0): + """Una nota: inviluppo attacco/rilascio, glissando facoltativo verso slide_to, vibrato in semitoni.""" + phase = 0.0 + n0, n = int(start * SR), int(length * SR) + for i in range(n): + t = i / SR + f = freq if slide_to is None else freq * (slide_to / freq) ** (t / length) + if vibrato: + f *= 2 ** (vibrato * math.sin(2 * math.pi * 5.5 * t) / 12) + phase += f / SR + env = min(1.0, t / attack) * min(1.0, (length - t) / release) + if n0 + i < len(buf): + buf[n0 + i] += vol * env * wave_fn(phase) + + +# MANZOLO: arpeggio di do maggiore che sale, col basso. +for k, note in enumerate(["C4", "E4", "G4", "C5", "E5"]): + tone(0.2 + k * 0.13, 0.16, hz(note)) +tone(0.2, 0.75, hz("C3"), triangle, vol=0.3) +# Art: accordo pieno. +for note in ["G4", "C5", "E5"]: + tone(1.0, 0.55, hz(note), vol=0.11, release=0.25) +tone(1.0, 0.6, hz("C3"), triangle, vol=0.3, release=0.25) +# (Poca): il trombone triste, due note che scendono e l'ultima che si lamenta. +tone(1.9, 0.26, hz("Eb4"), saw, vol=0.3, attack=0.03, slide_to=hz("D4")) +tone(2.18, 0.42, hz("D4"), saw, vol=0.3, attack=0.03, slide_to=hz("Db4"), vibrato=0.35, release=0.15) +# PRESENTS: accordo finale arpeggiato veloce, poi lungo, che si spegne. +for k, note in enumerate(["C4", "E4", "G4", "C5"]): + tone(2.65 + k * 0.05, 1.6 - k * 0.05, hz(note), vol=0.1, release=1.1) +tone(2.65, 1.7, hz("C3"), triangle, vol=0.32, release=1.2) +tone(2.65, 0.12, hz("C6"), square, vol=0.08, release=0.06) + +peak = max(abs(x) for x in buf) +pcm = array("h", (int(x / peak * 0.85 * 32767) for x in buf)) +with tempfile.TemporaryDirectory() as tmp: + w = Path(tmp) / "cartello.wav" + with wave.open(str(w), "wb") as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(SR) + f.writeframes(pcm.tobytes()) + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", "lowpass=f=9000", "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True) +print(f"-> {OUT.relative_to(OUT.parent.parent.parent)} ({DUR} s)")