/** * City10 audio engine (Web Audio API, fully procedural — no external assets). * * Provides background music, ambient weather layers and gameplay SFX with * independent master / music / SFX volume controls persisted in localStorage. * The context unlocks on the first user gesture (browser autoplay policy). */ class CityAudioEngine { constructor() { this.ctx = null; this.master = null; this.musicBus = null; this.sfxBus = null; this.musicNodes = []; this.rainNodes = null; this.started = false; this.muted = false; // Default levels are intentionally audible on laptop speakers. this.volumes = { master: 0.85, music: 0.7, sfx: 1.0 }; this._footstepTimer = 0; this._lastThunder = 0; this._loadPrefs(); } _loadPrefs() { try { const raw = localStorage.getItem('city10.audio'); if (!raw) return; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return; // v1 stored overly quiet defaults; migrate once to audiable levels. if (!parsed.version || parsed.version < 2) { this.volumes = { master: 0.85, music: 0.7, sfx: 1.0 }; this.muted = !!parsed.muted; this._savePrefs(); return; } if (typeof parsed.master === 'number') this.volumes.master = clamp01(parsed.master); if (typeof parsed.music === 'number') this.volumes.music = clamp01(parsed.music); if (typeof parsed.sfx === 'number') this.volumes.sfx = clamp01(parsed.sfx); this.muted = !!parsed.muted; } catch (_) { /* ignore corrupt prefs */ } } _savePrefs() { try { localStorage.setItem('city10.audio', JSON.stringify({ ...this.volumes, muted: this.muted, version: 2, })); } catch (_) { /* private mode etc. */ } } /** Create/resume the AudioContext. Safe to call often. */ unlock() { if (!this.ctx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false; this.ctx = new AC(); this.master = this.ctx.createGain(); this.master.connect(this.ctx.destination); this.musicBus = this.ctx.createGain(); this.musicBus.connect(this.master); this.sfxBus = this.ctx.createGain(); this.sfxBus.connect(this.master); this._applyVolumes(); } if (this.ctx.state === 'suspended') { this.ctx.resume().then(() => { // After resume, re-apply gains in case the graph was created while suspended. this._applyVolumes(); }).catch(() => { /* ignore */ }); } if (!this.started) { this.started = true; this._startMusic(); this._applyWeatherLayer(null); // Audible confirmation so the user knows audio is live. if (this.ctx.state === 'running') { this._tone(660, 0.08, { type: 'triangle', peak: 0.22 }); this._tone(990, 0.12, { type: 'triangle', peak: 0.18, when: this.ctx.currentTime + 0.08 }); } } return true; } _applyVolumes() { if (!this.ctx) return; const now = this.ctx.currentTime; const m = this.muted ? 0 : 1; this.master.gain.setTargetAtTime(this.volumes.master * m, now, 0.03); this.musicBus.gain.setTargetAtTime(this.volumes.music, now, 0.03); this.sfxBus.gain.setTargetAtTime(this.volumes.sfx, now, 0.03); } setVolume(channel, value) { const v = clamp01(Number(value) || 0); if (channel === 'master') this.volumes.master = v; else if (channel === 'music') this.volumes.music = v; else if (channel === 'sfx') this.volumes.sfx = v; else return; this._savePrefs(); this._applyVolumes(); } getVolume(channel) { if (channel === 'master') return this.volumes.master; if (channel === 'music') return this.volumes.music; if (channel === 'sfx') return this.volumes.sfx; return 0; } setMuted(muted) { this.muted = !!muted; this._savePrefs(); this._applyVolumes(); } toggleMute() { this.setMuted(!this.muted); return this.muted; } state() { return { ready: !!this.ctx, contextState: this.ctx ? this.ctx.state : 'none', started: this.started, muted: this.muted, master: this.volumes.master, music: this.volumes.music, sfx: this.volumes.sfx, }; } // ------------------------------------------------------------------ // Background music — soft generative ambient pad (original, procedural) // ------------------------------------------------------------------ _startMusic() { if (!this.ctx) return; const ctx = this.ctx; const t0 = ctx.currentTime; // Warm low drone (audible bed under the pad) const drone = ctx.createOscillator(); drone.type = 'sine'; drone.frequency.value = 55; const droneGain = ctx.createGain(); droneGain.gain.value = 0.12; drone.connect(droneGain).connect(this.musicBus); drone.start(t0); // Slow detuned pad voices const padFreqs = [110, 164.81, 220, 329.63]; // A2 E3 A3 E4 const padFilter = ctx.createBiquadFilter(); padFilter.type = 'lowpass'; padFilter.frequency.value = 1200; padFilter.Q.value = 0.7; padFilter.connect(this.musicBus); const padGain = ctx.createGain(); padGain.gain.value = 0.14; padGain.connect(padFilter); const pads = padFreqs.map((f, i) => { const o = ctx.createOscillator(); o.type = i % 2 === 0 ? 'triangle' : 'sine'; o.frequency.value = f; o.detune.value = (i - 1.5) * 6; const g = ctx.createGain(); g.gain.value = 0.35; o.connect(g).connect(padGain); o.start(t0 + i * 0.1); return o; }); // Breathing filter sweep const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.05; const lfoGain = ctx.createGain(); lfoGain.gain.value = 350; lfo.connect(lfoGain).connect(padFilter.frequency); lfo.start(t0); // Sparse bell arpeggio const bellSeq = [440, 554.37, 659.25, 554.37, 493.88, 440]; let bellIdx = 0; const bellTimer = setInterval(() => { if (!this.ctx || this.ctx.state !== 'running') return; if (this.volumes.music < 0.02 || this.muted) return; const now = this.ctx.currentTime; const freq = bellSeq[bellIdx % bellSeq.length]; bellIdx++; this._bell(freq, now, 0.2); }, 3200); this.musicNodes = [drone, droneGain, padFilter, padGain, ...pads, lfo, lfoGain, { stop: () => clearInterval(bellTimer) }]; } _bell(freq, when, peak) { const ctx = this.ctx; const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = freq; const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, when); g.gain.exponentialRampToValueAtTime(peak, when + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, when + 1.4); o.connect(g).connect(this.musicBus); o.start(when); o.stop(when + 1.5); } // ------------------------------------------------------------------ // Weather ambience (rain noise layer) // ------------------------------------------------------------------ /** @param {string|null} weather clear|cloudy|rain|storm */ setWeather(weather) { if (!this.ctx || !this.started) return; this._applyWeatherLayer(weather); } _applyWeatherLayer(weather) { if (!this.ctx) return; const raining = weather === 'rain' || weather === 'storm'; if (raining && !this.rainNodes) { const ctx = this.ctx; const bufferSize = ctx.sampleRate * 2; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1) * 0.5; } const noise = ctx.createBufferSource(); noise.buffer = buffer; noise.loop = true; const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = weather === 'storm' ? 900 : 1400; filter.Q.value = 0.6; const gain = ctx.createGain(); gain.gain.value = 0; gain.gain.setTargetAtTime(weather === 'storm' ? 0.12 : 0.07, ctx.currentTime, 0.8); noise.connect(filter).connect(gain).connect(this.sfxBus); noise.start(); this.rainNodes = { noise, filter, gain, weather }; } else if (raining && this.rainNodes) { const target = weather === 'storm' ? 0.12 : 0.07; this.rainNodes.gain.gain.setTargetAtTime(target, this.ctx.currentTime, 0.5); this.rainNodes.filter.frequency.setTargetAtTime(weather === 'storm' ? 900 : 1400, this.ctx.currentTime, 0.5); this.rainNodes.weather = weather; } else if (!raining && this.rainNodes) { this.rainNodes.gain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.6); const nodes = this.rainNodes; this.rainNodes = null; setTimeout(() => { try { nodes.noise.stop(); } catch (_) { /* already stopped */ } }, 2000); } } /** Play a short thunder crack during storms. */ thunder() { if (!this.ctx || this.muted) return; const now = this.ctx.currentTime; if (now - this._lastThunder < 4) return; this._lastThunder = now; const ctx = this.ctx; const bufferSize = ctx.sampleRate * 1.2; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { const t = i / ctx.sampleRate; data[i] = (Math.random() * 2 - 1) * Math.exp(-t * 3.5); } const src = ctx.createBufferSource(); src.buffer = buffer; const filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 400; const g = ctx.createGain(); g.gain.value = 0.5; src.connect(filter).connect(g).connect(this.sfxBus); src.start(now); } // ------------------------------------------------------------------ // SFX primitives // ------------------------------------------------------------------ _tone(freq, duration, { type = 'sine', peak = 0.15, attack = 0.01, bus = 'sfx', when = null, glideTo = null } = {}) { if (!this.ctx || this.muted) return; const ctx = this.ctx; const t = when != null ? when : ctx.currentTime; const o = ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(freq, t); if (glideTo != null) { o.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), t + duration); } const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t + attack); g.gain.exponentialRampToValueAtTime(0.0001, t + duration); o.connect(g).connect(bus === 'music' ? this.musicBus : this.sfxBus); o.start(t); o.stop(t + duration + 0.05); } /** Named one-shot effects used by the game/UI. */ play(name) { if (!this.ctx || this.muted) return; if (this.ctx.state !== 'running') { this.ctx.resume().catch(() => { /* ignore */ }); } switch (name) { case 'ui': this._tone(660, 0.08, { type: 'triangle', peak: 0.22 }); break; case 'ok': this._tone(523.25, 0.12, { type: 'sine', peak: 0.3 }); this._tone(783.99, 0.18, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.1 }); break; case 'error': this._tone(220, 0.2, { type: 'square', peak: 0.16 }); this._tone(164.81, 0.25, { type: 'square', peak: 0.14, when: this.ctx.currentTime + 0.12 }); break; case 'eat': this._tone(300, 0.08, { type: 'triangle', peak: 0.26, glideTo: 180 }); this._tone(400, 0.1, { type: 'triangle', peak: 0.22, when: this.ctx.currentTime + 0.12, glideTo: 220 }); break; case 'drink': this._tone(500, 0.15, { type: 'sine', peak: 0.22, glideTo: 700 }); break; case 'sleep': this._tone(392, 0.4, { type: 'sine', peak: 0.22, glideTo: 196 }); break; case 'wake': this._tone(261.63, 0.15, { type: 'sine', peak: 0.26 }); this._tone(329.63, 0.15, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.12 }); this._tone(392, 0.2, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.24 }); break; case 'work': this._tone(440, 0.1, { type: 'square', peak: 0.14 }); this._tone(554.37, 0.1, { type: 'square', peak: 0.14, when: this.ctx.currentTime + 0.1 }); break; case 'money': this._tone(880, 0.08, { type: 'sine', peak: 0.28 }); this._tone(1174.66, 0.12, { type: 'sine', peak: 0.24, when: this.ctx.currentTime + 0.07 }); break; case 'build': this._tone(180, 0.15, { type: 'sawtooth', peak: 0.16 }); this._tone(240, 0.15, { type: 'sawtooth', peak: 0.16, when: this.ctx.currentTime + 0.15 }); this._tone(320, 0.2, { type: 'sawtooth', peak: 0.16, when: this.ctx.currentTime + 0.3 }); break; case 'hospital': this._tone(440, 0.35, { type: 'sine', peak: 0.32, glideTo: 220 }); this._tone(330, 0.4, { type: 'sine', peak: 0.28, when: this.ctx.currentTime + 0.3, glideTo: 165 }); break; case 'horn': this._tone(370, 0.25, { type: 'sawtooth', peak: 0.18 }); this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 }); break; case 'footstep': this._tone(90 + Math.random() * 40, 0.06, { type: 'triangle', peak: 0.12, glideTo: 60 }); break; case 'thunder': this.thunder(); break; case 'reload': this._tone(330, 0.1, { type: 'triangle', peak: 0.22 }); this._tone(494, 0.15, { type: 'triangle', peak: 0.22, when: this.ctx.currentTime + 0.08 }); break; default: break; } } /** Continuous footstep ticks while the local player is walking. */ footsteps(dt, moving) { if (!moving || !this.ctx || this.muted || this.ctx.state !== 'running') { this._footstepTimer = 0; return; } this._footstepTimer -= dt; if (this._footstepTimer <= 0) { this._footstepTimer = 0.38; this.play('footstep'); } } } function clamp01(v) { return Math.max(0, Math.min(1, v)); } export const cityAudio = new CityAudioEngine(); export default cityAudio;