diff --git a/assets/audio/bocce.mp3 b/assets/audio/bocce.mp3 new file mode 100644 index 0000000..3eeb18b Binary files /dev/null and b/assets/audio/bocce.mp3 differ diff --git a/assets/voices/andrea-6797d70153.mp3 b/assets/voices/andrea-6797d70153.mp3 new file mode 100644 index 0000000..541b68e Binary files /dev/null and b/assets/voices/andrea-6797d70153.mp3 differ diff --git a/assets/voices/andrea-97d75e2a9d.mp3 b/assets/voices/andrea-97d75e2a9d.mp3 deleted file mode 100644 index 886a1eb..0000000 Binary files a/assets/voices/andrea-97d75e2a9d.mp3 and /dev/null differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 47589a6..728fd24 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -307,26 +307,6 @@ "file": "andrea-68a845881b.mp3", "sec": 1.94 }, - "andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": { - "file": "andrea-97d75e2a9d.mp3", - "sec": 5.38 - }, - "andrea|Buongiorno, Fra Peppino!": { - "file": "andrea-94988dc8c6.mp3", - "sec": 1.74 - }, - "fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": { - "file": "fra_peppino-651c4d95ab.mp3", - "sec": 3.72 - }, - "andrea|Il palio. È sparito.": { - "file": "andrea-c9c068f507.mp3", - "sec": 2.39 - }, - "fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": { - "file": "fra_peppino-8b3d0a5b8f.mp3", - "sec": 4.47 - }, "andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": { "file": "andrea-43fd5c2b27.mp3", "sec": 4.46 @@ -543,6 +523,26 @@ "file": "andrea-43d3435d61.mp3", "sec": 2.99 }, + "andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": { + "file": "andrea-6797d70153.mp3", + "sec": 8.45 + }, + "andrea|Buongiorno, Fra Peppino!": { + "file": "andrea-94988dc8c6.mp3", + "sec": 1.74 + }, + "fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": { + "file": "fra_peppino-651c4d95ab.mp3", + "sec": 3.72 + }, + "andrea|Il palio. È sparito.": { + "file": "andrea-c9c068f507.mp3", + "sec": 2.39 + }, + "fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": { + "file": "fra_peppino-8b3d0a5b8f.mp3", + "sec": 4.47 + }, "andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": { "file": "andrea-486c980f52.mp3", "sec": 7.48 diff --git a/src/story/story.js b/src/story/story.js index a63f10f..1e2ecaf 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -135,7 +135,7 @@ export const story = { }, // Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria. bocce: { - type: 'bocce', + type: 'bocce', music: 'assets/audio/bocce.mp3', // la polka del pallaio (tools/polka_bocce.py, Andrea: «un sound suo») picture: 'assets/closeups/pallaio.png', names: { player: 'Andrea', cpu: 'Mariano' }, win: [ @@ -576,23 +576,13 @@ export const story = { map: [56, 45], background: 'assets/scenes/pieve-dalla-salita.png', // Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea). - speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] }, - // Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre). - people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }], + speakers: { san_pietro: [300, 100] }, apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }], // La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra). walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]], depth: [[215, 0.42], [360, 1.15]], entry: [30, 320], hotspots: [ - { - // Battute provvisorie: le vere le scrive Andrea. - id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk', - actions: { - look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }], - talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }], - }, - }, { id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up', actions: { @@ -900,7 +890,19 @@ export const story = { walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]], depth: [[292, 0.78], [360, 1.1]], entry: [320, 345], + // Fra Peppino da Gricignano (sprite di Codex dalla foto di Andrea, 25 ottobre): stava sul sagrato della Pieve, + // ma è un francescano, e il Bosco ai Frati è il loro convento (Andrea, 26 ottobre). + speakers: { fra_peppino: [470, 238] }, + people: [{ who: 'fra_peppino', at: [470, 304], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }], hotspots: [ + { + // Battute provvisorie: le vere le scrive Andrea. + id: 'fra_peppino', name: 'Fra Peppino', box: [458, 248, 26, 58], at: [430, 306], face: 'right', default: 'talk', + actions: { + look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.' }], + talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }], + }, + }, { id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up', actions: { diff --git a/tools/polka_bocce.py b/tools/polka_bocce.py new file mode 100644 index 0000000..93ed451 --- /dev/null +++ b/tools/polka_bocce.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""La musica delle bocce al pallaio (assets/audio/bocce.mp3), sintetizzata da zero come il valzer delle carte: una +polka da circolo in do maggiore, in due quarti. Basso «um-pa» sui due movimenti, fisarmonica a contrattempo e una +melodia originale di clarinetto, con un filo di vibrato. Trentadue battute che chiudono sul sol settima: il loop non +si sente. (Andrea, 26 ottobre: «anche le bocce avrebbero bisogno di un sound suo».) + python3 tools/polka_bocce.py # scrive assets/audio/bocce.mp3 (serve ffmpeg) +""" +import math +import random +import subprocess +import tempfile +import wave +from array import array +from pathlib import Path + +SR = 22050 +BPM = 116 +BEAT = 60 / BPM +OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "bocce.mp3" + +HARMONY = {"C": ["E4", "G4", "C5"], "G7": ["F4", "G4", "B4"], "F": ["F4", "A4", "C5"], "A7": ["E4", "G4", "C#5"], + "Dm": ["F4", "A4", "D5"]} +BASS = {"C": ("C2", "G2"), "G7": ("G1", "D2"), "F": ("F1", "C2"), "A7": ("A1", "E2"), "Dm": ("D2", "A1")} +CHORDS = ("C C G7 G7 G7 G7 C C C C F F C G7 C C " + "F F C C G7 G7 C C F F C A7 Dm G7 C G7").split() +h = 0.5 +MELODY = [ + [("E5", h), ("G5", h), ("C6", 1)], [("B5", h), ("A5", h), ("G5", 1)], [("F5", h), ("G5", h), ("A5", h), ("B5", h)], [("D6", 1), ("B5", 1)], + [("A5", h), ("B5", h), ("A5", h), ("G5", h)], [("F5", 1), ("D5", 1)], [("E5", h), ("F5", h), ("G5", 1)], [("C5", 2)], + [("E5", h), ("G5", h), ("C6", 1)], [("E6", h), ("D6", h), ("C6", 1)], [("A5", h), ("C6", h), ("A5", h), ("F5", h)], [("A5", 1.5), ("G5", h)], + [("G5", h), ("E5", h), ("C5", h), ("E5", h)], [("D5", h), ("F5", h), ("B5", 1)], [("C6", 1), ("G5", 1)], [("C6", 1), (None, 1)], + [("C6", h), ("A5", h), ("F5", 1)], [("A5", h), ("G5", h), ("F5", 1)], [("G5", h), ("E5", h), ("C5", 1)], [("E5", h), ("G5", h), ("C6", 1)], + [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("D5", 1), ("G5", 1)], [("E5", h), ("G5", h), ("E5", h), ("C5", h)], [("G4", 2)], + [("A4", h), ("C5", h), ("F5", 1)], [("A5", 1), ("F5", 1)], [("E5", h), ("G5", h), ("C6", 1)], [("C#6", 1), ("A5", 1)], + [("D6", h), ("C6", h), ("A5", 1)], [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("E5", 1), ("C5", 1)], [("D5", h), ("E5", h), ("F5", h), ("G5", h)], +] +assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 2 for bar in MELODY) +DUR = len(CHORDS) * 2 * BEAT +buf = [0.0] * int(SR * DUR) +rng = random.Random(11) + + +def midi(note: str) -> int: + 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 len(note) > 2 and note[1] == "b" else 0) + return semis + 12 * (int(note[-1]) + 1) + + +def hz(note: str) -> float: + return 440 * 2 ** ((midi(note) - 69) / 12) + + +def add(start: float, samples) -> None: + """Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo).""" + n0 = int(start * SR) + for i, v in enumerate(samples): + buf[(n0 + i) % len(buf)] += v + + +def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.006, release: float = 0.06, + vibrato: float = 0.0): + n = int((length + release) * SR) + out, phase = [], 0.0 + for i in range(n): + t = i / SR + env = min(1.0, t / attack) * math.exp(-t / decay) + if t > length: + env *= max(0.0, 1 - (t - length) / release) + # Il vibrato arriva dopo l'attacco, come quello di un fiato. + phase += 2 * math.pi * freq * (1 + vibrato * min(1.0, t / 0.25) * math.sin(2 * math.pi * 5.2 * t)) / SR + out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(k * phase) for k, a, d in partials)) + return out + + +at = lambda beat: beat * BEAT # noqa: E731 +LONG = 99 +# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge. +REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG)) +# Clarinetto: armonici dispari, il suono di legno. +REEDWOOD = ((1, 1.0, LONG), (3, 0.42, LONG), (5, 0.2, LONG), (7, 0.09, LONG), (2, 0.06, LONG)) + +for bar, name in enumerate(CHORDS): + b0 = bar * 2 + # Basso «um-pa»: fondamentale sull'uno, quinta sul due. + for k, note in enumerate(BASS[name]): + add(at(b0 + k), tone(hz(note), BEAT * 0.55, 0.32, ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.2, 0.12)), decay=0.35)) + # Fisarmonica a contrattempo, corta e frizzante. + for k in (0.5, 1.5): + for note in HARMONY[name]: + for detune, vol in ((1.0, 0.026), (1.004, 0.019)): + add(at(b0 + k), tone(hz(note) * detune, BEAT * 0.3, vol, REED, decay=LONG, attack=0.012, release=0.04)) + # Un piatto leggero sul due, quello della banda. + add(at(b0 + 1), [0.03 * math.exp(-i / SR / 0.08) * rng.uniform(-1, 1) for i in range(int(0.2 * SR))]) + +beat = 0.0 +for bar in MELODY: + for note, length in bar: + if note: + add(at(beat), tone(hz(note), BEAT * length * 0.9, 0.09, REEDWOOD, decay=LONG, attack=0.03, release=0.06, vibrato=0.006)) + beat += length + +peak = max(abs(x) for x in buf) or 1 +with tempfile.TemporaryDirectory() as tmp: + w = Path(tmp) / "bocce.wav" + with wave.open(str(w), "wb") as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(SR) + f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes()) + # All'aperto, accanto al campo: poca eco, alti morbidi. + af = "aecho=0.8:0.5:70|150:0.12|0.07,lowpass=f=8500,volume=1.25" + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True) +print(f"-> {OUT} ({DUR:.0f} s)")