470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}
|
||
|
|
}
|