// Web Audio synthesis — whistle, goal cheer, click, crowd, etc. // No external assets. All generated on the fly. let _ctx = null; function getCtx() { if (!_ctx) { try { _ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} } if (_ctx && _ctx.state === 'suspended') _ctx.resume(); return _ctx; } let _muted = false; function setMuted(v) { _muted = v; } function isMuted() { return _muted; } function _env(gain, t, attack, decay, sustain, release, peak = 0.3) { gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(peak, t + attack); gain.gain.linearRampToValueAtTime(peak * sustain, t + attack + decay); gain.gain.linearRampToValueAtTime(0, t + attack + decay + release); } function tone(freq, dur, type = 'sine', peak = 0.18, slideTo = null) { if (_muted) return; const ctx = getCtx(); if (!ctx) return; const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = type; o.frequency.setValueAtTime(freq, ctx.currentTime); if (slideTo != null) o.frequency.linearRampToValueAtTime(slideTo, ctx.currentTime + dur); _env(g, ctx.currentTime, 0.005, 0.05, 0.6, dur - 0.05, peak); o.connect(g).connect(ctx.destination); o.start(); o.stop(ctx.currentTime + dur + 0.05); } function playTap() { tone(720, 0.06, 'square', 0.08); } function playSelect() { tone(880, 0.08, 'triangle', 0.14); setTimeout(() => tone(1320, 0.10, 'triangle', 0.12), 60); } function playWrong() { tone(220, 0.22, 'sawtooth', 0.14, 110); } function playWhistle() { if (_muted) return; const ctx = getCtx(); if (!ctx) return; // double-whistle trill for (let i = 0; i < 2; i++) { setTimeout(() => { const o = ctx.createOscillator(); const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); const g = ctx.createGain(); o.type = 'sine'; o.frequency.value = 2300; lfo.frequency.value = 28; lfoGain.gain.value = 180; lfo.connect(lfoGain).connect(o.frequency); _env(g, ctx.currentTime, 0.02, 0.05, 0.8, 0.22, 0.16); o.connect(g).connect(ctx.destination); lfo.start(); o.start(); o.stop(ctx.currentTime + 0.32); lfo.stop(ctx.currentTime + 0.32); }, i * 180); } } function playGoal() { if (_muted) return; const ctx = getCtx(); if (!ctx) return; // Triumphant arpeggio const notes = [392, 523, 659, 784, 988]; notes.forEach((f, i) => { setTimeout(() => tone(f, 0.18, 'triangle', 0.16), i * 70); }); // crowd swell setTimeout(() => playCrowd(0.9), 250); } function playCrowd(dur = 0.6) { if (_muted) return; const ctx = getCtx(); if (!ctx) return; const bufferSize = Math.floor(ctx.sampleRate * dur); const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * 0.5; const src = ctx.createBufferSource(); src.buffer = buffer; const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900; const g = ctx.createGain(); _env(g, ctx.currentTime, 0.08, 0.15, 0.6, dur - 0.2, 0.22); src.connect(lp).connect(g).connect(ctx.destination); src.start(); src.stop(ctx.currentTime + dur + 0.05); } function playTick() { tone(1400, 0.03, 'square', 0.06); } function playReveal() { // ascending rapid ticks then chord for (let i = 0; i < 8; i++) setTimeout(() => tone(600 + i * 80, 0.04, 'square', 0.08), i * 80); setTimeout(() => { tone(523, 0.4, 'triangle', 0.16); tone(659, 0.4, 'triangle', 0.14); tone(784, 0.4, 'triangle', 0.12); }, 700); } function playCoin() { tone(1320, 0.06, 'square', 0.12); setTimeout(() => tone(1760, 0.10, 'square', 0.12), 70); } Object.assign(window, { Sound: { playTap, playSelect, playWrong, playWhistle, playGoal, playCrowd, playTick, playReveal, playCoin, setMuted, isMuted, getCtx }, });