/** * Synthesized Web Audio Sound Manager * Features: * 1. Retro 8-bit / MIDI procedural sound effects for sheep nibbles, wolf bites, mating chimes, and sprout bloops. * Tuned to be soft, gentle, and non-intrusive. * 2. Chill, slow, melodic loopable background music ("elevator music" / lo-fi bossa nova). * 3. 100% INDEPENDENT Sound FX and Music toggles — muting sound never affects music, and vice versa. */ export class SoundManager { constructor() { this.ctx = null; this.isMuted = false; // Controls Sound FX (chomps, bites, bloops) this.musicEnabled = true; // Controls Elevator Music BGM independently this.isMusicPlaying = false; // Volume levels (soft, balanced, comfortable listening) this.sfxVolume = 0.45; this.musicVolume = 0.15; this.sfxGain = null; this.musicGain = null; // Polyphony throttle to prevent distortion or rapid volume spikes during large flock grazing this.lastSheepChompTime = 0; this.lastWolfChompTime = 0; this.lastSproutTime = 0; // Music scheduler state this.musicTimer = null; this.currentStep = 0; this.nextNoteTime = 0; this.tempo = 66; // BPM (slow, relaxed elevator music tempo) this.secondsPerBeat = 60.0 / this.tempo; this.secondsPerStep = this.secondsPerBeat / 2; // Eighth note steps // Load saved preferences const savedMuted = localStorage.getItem('LifeSimulator_Muted'); if (savedMuted !== null) { this.isMuted = savedMuted === 'true'; } const savedMusic = localStorage.getItem('LifeSimulator_MusicEnabled'); if (savedMusic !== null) { this.musicEnabled = savedMusic === 'true'; } } ensureContext() { if (!this.ctx) { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (AudioCtx) { this.ctx = new AudioCtx(); // 1. Independent SFX bus -> connects directly to speakers this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.setValueAtTime(this.isMuted ? 0 : this.sfxVolume, this.ctx.currentTime); this.sfxGain.connect(this.ctx.destination); // 2. Independent Music bus -> connects directly to speakers (never affected by sfx mute) this.musicGain = this.ctx.createGain(); this.musicGain.gain.setValueAtTime(this.musicEnabled ? this.musicVolume : 0, this.ctx.currentTime); this.musicGain.connect(this.ctx.destination); } } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume().catch(() => {}); } } /** * Toggles Sound FX ON / OFF independently of Music */ toggleMute() { this.ensureContext(); this.isMuted = !this.isMuted; localStorage.setItem('LifeSimulator_Muted', this.isMuted.toString()); if (this.sfxGain && this.ctx) { this.sfxGain.gain.setTargetAtTime( this.isMuted ? 0 : this.sfxVolume, this.ctx.currentTime, 0.03 ); } return this.isMuted; } /** * Toggles Elevator Music ON / OFF independently of Sound FX */ toggleMusic() { this.ensureContext(); this.musicEnabled = !this.musicEnabled; localStorage.setItem('LifeSimulator_MusicEnabled', this.musicEnabled.toString()); if (this.musicGain && this.ctx) { this.musicGain.gain.setTargetAtTime( this.musicEnabled ? this.musicVolume : 0, this.ctx.currentTime, 0.04 ); } if (this.musicEnabled) { this.startMusic(); } else { this.stopMusic(); } return this.musicEnabled; } // ========================================================================= // 1. PROCEDURAL "ELEVATOR MUSIC" CHILL BGM (LO-FI BOSSA / MEADOW ELEVATOR) // ========================================================================= startMusic() { if (!this.musicEnabled) return; this.ensureContext(); if (!this.ctx || this.isMusicPlaying) return; this.isMusicPlaying = true; this.currentStep = 0; this.nextNoteTime = this.ctx.currentTime + 0.1; this._scheduleLoop(); } stopMusic() { this.isMusicPlaying = false; if (this.musicTimer) { clearTimeout(this.musicTimer); this.musicTimer = null; } } _scheduleLoop() { if (!this.isMusicPlaying || !this.ctx) return; // Lookahead scheduling (schedule up to 0.4s into future for gapless playback) const scheduleAheadTime = 0.4; while (this.nextNoteTime < this.ctx.currentTime + scheduleAheadTime) { this._playStep(this.currentStep, this.nextNoteTime); this.nextNoteTime += this.secondsPerStep; this.currentStep = (this.currentStep + 1) % 64; // 8 bars * 8 eighth notes = 64 steps } this.musicTimer = setTimeout(() => this._scheduleLoop(), 120); } _playStep(step, time) { if (!this.musicEnabled) return; const bar = Math.floor(step / 8); const stepInBar = step % 8; const chords = [ // 0: Cmaj7 (C3, G3, B3, E4) { root: 130.81, notes: [130.81, 196.00, 246.94, 329.63] }, // 1: Am7 (A2, E3, G3, C4) { root: 110.00, notes: [110.00, 164.81, 196.00, 261.63] }, // 2: Dm7 (D3, A3, C4, F4) { root: 146.83, notes: [146.83, 220.00, 261.63, 349.23] }, // 3: G7 (G2, D3, F3, B3) { root: 98.00, notes: [98.00, 146.83, 174.61, 246.94] }, // 4: Em7 (E3, B3, D4, G4) { root: 164.81, notes: [164.81, 246.94, 293.66, 392.00] }, // 5: A7 (A2, E3, G3, C#4) { root: 110.00, notes: [110.00, 164.81, 196.00, 277.18] }, // 6: Dm7 (D3, A3, C4, F4) { root: 146.83, notes: [146.83, 220.00, 261.63, 349.23] }, // 7: G7sus4 (G2, D3, F3, C4) -> G7 (B3) { root: 98.00, notes: [98.00, 146.83, 174.61, stepInBar < 4 ? 261.63 : 246.94] } ]; const currentChord = chords[bar]; // A. Warm Rhodes / Electric Piano Chords (Gentle strum on beats 0, 3, 5 for bossa elevator vibe) if (stepInBar === 0 || stepInBar === 3 || stepInBar === 5) { const chordVel = stepInBar === 0 ? 0.08 : 0.055; for (let n = 1; n < currentChord.notes.length; n++) { this._synthRhodesKey(time + (n - 1) * 0.012, currentChord.notes[n], 0.65, chordVel); } } // B. Soft Walking Bass (Downbeats 0 and 4) if (stepInBar === 0) { this._synthBassNote(time, currentChord.root, 0.45, 0.11); } else if (stepInBar === 4) { this._synthBassNote(time, currentChord.notes[1] * 0.5, 0.4, 0.08); } // C. Relaxing Melodic Motifs (Soulful, slow pentatonic lines floating above) const melodyMap = { 2: 392.00, // G4 4: 493.88, // B4 6: 523.25, // C5 8: 493.88, // B4 11: 440.00, // A4 13: 392.00, // G4 18: 349.23, // F4 20: 392.00, // G4 22: 440.00, // A4 26: 392.00, // G4 28: 293.66, // D4 34: 587.33, // D5 36: 523.25, // C5 38: 493.88, // B4 42: 440.00, // A4 44: 415.30, // G#4 46: 440.00, // A4 50: 392.00, // G4 52: 349.23, // F4 54: 329.63, // E4 58: 293.66, // D4 60: 246.94 // B3 }; if (melodyMap[step]) { this._synthMelodyNote(time, melodyMap[step], 0.5, 0.075); } } _synthRhodesKey(time, freq, duration, vel) { if (!this.ctx || !this.musicGain) return; 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(750, time); filter.frequency.exponentialRampToValueAtTime(320, time + duration); gain.gain.setValueAtTime(vel, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(filter); filter.connect(gain); gain.connect(this.musicGain); osc.start(time); osc.stop(time + duration + 0.02); } _synthBassNote(time, freq, duration, vel) { if (!this.ctx || !this.musicGain) return; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, time); filter.type = 'lowpass'; filter.frequency.setValueAtTime(240, time); gain.gain.setValueAtTime(vel, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(filter); filter.connect(gain); gain.connect(this.musicGain); osc.start(time); osc.stop(time + duration + 0.02); } _synthMelodyNote(time, freq, duration, vel) { if (!this.ctx || !this.musicGain) return; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, time); filter.type = 'lowpass'; filter.frequency.setValueAtTime(1200, time); gain.gain.setValueAtTime(vel, time); gain.gain.exponentialRampToValueAtTime(0.001, time + duration); osc.connect(filter); filter.connect(gain); gain.connect(this.musicGain); osc.start(time); osc.stop(time + duration + 0.02); } // ========================================================================= // 2. PROCEDURAL SOUND EFFECTS (SOFTER, GENTLE CHOMPS, BITES, BLOOPS) // ========================================================================= /** * Sheep Nibble / Chomp Sound * Soft, gentle, subtle retro pasture munch: low-gain dual micro-nibble. */ playSheepChomp() { if (this.isMuted) return; this.ensureContext(); if (!this.ctx || this.ctx.state !== 'running') return; const now = this.ctx.currentTime; if (now - this.lastSheepChompTime < 0.14) return; // Polyphony throttle this.lastSheepChompTime = now; // Pitch variation const pitchMod = 0.93 + Math.random() * 0.14; const baseFreq = 440 * pitchMod; // Soft nibble clicks (much lower, comfortable volume level) this._synthChompClick(now, baseFreq, 0.045, 0.065); this._synthChompClick(now + 0.04, baseFreq * 0.82, 0.05, 0.075); } _synthChompClick(startTime, freq, duration, gainLevel) { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, startTime); osc.frequency.exponentialRampToValueAtTime(freq * 0.5, startTime + duration); filter.type = 'bandpass'; filter.frequency.setValueAtTime(freq * 1.5, startTime); filter.Q.setValueAtTime(2.2, startTime); gain.gain.setValueAtTime(gainLevel, startTime); gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); osc.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); osc.start(startTime); osc.stop(startTime + duration + 0.01); } /** * Wolf Predatory Bite / Chomp Sound * Deep arcade crunch: comfortable, softened volume level. */ playWolfChomp() { if (this.isMuted) return; this.ensureContext(); if (!this.ctx || this.ctx.state !== 'running') return; const now = this.ctx.currentTime; if (now - this.lastWolfChompTime < 0.16) return; this.lastWolfChompTime = now; const pitchMod = 0.90 + Math.random() * 0.20; const startTime = now; const duration = 0.11; // Meat bite body const osc = this.ctx.createOscillator(); const oscGain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(230 * pitchMod, startTime); osc.frequency.exponentialRampToValueAtTime(60 * pitchMod, startTime + duration); // Softened body volume oscGain.gain.setValueAtTime(0.12, startTime); oscGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(650, startTime); filter.frequency.exponentialRampToValueAtTime(160, startTime + duration); osc.connect(filter); filter.connect(oscGain); oscGain.connect(this.sfxGain); osc.start(startTime); osc.stop(startTime + duration + 0.01); // Soft teeth crunch snap this._synthNoiseSnap(startTime, 0.04, 0.065); } _synthNoiseSnap(startTime, duration, volume) { const bufferSize = Math.floor(this.ctx.sampleRate * duration); const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const output = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.35)); } const whiteNoise = this.ctx.createBufferSource(); whiteNoise.buffer = buffer; const filter = this.ctx.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.setValueAtTime(1000, startTime); const gain = this.ctx.createGain(); gain.gain.setValueAtTime(volume, startTime); gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); whiteNoise.connect(filter); filter.connect(gain); gain.connect(this.sfxGain); whiteNoise.start(startTime); whiteNoise.stop(startTime + duration + 0.01); } /** * Mating Heart Chime */ playHeartChime() { if (this.isMuted) return; this.ensureContext(); if (!this.ctx || this.ctx.state !== 'running') return; const now = this.ctx.currentTime; const playNote = (time, freq) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, time); gain.gain.setValueAtTime(0.08, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.14); osc.connect(gain); gain.connect(this.sfxGain); osc.start(time); osc.stop(time + 0.15); }; playNote(now, 523.25); // C5 playNote(now + 0.07, 783.99); // G5 } /** * Rain Cloud Plant Sprout Bloop */ playSproutChime() { if (this.isMuted) return; this.ensureContext(); if (!this.ctx || this.ctx.state !== 'running') return; const now = this.ctx.currentTime; if (now - this.lastSproutTime < 0.25) return; this.lastSproutTime = now; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(320, now); osc.frequency.exponentialRampToValueAtTime(640, now + 0.1); gain.gain.setValueAtTime(0.07, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12); osc.connect(gain); gain.connect(this.sfxGain); osc.start(now); osc.stop(now + 0.13); } }