Files
game_development_workshop/js/audio.js
T

1067 lines
30 KiB
JavaScript

/**
* 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;
// Dedicated Countdown Tick Sound Clip (User uploaded custom audio)
try {
this.countdownTickAudio = new Audio('audio/countdown_tick.mp3');
this.countdownTickAudio.preload = 'auto';
} catch (e) {
this.countdownTickAudio = null;
}
// Dedicated Top Timer Last 10-Second Warning Sound Clip (User uploaded custom audio)
try {
this.timerLast10sAudio = new Audio('audio/timer_last_10s.wav');
this.timerLast10sAudio.preload = 'auto';
} catch (e) {
this.timerLast10sAudio = 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);
}
if (this.countdownTickAudio) {
this.countdownTickAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
if (this.timerLast10sAudio) {
this.timerLast10sAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
}
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);
}
if (this.countdownTickAudio) {
this.countdownTickAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
if (this.timerLast10sAudio) {
this.timerLast10sAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
}
playCountdownTickAudio() {
this.ensureContext();
if (this.isMuted) return;
if (this.countdownTickAudio) {
try {
const sound = this.countdownTickAudio.cloneNode();
sound.volume = Math.max(0, Math.min(1, this.sfxVolume));
sound.play().catch(err => {
console.warn("Countdown tick audio playback note:", err);
});
} catch (e) {
try {
this.countdownTickAudio.currentTime = 0;
this.countdownTickAudio.volume = Math.max(0, Math.min(1, this.sfxVolume));
this.countdownTickAudio.play().catch(() => {});
} catch (err) {}
}
}
}
/**
* Normal Clock Tick for the first 20 seconds (30s down to 11s)
*/
playTimerTick() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
// Crisp high-tech clock tick matching 1st beat
osc.type = 'triangle';
osc.frequency.setValueAtTime(1100, t);
osc.frequency.exponentialRampToValueAtTime(320, t + 0.035);
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1200, t);
filter.Q.setValueAtTime(3.5, t);
gain.gain.setValueAtTime(0.22, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.05);
}
/**
* Dedicated Last 10 Seconds Audio Track (User uploaded custom audio)
*/
playTimerLast10sAudio() {
this.ensureContext();
if (this.isMuted) return;
if (this.timerLast10sAudio) {
try {
this.timerLast10sAudio.currentTime = 0;
this.timerLast10sAudio.volume = Math.max(0, Math.min(1, this.sfxVolume));
this.timerLast10sAudio.play().catch(err => {
console.warn("Timer last 10s audio playback note:", err);
});
} catch (e) {
console.warn("Error playing timer last 10s audio:", e);
}
}
}
stopTimerLast10sAudio() {
if (this.timerLast10sAudio) {
try {
this.timerLast10sAudio.pause();
this.timerLast10sAudio.currentTime = 0;
} catch (e) {}
}
}
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);
});
}
/**
* Timer Warning Tick & Urgent Countdown Beep SFX
*/
playTimerWarningTick(isUrgent = false) {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
if (isUrgent) {
// Rapid, tense dual-tone hazard emergency beep
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(880, t);
osc.frequency.setValueAtTime(700, t + 0.06);
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400, t);
filter.Q.setValueAtTime(4.0, t);
gain.gain.setValueAtTime(0.22, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.15);
} else {
// High-tech warning clock tick
osc.type = 'sine';
osc.frequency.setValueAtTime(560, t);
osc.frequency.exponentialRampToValueAtTime(420, t + 0.05);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
osc.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.09);
}
}
/**
* Industrial Emergency Hazard Alarm Sound (Time Expired / Hazard Warning)
*/
playTimerHazardAlarm() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
// Industrial emergency hazard klaxon / buzzer burst
[0, 0.18, 0.36].forEach(offset => {
const time = t + offset;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(620, time);
osc.frequency.linearRampToValueAtTime(380, time + 0.14);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, time);
gain.gain.setValueAtTime(0.26, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(time);
osc.stop(time + 0.16);
});
}
/**
* Central Screen Start Countdown Beep (3, 2, 1)
*/
playCountdownBeep(num) {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
// Pitch rises: 3 (520Hz) -> 2 (680Hz) -> 1 (880Hz)
const baseFreq = num === 3 ? 520 : (num === 2 ? 680 : 880);
osc.type = 'triangle';
osc.frequency.setValueAtTime(baseFreq, t);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(baseFreq * 2, t);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2400, t);
gain.gain.setValueAtTime(0.24, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
osc.connect(filter);
osc2.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc2.start(t);
osc.stop(t + 0.3);
osc2.stop(t + 0.3);
}
/**
* Central Screen Countdown Launch / GO! Chime
*/
playCountdownGo() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
// Energetic ascending triad (C5 -> E5 -> G5)
[523.25, 659.25, 783.99, 1046.50].forEach((freq, idx) => {
const time = t + (idx * 0.05);
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, time);
gain.gain.setValueAtTime(0.22, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.45);
osc.connect(gain);
gain.connect(this.sfxGain);
osc.start(time);
osc.stop(time + 0.5);
});
}
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();