// Web Audio API Audio System with Voice Polyphony Limiting & SFX Throttling (function() { 'use strict'; // Musical note frequency lookup const N = { D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47, C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94, C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88, C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77, C6: 1046.50 }; const THEMES = { day: { tempo: 230, bassType: 'triangle', leadType: 'sine', bassVol: 0.10, leadVol: 0.08, bass: [ N.A2, null, N.E3, null, N.A2, null, N.E3, null, N.D3, null, N.A3, null, N.E3, null, N.G3, null ], lead: [ N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4, N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4 ] }, night: { tempo: 280, bassType: 'sine', leadType: 'triangle', bassVol: 0.12, leadVol: 0.07, bass: [ N.C3, null, N.G3, null, N.A2, null, N.E3, null, N.F3, null, N.C4, null, N.G3, null, N.B3, null ], lead: [ N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5, N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null ] }, pool: { tempo: 210, bassType: 'triangle', leadType: 'sine', bassVol: 0.11, leadVol: 0.09, bass: [ N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4, N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null ], lead: [ N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4, N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null ] }, fog: { tempo: 310, bassType: 'sawtooth', leadType: 'triangle', bassVol: 0.07, leadVol: 0.07, bass: [ N.D2, null, N.A2, null, N.D2, null, N.Bb2, null, N.A2, null, N.F2, null, N.G2, null, N.A2, null ], lead: [ N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5, N.D5, null, N.A4, null, null, N.F5, N.E5, null ] }, roof: { tempo: 190, bassType: 'triangle', leadType: 'sawtooth', bassVol: 0.11, leadVol: 0.06, bass: [ N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3, N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null ], lead: [ N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5, N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null ] }, boss: { tempo: 165, bassType: 'sawtooth', leadType: 'square', bassVol: 0.09, leadVol: 0.06, bass: [ N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2, N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3 ], lead: [ N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5, null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5 ] } }; class AudioSystem { constructor() { this.ctx = null; this.muted = false; // Music State this.musicInterval = null; this.musicPlaying = false; this.musicStep = 0; this.currentThemeName = 'day'; // Polyphony & Voice Limiting State this.maxGlobalVoices = 24; this.activeVoiceCount = 0; this.activeVoicesPerKey = new Map(); this.lastPlayTime = new Map(); // Per-sound configuration (polyphony cap & throttle cooldown ms) this.sfxLimits = { shoot: { maxVoices: 4, minIntervalMs: 40 }, hit: { maxVoices: 4, minIntervalMs: 35 }, splat: { maxVoices: 4, minIntervalMs: 35 }, chomp: { maxVoices: 3, minIntervalMs: 65 }, coin: { maxVoices: 4, minIntervalMs: 35 }, sun: { maxVoices: 3, minIntervalMs: 50 }, plant: { maxVoices: 3, minIntervalMs: 40 }, explosion: { maxVoices: 3, minIntervalMs: 80 }, sizzle: { maxVoices: 2, minIntervalMs: 100 }, mower: { maxVoices: 2, minIntervalMs: 150 }, strike: { maxVoices: 3, minIntervalMs: 50 }, btn: { maxVoices: 2, minIntervalMs: 30 } }; } init() { if (!this.ctx) { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (AudioCtx) { this.ctx = new AudioCtx(); } } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume().catch(() => {}); } } toggleMute() { this.muted = !this.muted; return this.muted; } isMuted() { return this.muted; } // Check voice limits and throttling canPlayVoice(key) { if (this.muted) return false; this.init(); if (!this.ctx) return false; const now = performance.now(); const cfg = this.sfxLimits[key] || { maxVoices: 4, minIntervalMs: 40 }; // 1. Throttle check const last = this.lastPlayTime.get(key) || 0; if (now - last < cfg.minIntervalMs) { return false; } // 2. Global polyphony limit if (this.activeVoiceCount >= this.maxGlobalVoices) { return false; } // 3. Per-sound polyphony limit const currentActive = this.activeVoicesPerKey.get(key) || 0; if (currentActive >= cfg.maxVoices) { return false; } // Register timestamp this.lastPlayTime.set(key, now); return true; } registerVoice(key, durationSec) { this.activeVoiceCount++; const current = this.activeVoicesPerKey.get(key) || 0; this.activeVoicesPerKey.set(key, current + 1); const release = () => { this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); const cur = this.activeVoicesPerKey.get(key) || 1; this.activeVoicesPerKey.set(key, Math.max(0, cur - 1)); }; setTimeout(release, Math.ceil(durationSec * 1000) + 20); } playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) { if (this.muted) return; this.init(); if (!this.ctx) return; try { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, this.ctx.currentTime); gain.gain.setValueAtTime(startVol, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(endVol, this.ctx.currentTime + duration); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(); osc.stop(this.ctx.currentTime + duration); } catch (e) {} } playShoot() { if (!this.canPlayVoice('shoot')) return; try { const ctx = this.ctx; const dur = 0.11; this.registerVoice('shoot', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(620, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + dur); gain.gain.setValueAtTime(0.24, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playHit() { if (!this.canPlayVoice('hit')) return; try { const ctx = this.ctx; const dur = 0.08; this.registerVoice('hit', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'square'; osc.frequency.setValueAtTime(180, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + dur); gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playSplat() { if (!this.canPlayVoice('splat')) return; try { const ctx = this.ctx; const dur = 0.09; this.registerVoice('splat', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(280, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + dur); gain.gain.setValueAtTime(0.22, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playSun() { if (!this.canPlayVoice('sun')) return; try { const ctx = this.ctx; this.registerVoice('sun', 0.35); const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 notes.forEach((freq, idx) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05); gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2); osc.connect(gain); gain.connect(ctx.destination); osc.start(ctx.currentTime + idx * 0.05); osc.stop(ctx.currentTime + idx * 0.05 + 0.2); }); } catch (e) {} } playPlant() { if (!this.canPlayVoice('plant')) return; try { const ctx = this.ctx; const dur = 0.15; this.registerVoice('plant', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(220, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playChomp() { if (!this.canPlayVoice('chomp')) return; try { const ctx = this.ctx; const dur = 0.1; this.registerVoice('chomp', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(140, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + dur); gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playExplosion() { if (!this.canPlayVoice('explosion')) return; try { const ctx = this.ctx; const dur = 0.5; this.registerVoice('explosion', dur); 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; } const noise = ctx.createBufferSource(); noise.buffer = buffer; const filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(800, ctx.currentTime); filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + dur); const gain = ctx.createGain(); gain.gain.setValueAtTime(0.5, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination); noise.start(); } catch (e) {} } playMower() { if (!this.canPlayVoice('mower')) return; try { const ctx = this.ctx; const dur = 1.2; this.registerVoice('mower', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(90, ctx.currentTime); osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + dur); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playCoin() { if (!this.canPlayVoice('coin')) return; try { const ctx = this.ctx; const dur = 0.3; this.registerVoice('coin', dur); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5 osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6 gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + dur); } catch (e) {} } playSizzle() { if (!this.canPlayVoice('sizzle')) return; try { const ctx = this.ctx; const dur = 0.8; this.registerVoice('sizzle', dur); 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; } const noise = ctx.createBufferSource(); noise.buffer = buffer; const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(1400, ctx.currentTime); filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + dur); const gain = ctx.createGain(); gain.gain.setValueAtTime(0.4, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination); noise.start(); } catch (e) {} } playMine() { this.playExplosion(); this.playTone(550, 'sine', 0.15, 0.3, 0.01); } playVault() { if (this.muted) return; this.init(); if (!this.ctx) return; try { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(180, this.ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(620, this.ctx.currentTime + 0.3); gain.gain.setValueAtTime(0.25, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(); osc.stop(this.ctx.currentTime + 0.3); } catch (e) {} } playGulp() { this.playTone(180, 'sine', 0.18, 0.35, 0.01); setTimeout(() => this.playTone(120, 'sine', 0.22, 0.3, 0.01), 90); } playStrike() { if (!this.canPlayVoice('strike')) return; this.registerVoice('strike', 0.2); this.playTone(240, 'square', 0.1, 0.3, 0.01); this.playTone(120, 'sawtooth', 0.2, 0.35, 0.01); } playHugeWave() { this.playTone(180, 'sawtooth', 0.6, 0.35, 0.01); } playButtonClick() { if (!this.canPlayVoice('btn')) return; this.registerVoice('btn', 0.06); this.playTone(480, 'sine', 0.05, 0.18, 0.01); } playWin() { if (this.muted) return; this.init(); if (!this.ctx) return; const fanfare = [392, 523, 659, 784, 1046]; fanfare.forEach((f, i) => { setTimeout(() => this.playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160); }); } playLose() { if (this.muted) return; this.init(); if (!this.ctx) return; const doom = [400, 360, 320, 280, 220]; doom.forEach((f, i) => { setTimeout(() => this.playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200); }); } // Background Music (Multi-Biome Procedural Chiptune Synthesizer) startMusic(themeName = 'day') { const targetTheme = THEMES[themeName] ? themeName : 'day'; if (this.musicPlaying && this.currentThemeName === targetTheme && this.musicInterval) { return; } this.currentThemeName = targetTheme; this.musicPlaying = true; this.init(); if (this.musicInterval) { clearInterval(this.musicInterval); this.musicInterval = null; } this.musicStep = 0; const theme = THEMES[this.currentThemeName]; this.musicInterval = setInterval(() => { if (!this.musicPlaying || this.muted) return; const b = theme.bass[this.musicStep % theme.bass.length]; const l = theme.lead[this.musicStep % theme.lead.length]; if (b) { this.playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001); } if (l) { this.playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001); } this.musicStep++; }, theme.tempo); } setTheme(themeName) { if (this.musicPlaying) { this.startMusic(themeName); } else { this.currentThemeName = THEMES[themeName] ? themeName : 'day'; } } getTheme() { return this.currentThemeName; } stopMusic() { this.musicPlaying = false; if (this.musicInterval) { clearInterval(this.musicInterval); this.musicInterval = null; } } toggleMusic() { if (this.musicPlaying) { this.stopMusic(); return false; } else { this.startMusic(this.currentThemeName); return true; } } isMusicPlaying() { return this.musicPlaying; } } // Instantiate singleton instance const audioInstance = new AudioSystem(); window.AudioSystem = AudioSystem; window.PvZAudio = audioInstance; })();