export class SoundSystem { constructor({ muted = false } = {}) { this.muted = muted; this.context = null; this.master = null; this.delaySend = null; this.musicOn = false; this.musicTimer = null; this._musicStep = 0; this._nextNoteTime = 0; this._baseStepDur = 60 / 132 / 4; this._stepDur = this._baseStepDur; this._intensity = 0; this._bossMode = false; this._stepsPerBar = 16; this._progression = [ { root: 73.42, chord: [146.83, 174.61, 220] }, { root: 58.27, chord: [116.54, 146.83, 174.61] }, { root: 87.31, chord: [174.61, 220, 261.63] }, { root: 65.41, chord: [130.81, 164.81, 196] }, ]; this._bossProgression = [ { root: 41.2, chord: [82.41, 98, 123.47] }, { root: 65.41, chord: [130.81, 164.81, 196] }, { root: 55, chord: [110, 130.81, 164.81] }, { root: 73.42, chord: [146.83, 174.61, 220] }, ]; this._arpPattern = [0, 1, 2, 1, 0, 2, 1, 2, 0, 1, 2, 1, 0, 2, 1, 2]; this._leadPattern = [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [587.33, 0, 698.46, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 587.33, 0, 523.25, 0], [0, 0, 0, 0, 0, 0, 0, 0, 440, 0, 523.25, 0, 587.33, 0, 0, 0], ]; this._leadPatternB = [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [659.26, 0, 880, 0, 987.77, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 0, 0], [0, 0, 0, 0, 587.33, 0, 659.26, 0, 698.46, 0, 659.26, 0, 587.33, 0, 0, 0], ]; this._bassPattern = [1, 0, 1, 0, 1, 0, 1, 0]; this._barPlan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar: -1 }; const unlock = () => { if (!this.context) { const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) return; this.context = new AudioContext(); this.master = this.context.createGain(); this.master.gain.value = this.muted ? 0 : 0.5; this.master.connect(this.context.destination); const delay = this.context.createDelay(0.6); delay.delayTime.value = 0.31; const feedback = this.context.createGain(); feedback.gain.value = 0.35; const wet = this.context.createGain(); wet.gain.value = 0.22; delay.connect(feedback); feedback.connect(delay); delay.connect(wet); wet.connect(this.master); this.delaySend = delay; } if (this.context.state === 'suspended') { this.context.resume().catch(() => {}); } }; window.addEventListener('keydown', unlock, { once: false }); window.addEventListener('pointerdown', unlock, { once: false }); } startMusic() { if (this.musicOn) return; this.musicOn = true; this._musicStep = 0; this._nextNoteTime = this.context ? this.context.currentTime + 0.1 : 0; if (this.musicTimer) clearInterval(this.musicTimer); this.musicTimer = setInterval(() => this._scheduleMusic(), 100); } stopMusic() { this.musicOn = false; if (this.musicTimer) { clearInterval(this.musicTimer); this.musicTimer = null; } } setIntensity(intensity) { const clamped = Math.max(0, Math.min(1, intensity)); this._intensity = clamped; this._stepDur = this._baseStepDur * (1 - 0.35 * clamped); } setBossMode(on) { this._bossMode = on; } _scheduleMusic() { if (!this.context || !this.musicOn) return; const aheadTime = this.context.currentTime + 0.5; const totalSteps = this._stepsPerBar * this._progression.length; while (this._nextNoteTime < aheadTime) { try { this._scheduleStep(this._musicStep, this._nextNoteTime); } catch (error) { console.error('Music schedule error', error); } this._nextNoteTime += this._stepDur; this._musicStep = (this._musicStep + 1) % totalSteps; } } _rollBarPlan(bar) { const plan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar }; if (bar === 1) { const roll = Math.random(); if (roll < 0.2) { plan.fill = true; } else if (roll < 0.32) { plan.breakdown = true; } } else if (bar === 2 || bar === 3) { plan.leadVariant = Math.random() < 0.35 ? 1 : 0; plan.lift = Math.random() < 0.25; } return plan; } _scheduleStep(step, time) { const delay = time - this.context.currentTime; const bar = Math.floor(step / this._stepsPerBar) % this._progression.length; const beat = step % this._stepsPerBar; const barDur = this._stepDur * this._stepsPerBar; const intensity = this._intensity; const drumGain = 1 + intensity * 0.6; if (beat === 0 || this._barPlan.bar !== bar) { this._barPlan = this._rollBarPlan(bar); } const plan = this._barPlan; const fill = plan.fill; const breakdown = plan.breakdown; const boss = this._bossMode; let { root, chord } = boss ? this._bossProgression[bar] : this._progression[bar]; if (plan.lift && bar === 2 && !boss) { chord = [174.61, 207.65, 261.63]; } if (boss && beat === 0) { this.tone(27.5, 'sine', barDur, 0.06, 27.5, delay); } if (beat === 0) { for (const note of chord) { this.tone(note, 'sawtooth', barDur, 0.02 + intensity * 0.01, note, delay); } } const kickFreq = boss ? 44 : 55; const snareFreq = boss ? 700 : 1800; if (!fill && !breakdown) { if (beat % 4 === 2 && this._bassPattern[Math.floor(beat / 4)]) { this.tone(root, 'square', this._stepDur * 1.5, 0.055, root, delay); } if (intensity > 0.4 && beat % 2 === 0) { this.tone(root * 2, 'square', this._stepDur * 0.9, 0.02, root * 2, delay); } if (beat % 4 === 0) { this.tone(kickFreq, 'sine', 0.14, 0.1 * drumGain, Math.max(28, kickFreq - 18), delay); } if (beat === 4 || beat === 12) { this.noise(0.1, 0.07 * drumGain, snareFreq, delay); } if (beat % 4 === 2) { this.noise(0.03, 0.02 * drumGain, 9000, delay); } if (beat % 4 === 3 || beat % 4 === 1) { this.noise(0.02, 0.012 * drumGain, 10000, delay); } if (beat % 4 === 1 || beat % 4 === 3) { const arp = chord[this._arpPattern[beat]]; this.tone(arp, 'triangle', 0.1, 0.018, arp, delay, this.delaySend); this.tone(arp * 2, 'triangle', 0.12, 0.012, arp * 2, delay); } } else if (fill) { if (beat % 2 === 0) { this.tone(55, 'sine', 0.1, 0.06, 34, delay); } if (beat >= 12) { this.noise(0.06, 0.045, 1500, delay); } if (beat % 2 === 1) { this.noise(0.03, 0.018, 8500, delay); } if (beat % 3 === 0) { this.noise(0.05, 0.012, 400 + beat * 160, delay); } const index = beat >= 12 ? (beat - 12) % 3 : Math.floor(beat / 2) % 3; const note = chord[index] * (beat >= 12 ? 4 : 2); this.tone(note, 'triangle', 0.09, 0.02, note, delay); } else if (breakdown) { if (beat === 0 || beat === 8) { this.tone(55, 'sine', 0.18, 0.08, 32, delay); } } if (!fill && !breakdown) { const lead = (plan.leadVariant === 1 ? this._leadPatternB : this._leadPattern)[bar][beat]; if (lead > 0) { this.tone(lead, 'sawtooth', 0.22, 0.03, lead, delay, this.delaySend); } } } setMuted(muted) { this.muted = muted; if (this.master && this.context) { this.master.gain.setTargetAtTime(muted ? 0 : 0.5, this.context.currentTime, 0.01); } } toggleMuted() { this.setMuted(!this.muted); return this.muted; } tone(freq, type, duration, gain, glideTo = freq, delay = 0, bus = this.master) { if (!this.context || this.muted) return; if (!Number.isFinite(freq) || !Number.isFinite(glideTo)) return; const { context } = this; const start = context.currentTime + delay; const oscillator = context.createOscillator(); const shape = context.createGain(); oscillator.type = type; oscillator.frequency.setValueAtTime(Math.max(1, freq), start); oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), start + duration); shape.gain.setValueAtTime(0.0001, start); shape.gain.exponentialRampToValueAtTime(gain, start + 0.008); shape.gain.exponentialRampToValueAtTime(0.0001, start + duration); oscillator.connect(shape); shape.connect(bus || this.master); oscillator.start(start); oscillator.stop(start + duration + 0.02); } noise(duration, gain, filterFreq, delay = 0, bus = this.master) { if (!this.context || this.muted) return; const { context } = this; const start = context.currentTime + delay; const bufferSize = Math.max(1, Math.ceil(context.sampleRate * duration)); const buffer = context.createBuffer(1, bufferSize, context.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i += 1) { data[i] = Math.random() * 2 - 1; } const source = context.createBufferSource(); source.buffer = buffer; const filter = context.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = filterFreq; filter.Q.value = 1.2; const shape = context.createGain(); shape.gain.setValueAtTime(0.0001, start); shape.gain.exponentialRampToValueAtTime(gain, start + 0.005); shape.gain.exponentialRampToValueAtTime(0.0001, start + duration); source.connect(filter); filter.connect(shape); shape.connect(bus || this.master); source.start(start); source.stop(start + duration + 0.02); } shoot() { this.tone(240, 'square', 0.1, 0.075, 85); this.noise(0.05, 0.028, 3200); } hit() { this.noise(0.12, 0.16, 1500); this.tone(180, 'triangle', 0.1, 0.12, 120); } death(big = false) { if (big) { this.noise(0.35, 0.22, 700); this.tone(120, 'sawtooth', 0.3, 0.2, 45); this.tone(90, 'sine', 0.4, 0.24, 30); return; } this.noise(0.2, 0.14, 500); this.tone(140, 'triangle', 0.22, 0.16, 55); } damage() { this.tone(160, 'sawtooth', 0.2, 0.18, 80); this.noise(0.15, 0.1, 900); } explosion() { this.noise(0.7, 0.3, 400); this.tone(200, 'sawtooth', 0.6, 0.22, 30); this.tone(60, 'sine', 0.8, 0.3, 20); } pickup() { this.tone(660, 'sine', 0.12, 0.14, 660); this.tone(990, 'sine', 0.18, 0.14, 990, 0.09); } stingerStart() { this.tone(392, 'triangle', 0.12, 0.1); this.tone(523, 'triangle', 0.12, 0.1, undefined, 0.1); this.tone(659, 'triangle', 0.3, 0.12, undefined, 0.2); } bossEntrance() { this.tone(27.5, 'sine', 1.6, 0.22, 27.5); this.tone(110, 'sawtooth', 0.5, 0.1, 55); this.noise(0.5, 0.12, 250); this.tone(82.41, 'square', 0.25, 0.08, 82.41, 0.7); this.tone(61.74, 'square', 0.4, 0.08, 61.74, 0.95); } playGameOverMusic() { this.stopMusic(); this._playGameOverSeq(); } _playGameOverSeq() { if (!this.context) return; const now = this.context.currentTime; const t = (offset) => Math.max(0, offset - now); this.tone(55, 'sine', 8, 0.05, 51, t(0)); this.tone(55, 'sine', 8, 0.03, 55, t(4)); const chords = [ [110, 164.81, 220], [98, 146.83, 196], [87.31, 130.81, 174.61], [82.41, 123.47, 164.81], ]; chords.forEach((chord, i) => { const start = 0.2 + i * 1.5; for (const note of chord) { this.tone(note, 'sawtooth', 1.5, 0.018, note, t(start)); } if (i === 0 || i === 2) { this.tone(55, 'sine', 0.14, 0.09, 32, t(start)); } }); const melody = [880, 783.99, 698.46, 659.26, 587.33, 523.25, 493.88, 440]; melody.forEach((note, i) => { this.tone(note, 'triangle', 0.7, 0.032, note, t(0.5 + i * 0.55), this.delaySend); }); } }