/** * PPE SAFETY CHALLENGE - WEB AUDIO SYNTHESIZER & SOUND ENGINE * Professional corporate-industrial sound design, procedural synthesis, and adaptive ducking. * Fully self-contained with no external media dependencies. */ class SoundEngine { constructor() { this.ctx = null; this.masterGain = null; this.musicGain = null; this.sfxGain = null; this.ambientGain = null; this.isMuted = false; this.musicVolume = 0.25; // 25% default as requested this.sfxVolume = 0.55; // 55% default as requested this.isAmbientEnabled = true; this.isMusicPlaying = false; this.musicInterval = null; this.currentMusicChord = 0; this.activeAmbientNodes = []; this.activeGrinderNode = null; this.activeLiquidNode = null; this.isInitialized = false; } /** * Initializes the Web Audio context on user gesture. */ init() { if (this.isInitialized) return; try { const AudioContext = window.AudioContext || window.webkitAudioContext; this.ctx = new AudioContext(); // Master Gain this.masterGain = this.ctx.createGain(); this.masterGain.gain.setValueAtTime(1.0, this.ctx.currentTime); this.masterGain.connect(this.ctx.destination); // Music Channel this.musicGain = this.ctx.createGain(); this.musicGain.gain.setValueAtTime(this.musicVolume, this.ctx.currentTime); this.musicGain.connect(this.masterGain); // SFX Channel this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.setValueAtTime(this.sfxVolume, this.ctx.currentTime); this.sfxGain.connect(this.masterGain); // Ambient Channel this.ambientGain = this.ctx.createGain(); this.ambientGain.gain.setValueAtTime(0.18, this.ctx.currentTime); this.ambientGain.connect(this.masterGain); this.isInitialized = true; } catch (e) { console.warn("Web Audio API not supported or blocked:", e); } } ensureContext() { if (!this.isInitialized) { this.init(); } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } } // ========================================== // VOLUME & MUTE CONTROLS // ========================================== get isMasterMuted() { return this.isMuted; } toggleMasterMute() { this.setMasterMute(!this.isMuted); return this.isMuted; } setMasterMute(muted) { this.isMuted = !!muted; if (this.masterGain && this.ctx) { this.masterGain.gain.setTargetAtTime(this.isMuted ? 0 : 1.0, this.ctx.currentTime, 0.05); } } setMusicVolume(val) { this.musicVolume = Math.max(0, Math.min(1, val)); if (this.musicGain && this.ctx) { this.musicGain.gain.setTargetAtTime(this.musicVolume, this.ctx.currentTime, 0.05); } } setSfxVolume(val) { this.sfxVolume = Math.max(0, Math.min(1, val)); if (this.sfxGain && this.ctx) { this.sfxGain.gain.setTargetAtTime(this.sfxVolume, this.ctx.currentTime, 0.05); } } setAmbientEnabled(enabled) { this.isAmbientEnabled = enabled; if (this.ambientGain && this.ctx) { this.ambientGain.gain.setTargetAtTime(enabled ? 0.18 : 0, this.ctx.currentTime, 0.1); } } /** * Duck music volume during critical warnings / consequences */ duckMusic(ducked = true) { if (!this.musicGain || !this.ctx) return; const target = ducked ? (this.musicVolume * 0.15) : this.musicVolume; this.musicGain.gain.setTargetAtTime(target, this.ctx.currentTime, 0.1); } // ========================================== // BACKGROUND MUSIC (Corporate-Industrial Synth Loop) // ========================================== playStartIntroSting() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Confident corporate chords: F -> C -> Dm -> Bb const chords = [ [349.23, 440.00, 523.25], // F major [261.63, 329.63, 392.00], // C major [293.66, 349.23, 440.00], // D minor [466.16, 587.33, 698.46] // Bb major (high shimmer) ]; chords.forEach((chord, i) => { const time = t + (i * 0.28); chord.forEach(freq => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, time); filter.type = 'lowpass'; filter.frequency.setValueAtTime(2200, time); gain.gain.setValueAtTime(0.001, time); gain.gain.exponentialRampToValueAtTime(0.12, time + 0.05); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.6); osc.connect(filter); filter.connect(gain); gain.connect(this.musicGain); osc.start(time); osc.stop(time + 0.65); }); }); } startBackgroundMusic() { this.ensureContext(); if (this.isMusicPlaying || !this.ctx) return; this.isMusicPlaying = true; // Corporate modern progression: Dm -> Bb -> F -> C const progression = [ { bass: 146.83, chord: [293.66, 349.23, 440.00] }, // Dm { bass: 116.54, chord: [233.08, 293.66, 349.23] }, // Bb { bass: 174.61, chord: [261.63, 349.23, 440.00] }, // F { bass: 130.81, chord: [261.63, 329.63, 392.00] } // C ]; let step = 0; const playStep = () => { if (!this.isMusicPlaying || !this.ctx || this.isMuted) return; const current = progression[step % progression.length]; const now = this.ctx.currentTime; // 1. Warm Bass Pulse const bassOsc = this.ctx.createOscillator(); const bassGain = this.ctx.createGain(); const bassFilter = this.ctx.createBiquadFilter(); bassOsc.type = 'sawtooth'; bassOsc.frequency.setValueAtTime(current.bass, now); bassFilter.type = 'lowpass'; bassFilter.frequency.setValueAtTime(350, now); bassGain.gain.setValueAtTime(0.001, now); bassGain.gain.linearRampToValueAtTime(0.08, now + 0.08); bassGain.gain.exponentialRampToValueAtTime(0.001, now + 1.8); bassOsc.connect(bassFilter); bassFilter.connect(bassGain); bassGain.connect(this.musicGain); bassOsc.start(now); bassOsc.stop(now + 1.9); // 2. Soft Pad Chords (Calm Corporate Ambiance) current.chord.forEach((freq, idx) => { const padOsc = this.ctx.createOscillator(); const padGain = this.ctx.createGain(); const padFilter = this.ctx.createBiquadFilter(); padOsc.type = 'sine'; padOsc.frequency.setValueAtTime(freq, now); padFilter.type = 'lowpass'; padFilter.frequency.setValueAtTime(800 + (idx * 200), now); padGain.gain.setValueAtTime(0.001, now); padGain.gain.linearRampToValueAtTime(0.04, now + 0.3); padGain.gain.exponentialRampToValueAtTime(0.001, now + 1.9); padOsc.connect(padFilter); padFilter.connect(padGain); padGain.connect(this.musicGain); padOsc.start(now); padOsc.stop(now + 2.0); }); // 3. Subtle Energetic Arpeggio Tick [0, 0.45, 0.9, 1.35].forEach((offset, i) => { const arpTime = now + offset; const arpOsc = this.ctx.createOscillator(); const arpGain = this.ctx.createGain(); arpOsc.type = 'sine'; const arpFreq = current.chord[i % current.chord.length] * 2; arpOsc.frequency.setValueAtTime(arpFreq, arpTime); arpGain.gain.setValueAtTime(0.001, arpTime); arpGain.gain.linearRampToValueAtTime(0.015, arpTime + 0.02); arpGain.gain.exponentialRampToValueAtTime(0.0001, arpTime + 0.2); arpOsc.connect(arpGain); arpGain.connect(this.musicGain); arpOsc.start(arpTime); arpOsc.stop(arpTime + 0.22); }); step++; }; // Play step 0 immediately with zero delay playStep(); this.musicInterval = setInterval(playStep, 1800); } enableAudioOnFirstGesture() { const unlockAndPlay = () => { this.ensureContext(); if (!this.isMusicPlaying) { this.startBackgroundMusic(); } ['click', 'keydown', 'pointerdown', 'touchstart'].forEach(evt => { window.removeEventListener(evt, unlockAndPlay); }); }; ['click', 'keydown', 'pointerdown', 'touchstart'].forEach(evt => { window.addEventListener(evt, unlockAndPlay, { once: false, passive: true }); }); } stopBackgroundMusic() { this.isMusicPlaying = false; if (this.musicInterval) { clearInterval(this.musicInterval); this.musicInterval = null; } } // ========================================== // AMBIENT SOUNDS // ========================================== setAmbientScenario(scenarioId) { this.ensureContext(); this.stopAmbient(); this.stopAllTaskSFX(); if (!this.ctx || !this.isAmbientEnabled) return; if (scenarioId === 'grinding') { // Workshop machinery hum & distant resonance const osc1 = this.ctx.createOscillator(); const osc2 = this.ctx.createOscillator(); const filter = this.ctx.createBiquadFilter(); const gain = this.ctx.createGain(); osc1.type = 'sine'; osc1.frequency.setValueAtTime(65, this.ctx.currentTime); // 60Hz grid hum osc2.type = 'triangle'; osc2.frequency.setValueAtTime(120, this.ctx.currentTime); filter.type = 'lowpass'; filter.frequency.setValueAtTime(250, this.ctx.currentTime); gain.gain.setValueAtTime(0.06, this.ctx.currentTime); osc1.connect(filter); osc2.connect(filter); filter.connect(gain); gain.connect(this.ambientGain); osc1.start(); osc2.start(); this.activeAmbientNodes = [osc1, osc2, gain]; } else if (scenarioId === 'chemical') { // Chemical fume hood ventilation air flow (filtered white noise) const bufferSize = this.ctx.sampleRate * 2; const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1) * 0.15; } const noiseSource = this.ctx.createBufferSource(); noiseSource.buffer = buffer; noiseSource.loop = true; const bandpass = this.ctx.createBiquadFilter(); bandpass.type = 'bandpass'; bandpass.frequency.setValueAtTime(450, this.ctx.currentTime); bandpass.Q.setValueAtTime(1.5, this.ctx.currentTime); const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0.08, this.ctx.currentTime); noiseSource.connect(bandpass); bandpass.connect(gain); gain.connect(this.ambientGain); noiseSource.start(); this.activeAmbientNodes = [noiseSource, gain]; } } stopAmbient() { this.activeAmbientNodes.forEach(node => { try { if (node.stop) node.stop(); if (node.disconnect) node.disconnect(); } catch (e) {} }); this.activeAmbientNodes = []; } // ========================================== // UI INTERACTION SOUND EFFECTS // ========================================== playEquipSFX() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Crisp mechanical gear latch sound const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'square'; osc.frequency.setValueAtTime(320, t); osc.frequency.exponentialRampToValueAtTime(120, t + 0.06); filter.type = 'lowpass'; filter.frequency.setValueAtTime(1200, t); gain.gain.setValueAtTime(0.12, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.07); osc.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); osc.start(t); osc.stop(t + 0.08); } playDeselectSFX() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(180, t); osc.frequency.exponentialRampToValueAtTime(260, t + 0.05); gain.gain.setValueAtTime(0.08, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06); osc.connect(gain); gain.connect(this.sfxGain); osc.start(t); osc.stop(t + 0.07); } playButtonClick() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(600, t); osc.frequency.exponentialRampToValueAtTime(200, t + 0.03); gain.gain.setValueAtTime(0.1, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04); osc.connect(gain); gain.connect(this.sfxGain); osc.start(t); osc.stop(t + 0.05); } playSuccessChime() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Harmonic positive triad: C5 (523.25) -> E5 (659.25) -> G5 (783.99) -> C6 (1046.50) const notes = [523.25, 659.25, 783.99, 1046.50]; notes.forEach((freq, i) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, t + (i * 0.09)); gain.gain.setValueAtTime(0.001, t + (i * 0.09)); gain.gain.linearRampToValueAtTime(0.16, t + (i * 0.09) + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + (i * 0.09) + 0.5); osc.connect(gain); gain.connect(this.sfxGain); osc.start(t + (i * 0.09)); osc.stop(t + (i * 0.09) + 0.55); }); } playSafetyStarSound() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Shimmer sparkle sweep for (let i = 0; i < 5; i++) { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const time = t + (i * 0.06); osc.type = 'triangle'; osc.frequency.setValueAtTime(900 + (i * 240), time); gain.gain.setValueAtTime(0.09, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.25); osc.connect(gain); gain.connect(this.sfxGain); osc.start(time); osc.stop(time + 0.26); } } playWarningTone() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Measured industrial safety double-beep (calibrated, not jarring) [0, 0.16].forEach(offset => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(480, t + offset); gain.gain.setValueAtTime(0.14, t + offset); gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.1); const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(1000, t + offset); osc.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); osc.start(t + offset); osc.stop(t + offset + 0.12); }); } playCriticalStopAlert() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Firm low "STOP" tone pulse const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(220, t); osc.frequency.linearRampToValueAtTime(180, t + 0.25); gain.gain.setValueAtTime(0.2, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); osc.connect(gain); gain.connect(this.sfxGain); osc.start(t); osc.stop(t + 0.36); } playWhooshImpact() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Quick swoosh particle sound for near-miss freeze frame const bufferSize = this.ctx.sampleRate * 0.3; const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1); } const source = this.ctx.createBufferSource(); source.buffer = buffer; const filter = this.ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(600, t); filter.frequency.exponentialRampToValueAtTime(2200, t + 0.18); filter.Q.setValueAtTime(3.0, t); const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0.01, t); gain.gain.linearRampToValueAtTime(0.18, t + 0.15); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); source.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); source.start(t); source.stop(t + 0.3); } // ========================================== // TASK SPECIFIC AUDIO (Grinder & Chemical) // ========================================== startGrinderSFX() { this.ensureContext(); this.stopGrinderSFX(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Grinder motor spin-up (11000 RPM whine) + spark friction sizzle const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(160, t); osc.frequency.exponentialRampToValueAtTime(420, t + 0.5); filter.type = 'bandpass'; filter.frequency.setValueAtTime(1400, t); filter.Q.setValueAtTime(2.0, t); gain.gain.setValueAtTime(0.01, t); gain.gain.linearRampToValueAtTime(0.15, t + 0.3); // Spark friction noise const bufferSize = this.ctx.sampleRate * 2; const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1) * 0.3; } const noise = this.ctx.createBufferSource(); noise.buffer = buffer; noise.loop = true; const noiseFilter = this.ctx.createBiquadFilter(); noiseFilter.type = 'highpass'; noiseFilter.frequency.setValueAtTime(3000, t); const noiseGain = this.ctx.createGain(); noiseGain.gain.setValueAtTime(0.01, t); noiseGain.gain.linearRampToValueAtTime(0.08, t + 0.3); noise.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(this.sfxGain); osc.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); osc.start(t); noise.start(t); this.activeGrinderNode = { osc, gain, noise, noiseGain }; } stopGrinderSFX() { if (this.activeGrinderNode && this.ctx) { const node = this.activeGrinderNode; this.activeGrinderNode = null; try { const t = this.ctx.currentTime; if (node.gain && node.gain.gain) { node.gain.gain.cancelScheduledValues(t); node.gain.gain.setValueAtTime(node.gain.gain.value, t); node.gain.gain.linearRampToValueAtTime(0.0001, t + 0.05); } if (node.noiseGain && node.noiseGain.gain) { node.noiseGain.gain.cancelScheduledValues(t); node.noiseGain.gain.setValueAtTime(node.noiseGain.gain.value, t); node.noiseGain.gain.linearRampToValueAtTime(0.0001, t + 0.05); } setTimeout(() => { try { if (node.osc) { node.osc.stop(); node.osc.disconnect(); } if (node.noise) { node.noise.stop(); node.noise.disconnect(); } } catch (e) {} }, 60); } catch (e) { try { if (node.osc) { node.osc.stop(); node.osc.disconnect(); } if (node.noise) { node.noise.stop(); node.noise.disconnect(); } } catch (err) {} } } } startLiquidTransferSFX() { this.ensureContext(); this.stopLiquidTransferSFX(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; // Gentle liquid pour / laminar stream sound const bufferSize = this.ctx.sampleRate * 2; const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1) * 0.2; } const noise = this.ctx.createBufferSource(); noise.buffer = buffer; noise.loop = true; const filter = this.ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(850, t); filter.Q.setValueAtTime(3.5, t); const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0.01, t); gain.gain.linearRampToValueAtTime(0.12, t + 0.2); noise.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); noise.start(t); this.activeLiquidNode = { noise, gain }; } stopLiquidTransferSFX() { if (this.activeLiquidNode && this.ctx) { const node = this.activeLiquidNode; this.activeLiquidNode = null; try { const t = this.ctx.currentTime; if (node.gain && node.gain.gain) { node.gain.gain.cancelScheduledValues(t); node.gain.gain.setValueAtTime(node.gain.gain.value, t); node.gain.gain.linearRampToValueAtTime(0.0001, t + 0.05); } setTimeout(() => { try { if (node.noise) { node.noise.stop(); node.noise.disconnect(); } } catch (e) {} }, 60); } catch (e) { try { if (node.noise) { node.noise.stop(); node.noise.disconnect(); } } catch (err) {} } } } stopAllTaskSFX() { this.stopGrinderSFX(); this.stopLiquidTransferSFX(); } playChemicalSplashSFX() { this.ensureContext(); if (!this.ctx || this.isMuted) return; const t = this.ctx.currentTime; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(350, t); osc.frequency.exponentialRampToValueAtTime(140, t + 0.12); gain.gain.setValueAtTime(0.18, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15); osc.connect(gain); gain.connect(this.sfxGain); osc.start(t); osc.stop(t + 0.16); } playVictorySting() { this.ensureContext(); if (!this.ctx || this.isMuted) return; this.stopBackgroundMusic(); const t = this.ctx.currentTime; // Professional triumphant fanfare: C4 -> E4 -> G4 -> C5 (sustained) const melody = [ { freq: 523.25, duration: 0.18, delay: 0 }, { freq: 659.25, duration: 0.18, delay: 0.18 }, { freq: 783.99, duration: 0.22, delay: 0.36 }, { freq: 1046.50, duration: 1.2, delay: 0.58 } ]; melody.forEach(note => { const time = t + note.delay; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(note.freq, time); filter.type = 'lowpass'; filter.frequency.setValueAtTime(2400, time); gain.gain.setValueAtTime(0.001, time); gain.gain.linearRampToValueAtTime(0.18, time + 0.04); gain.gain.exponentialRampToValueAtTime(0.001, time + note.duration); osc.connect(filter); filter.connect(gain); gain.connect(this.musicGain); osc.start(time); osc.stop(time + note.duration + 0.05); }); } } // Global Sound Engine Instance window.soundEngine = new SoundEngine();