LifeSimulator/js/audio.js

469 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);
}
}