808 lines
23 KiB
JavaScript
808 lines
23 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;
|
|
|
|
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();
|