diff --git a/assets/audio/biliardo.mp3 b/assets/audio/biliardo.mp3 new file mode 100644 index 0000000..798dccf Binary files /dev/null and b/assets/audio/biliardo.mp3 differ diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index b63a84d..97f8385 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -109,6 +109,11 @@ export class Soundscape { else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan }); else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); } else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan }); + else if (kind === 'tavolo-lontano') { + // Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano. + this.tone(1700, 900, 0.04, 0.025, 0, pan); + this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan); + } else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan); else if (kind === 'bird') { for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan); diff --git a/src/story/soundscapes.js b/src/story/soundscapes.js index fa47565..2e9d5c0 100644 --- a/src/story/soundscapes.js +++ b/src/story/soundscapes.js @@ -5,6 +5,8 @@ export const soundscapes = { acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] }, pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] }, circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] }, + // Gli altri tavoli della sala: una stecca, una biglia che ne tocca un'altra, ogni tanto un bicchiere al bar. + biliardo: { bed: null, level: 0, day: ['tavolo-lontano', 'tavolo-lontano', 'glass'], night: ['tavolo-lontano', 'tavolo-lontano', 'glass'], gap: [4, 10] }, fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] }, fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] }, }; @@ -15,6 +17,7 @@ const groups = { pieve: ['pieve', 'pieve_interno', 'oratorio'], circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'], fortezza: ['fortezza'], + biliardo: ['circolo_4_maggio'], }; export function soundscapeFor(scene, closeup) { diff --git a/src/story/story.js b/src/story/story.js index fd8d4a9..40806cb 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -1056,6 +1056,7 @@ export const story = { name: 'Circolo 4 Maggio, sala dei biliardi', map: [29, 48], background: 'assets/scenes/circolo-4-maggio.png', + music: 'assets/audio/biliardo.mp3', // trio jazz da circolo (tools/jazz_biliardo.py, 25 ottobre) indoor: true, walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]], depth: [[190, 1.2], [360, 2.5]], diff --git a/tests/multimedia.test.mjs b/tests/multimedia.test.mjs index 174de12..c4d10ed 100644 --- a/tests/multimedia.test.mjs +++ b/tests/multimedia.test.mjs @@ -83,6 +83,7 @@ test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo ri test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => { assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura'); assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno')); + assert.equal(soundscapeFor('circolo_4_maggio'), 'biliardo', 'nella sala si sentono gli altri tavoli'); for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird')); const s = new Soundscape({ createContext: audioContext, random: () => 0.3 }); s.unlock(); s.configure({ active: true }); diff --git a/tools/jazz_biliardo.py b/tools/jazz_biliardo.py new file mode 100644 index 0000000..bb4a83d --- /dev/null +++ b/tools/jazz_biliardo.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""La musica della sala dei biliardi del Circolo 4 Maggio (assets/audio/biliardo.mp3), sintetizzata da zero: un trio +jazz da circolo in si bemolle, con lo swing: contrabbasso che cammina, piano elettrico che accompagna, piatto e spazzole, +e una melodia originale di vibrafono. Sedici battute che chiudono sul giro di ritorno: il loop non si sente. + python3 tools/jazz_biliardo.py # scrive assets/audio/biliardo.mp3 (serve ffmpeg) +""" +import math +import random +import subprocess +import tempfile +import wave +from array import array +from pathlib import Path + +SR = 22050 +BPM = 104 +BEAT = 60 / BPM +SWING = 2 / 3 # la croma in levare cade ai due terzi del movimento +OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "biliardo.mp3" + +# Il giro: un accordo per battuta. Basso (fondamentale), qualità, voci del piano (senza fondamentale). +CHORDS = [ + ("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]), + ("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]), + ("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]), + ("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]), + ("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("Bb1", "dom", ["D3", "Ab3", "C4", "F4"]), + ("Eb2", "maj", ["G3", "Bb3", "D4", "F4"]), ("E2", "dim", ["E3", "G3", "Bb3", "Db4"]), + ("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]), + ("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]), +] +THIRD_FIFTH = {"maj": (4, 7), "dom": (4, 7), "min": (3, 7), "dim": (3, 6)} +# La melodia del vibrafono: (movimento dall'inizio, nota, durata in movimenti). I levare cadono a .66. +S = SWING +MELODY = [ + (0, "F5", 1.5), (1 + S, "D5", 1 - S), (2, "C5", S), (2 + S, "Bb4", 1.3), + (5, "D5", 1), (6, "F5", S), (6 + S, "E5", 1.3), + (8, "Eb5", 1.5), (9 + S, "G5", 1 - S), (10, "Bb5", 2), + (12 + S, "A5", 1 - S), (13, "G5", S), (13 + S, "F5", 1 - S), (14, "Eb5", 1), (15, "C5", 1), + (16, "D5", 1), (17, "F5", S), (17 + S, "A5", 1.3), (19 + S, "G5", 1 - S), + (20, "B4", 1), (21, "D5", 1), (22, "F5", 1.5), + (24, "Eb5", S), (24 + S, "D5", 1 - S), (25, "C5", 1), (26, "G4", 2), + (29, "A4", S), (29 + S, "C5", 1 - S), (30, "Eb5", 1), (31, "D5", 1), + (33 + S, "F5", 1 - S), (34, "D5", 1.6), + (36, "Ab5", 1.5), (37 + S, "G5", 1 - S), (38, "F5", 2), + (40, "G5", 1), (41, "Bb5", 1), (42, "F5", 2), + (44, "E5", 1), (45, "G5", 1), (46, "Bb5", 1.8), + (48, "F5", 1.5), (49 + S, "E5", 1 - S), (50, "D5", 2), + (52, "B4", 1), (53, "D5", S), (53 + S, "F5", 1.3), + (56, "Eb5", 1.5), (57 + S, "D5", 1 - S), (58, "C5", 2), + (60, "A4", 1), (61, "C5", 1), (62, "F5", 1.8), +] +DUR = len(CHORDS) * 4 * BEAT +buf = [0.0] * int(SR * DUR) +rng = random.Random(4) + + +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(m: int) -> float: + return 440 * 2 ** ((m - 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.008, + tremolo: float = 0.0, rate: float = 5.0, release: float = 0.08): + n = int((length + release) * SR) + out = [] + 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) + trem = 1 - tremolo * (0.5 + 0.5 * math.sin(2 * math.pi * rate * t)) + out.append(vol * env * trem * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) for k, a, d in partials)) + return out + + +def noise(length: float, vol: float, decay: float, bright: bool, attack: float = 0.002): + """Rumore filtrato alla buona: la differenza fra campioni vicini è il piatto, la media le spazzole.""" + n, out, prev, low = int(length * SR), [], 0.0, 0.0 + for i in range(n): + t = i / SR + x = rng.uniform(-1, 1) + if bright: + v, prev = x - prev, x + else: + low += 0.08 * (x - low) + v = low * 3 + out.append(vol * min(1.0, t / attack) * math.exp(-t / decay) * v) + return out + + +at = lambda beat: beat * BEAT # noqa: E731 +LONG = 99 + +for bar, (root, quality, voices) in enumerate(CHORDS): + b0 = bar * 4 + # Contrabbasso: fondamentale, terza, quinta, poi un semitono vicino alla fondamentale dopo. + r = midi(root) + third, fifth = THIRD_FIFTH[quality] + nxt = midi(CHORDS[(bar + 1) % len(CHORDS)][0]) + walk = [r, r + third, r + fifth, nxt + (1 if bar % 2 else -1)] + for k, m in enumerate(walk): + add(at(b0 + k), tone(hz(m), BEAT * 0.92, 0.26, ((1, 1.0, LONG), (2, 0.45, 0.5), (3, 0.12, 0.25)), decay=0.7)) + # Piano elettrico: colpi corti sul levare del due e sul quattro, una battuta sì e una no un po' diverso. + hits = [(1 + SWING, 0.5), (3, 0.9)] if bar % 2 == 0 else [(0 + SWING, 0.4), (2 + SWING, 1.2)] + for beat, length in hits: + for v in voices: + add(at(b0 + beat), tone(hz(midi(v)), BEAT * length, 0.045, ((1, 1.0, LONG), (2, 0.22, 0.6), (7, 0.05, 0.05)), + decay=1.1, tremolo=0.2, rate=4.6)) + # Piatto: ogni movimento, più il levare del due e del quattro; spazzole sul due e sul quattro; cassa leggerissima. + for k in range(4): + add(at(b0 + k), noise(0.35, 0.05 if k % 2 else 0.035, 0.18, bright=True)) + if k % 2: + add(at(b0 + k + SWING), noise(0.25, 0.03, 0.12, bright=True)) + add(at(b0 + k) - 0.04, noise(0.2, 0.05, 0.09, bright=False, attack=0.05)) + else: + add(at(b0 + k), tone(55, 0.12, 0.05, ((1, 1.0, LONG),), decay=0.08)) + +for beat, note, length in MELODY: + # Vibrafono: la nota, un armonico alto che sparisce subito, il tremolo del motore. + add(at(beat), tone(hz(midi(note)), BEAT * length, 0.11, ((1, 1.0, LONG), (4, 0.18, 0.12), (10, 0.05, 0.03)), + decay=1.6, tremolo=0.25, rate=5.6, release=0.4)) + +peak = max(abs(x) for x in buf) or 1 +with tempfile.TemporaryDirectory() as tmp: + w = Path(tmp) / "biliardo.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()) + # Una saletta piccola: poco riverbero, niente alti taglienti. + af = "aecho=0.8:0.6:45|95:0.18|0.1,lowpass=f=9000,volume=1.3" + 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)")