// ============================================================ // THE BEACON - Sound Engine // Procedural audio via Web Audio API (no audio files) // ============================================================ const Sound = (function () { let ctx = null; let masterGain = null; let ambientGain = null; let ambientOsc1 = null; let ambientOsc2 = null; let ambientOsc3 = null; let ambientLfo = null; let ambientStarted = false; let currentPhase = 0; function ensureCtx() { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); masterGain = ctx.createGain(); masterGain.gain.value = 0.3; masterGain.connect(ctx.destination); } if (ctx.state === 'suspended') { ctx.resume(); } return ctx; } function isMuted() { return typeof _muted !== 'undefined' && _muted; } // --- Noise buffer helper --- function createNoiseBuffer(duration) { const c = ensureCtx(); const len = c.sampleRate * duration; const buf = c.createBuffer(1, len, c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < len; i++) { data[i] = Math.random() * 2 - 1; } return buf; } // --- playClick: mechanical keyboard sound --- function playClick() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; // Short noise burst (mechanical key) const noise = c.createBufferSource(); noise.buffer = createNoiseBuffer(0.03); const noiseGain = c.createGain(); noiseGain.gain.setValueAtTime(0.4, now); noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03); const hpFilter = c.createBiquadFilter(); hpFilter.type = 'highpass'; hpFilter.frequency.value = 3000; noise.connect(hpFilter); hpFilter.connect(noiseGain); noiseGain.connect(masterGain); noise.start(now); noise.stop(now + 0.03); // Click tone const osc = c.createOscillator(); osc.type = 'square'; osc.frequency.setValueAtTime(1800, now); osc.frequency.exponentialRampToValueAtTime(600, now + 0.02); const oscGain = c.createGain(); oscGain.gain.setValueAtTime(0.15, now); oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025); osc.connect(oscGain); oscGain.connect(masterGain); osc.start(now); osc.stop(now + 0.03); } // --- playBuild: purchase thud + chime --- function playBuild() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; // Low thud const thud = c.createOscillator(); thud.type = 'sine'; thud.frequency.setValueAtTime(150, now); thud.frequency.exponentialRampToValueAtTime(60, now + 0.12); const thudGain = c.createGain(); thudGain.gain.setValueAtTime(0.35, now); thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); thud.connect(thudGain); thudGain.connect(masterGain); thud.start(now); thud.stop(now + 0.15); // Bright chime on top const chime = c.createOscillator(); chime.type = 'sine'; chime.frequency.setValueAtTime(880, now + 0.05); chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2); const chimeGain = c.createGain(); chimeGain.gain.setValueAtTime(0, now); chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06); chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); chime.connect(chimeGain); chimeGain.connect(masterGain); chime.start(now + 0.05); chime.stop(now + 0.25); } // --- playProject: ascending chime --- function playProject() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; const notes = [523, 659, 784]; // C5, E5, G5 notes.forEach((freq, i) => { const osc = c.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq; const gain = c.createGain(); const t = now + i * 0.1; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.22, t + 0.03); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); osc.connect(gain); gain.connect(masterGain); osc.start(t); osc.stop(t + 0.35); }); } // --- playMilestone: bright arpeggio --- function playMilestone() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6 notes.forEach((freq, i) => { const osc = c.createOscillator(); osc.type = 'triangle'; osc.frequency.value = freq; const gain = c.createGain(); const t = now + i * 0.08; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.25, t + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4); osc.connect(gain); gain.connect(masterGain); osc.start(t); osc.stop(t + 0.4); }); } // --- playFanfare: 8-note scale for phase transitions --- function playFanfare() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5 scale.forEach((freq, i) => { const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; const filter = c.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 2000; const gain = c.createGain(); const t = now + i * 0.1; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.15, t + 0.03); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3); osc.connect(filter); filter.connect(gain); gain.connect(masterGain); osc.start(t); osc.stop(t + 0.3); }); // Final chord const chordNotes = [523, 659, 784]; chordNotes.forEach((freq) => { const osc = c.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq; const gain = c.createGain(); const t = now + 0.8; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.2, t + 0.05); gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2); osc.connect(gain); gain.connect(masterGain); osc.start(t); osc.stop(t + 1.2); }); } // --- playDriftEnding: descending dissonance --- function playDriftEnding() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned notes.forEach((freq, i) => { const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; // Slight detune for dissonance const osc2 = c.createOscillator(); osc2.type = 'sawtooth'; osc2.frequency.value = freq * 1.02; const filter = c.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(1500, now + i * 0.2); filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5); const gain = c.createGain(); const t = now + i * 0.2; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.1, t + 0.05); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8); osc.connect(filter); osc2.connect(filter); filter.connect(gain); gain.connect(masterGain); osc.start(t); osc.stop(t + 0.8); osc2.start(t); osc2.stop(t + 0.8); }); } // --- playBeaconEnding: warm chord --- function playBeaconEnding() { if (isMuted()) return; const c = ensureCtx(); const now = c.currentTime; // Warm major chord: C3, E3, G3, C4, E4 const chord = [131, 165, 196, 262, 330]; chord.forEach((freq, i) => { const osc = c.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq; // Add subtle harmonics const osc2 = c.createOscillator(); osc2.type = 'sine'; osc2.frequency.value = freq * 2; const gain = c.createGain(); const gain2 = c.createGain(); const t = now + i * 0.15; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.15, t + 0.3); gain.gain.setValueAtTime(0.15, t + 2); gain.gain.exponentialRampToValueAtTime(0.001, t + 4); gain2.gain.setValueAtTime(0, t); gain2.gain.linearRampToValueAtTime(0.05, t + 0.3); gain2.gain.setValueAtTime(0.05, t + 2); gain2.gain.exponentialRampToValueAtTime(0.001, t + 4); osc.connect(gain); osc2.connect(gain2); gain.connect(masterGain); gain2.connect(masterGain); osc.start(t); osc.stop(t + 4); osc2.start(t); osc2.stop(t + 4); }); } // --- Ambient drone system --- function startAmbient() { if (ambientStarted) return; if (isMuted()) return; const c = ensureCtx(); ambientStarted = true; ambientGain = c.createGain(); ambientGain.gain.value = 0; ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3); ambientGain.connect(masterGain); // Base drone ambientOsc1 = c.createOscillator(); ambientOsc1.type = 'sine'; ambientOsc1.frequency.value = 55; // A1 ambientOsc1.connect(ambientGain); ambientOsc1.start(); // Second voice (fifth above) ambientOsc2 = c.createOscillator(); ambientOsc2.type = 'sine'; ambientOsc2.frequency.value = 82.4; // E2 const g2 = c.createGain(); g2.gain.value = 0.5; ambientOsc2.connect(g2); g2.connect(ambientGain); ambientOsc2.start(); // Third voice (high shimmer) ambientOsc3 = c.createOscillator(); ambientOsc3.type = 'triangle'; ambientOsc3.frequency.value = 220; // A3 const g3 = c.createGain(); g3.gain.value = 0.15; ambientOsc3.connect(g3); g3.connect(ambientGain); ambientOsc3.start(); // LFO for subtle movement ambientLfo = c.createOscillator(); ambientLfo.type = 'sine'; ambientLfo.frequency.value = 0.2; const lfoGain = c.createGain(); lfoGain.gain.value = 3; ambientLfo.connect(lfoGain); lfoGain.connect(ambientOsc1.frequency); ambientLfo.start(); } function updateAmbientPhase(phase) { if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return; if (phase === currentPhase) return; currentPhase = phase; const c = ensureCtx(); const now = c.currentTime; const rampTime = 2; // Phase determines the drone's character const phases = { 1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 }, 2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 }, 3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 }, 4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 }, 5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 }, 6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 } }; const p = phases[phase] || phases[1]; ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime); ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime); ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime); } // --- Mute integration --- function onMuteChanged(muted) { if (ambientGain) { ambientGain.gain.linearRampToValueAtTime( muted ? 0 : 0.06, (ctx ? ctx.currentTime : 0) + 0.3 ); } } // Public API return { playClick, playBuild, playProject, playMilestone, playFanfare, playDriftEnding, playBeaconEnding, startAmbient, updateAmbientPhase, onMuteChanged }; })();