Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
151 lines
7.9 KiB
JavaScript
151 lines
7.9 KiB
JavaScript
// 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); }
|
|
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 }); }
|
|
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]);
|
|
}
|
|
}
|