// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download, // nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi. import { soundscapes } from '../story/soundscapes.js'; export class Soundscape { constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) { this.volume = volume; this.createContext = createContext; this.random = random; this.context = null; this.nodes = new Set(); this.active = false; this.muted = false; this.key = null; this.elapsed = 0; this.next = 4; this.bed = null; } unlock() { try { if (!this.context) { this.context = this.createContext(); this.master = this.context.createGain(); this.master.gain.value = 0; this.master.connect(this.context.destination); const rate = this.context.sampleRate; this.noise = this.context.createBuffer(1, rate * 4, rate); const data = this.noise.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1; } if (this.context.state === 'suspended') this.context.resume().catch(() => {}); } catch { // Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura. this.master?.disconnect(); this.context = null; this.master = null; this.noise = null; } } get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; } configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) { this.active = active; this.muted = muted; this.volume = Math.max(0, Math.min(1, volume)); if (!this.context) return; const gain = this.audible ? this.volume * duck : 0; this.master.gain.cancelScheduledValues(this.context.currentTime); if (!gain) { this.master.gain.setValueAtTime(0, this.context.currentTime); this.clear(); } else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12); } clear() { for (const node of [...this.nodes]) node.stop(); this.bed = null; } // Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono. connect(source, gain, filter = null, pan = 0) { const panner = this.context.createStereoPanner?.(); if (panner) panner.pan.value = pan; const chain = [source, filter, gain, panner, this.master].filter(Boolean); for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]); const node = { stop: () => { if (!this.nodes.delete(node)) return; try { source.stop(); } catch { /* giĆ  finito */ } chain.slice(0, -1).forEach(n => n.disconnect()); } }; source.onended = node.stop; this.nodes.add(node); return node; } noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) { const c = this.context, now = c.currentTime; const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain(); source.buffer = this.noise; source.loop = loop; filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15)); if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); const node = this.connect(source, gain, filter, pan); source.start(); if (!loop) source.stop(now + duration); return { node, gain, level }; } tone(frequency, end, duration, level, delay = 0, pan = 0) { const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain(); source.type = 'sine'; source.frequency.setValueAtTime(frequency, at); source.frequency.exponentialRampToValueAtTime(end, at + duration); gain.gain.setValueAtTime(0, at); gain.gain.linearRampToValueAtTime(level, at + 0.015); gain.gain.exponentialRampToValueAtTime(0.0001, at + duration); this.connect(source, gain, null, pan); source.start(at); source.stop(at + duration + 0.03); } play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) { if (!this.audible) return; if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true }); else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan }); else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); } // I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere. else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); } else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan }); else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, pan); } else if (kind === 'card-shuffle') { this.noiseSound(0.55, 3100, 0.16, { paper: true, pan }); for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan); } else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan }); else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); } else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan }); else if (kind === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); } else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); } 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 }); } // Il basket: il tiro, la palla che batte per terra, il ferro, il tabellone, la retina. else if (kind === 'basket-throw') this.noiseSound(0.08, 1200, 0.05, { pan }); else if (kind === 'basket-bounce') { this.tone(190, 95, 0.11, 0.05 + strength * 0.12, 0, pan); this.noiseSound(0.06, 500, 0.05, { pan }); } else if (kind === 'basket-rim') { this.tone(1500, 1100, 0.14, 0.04 + strength * 0.1, 0, pan); this.tone(2300, 1900, 0.08, 0.03, 0, pan); } else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); } else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan }); else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, 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); } else if (kind === 'cricket') { for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan); } else if (kind === 'bell' || kind === 'glass') { const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65; for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]]) this.tone(base * ratio, base * ratio, duration, level, 0, pan); } } update(profile, phase, dt) { if (!this.audible) return; const key = `${profile}:${phase}`, def = soundscapes[profile]; if (!def) return; if (this.key !== key) { this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5; } if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true }); this.elapsed += dt; if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6); if (this.elapsed < this.next) return; const events = phase === 'sera' ? def.night : def.day; this.play(events[Math.floor(this.random() * events.length)]); this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]); } }