/** * SoundLibrary — file-based ambient + event sound system. * * Reads /sounds/manifest.json, loads every listed file as an Audio element, * then plays them programmatically based on time-of-day and scene events. * * Continuous (looped, crossfaded): * day fades in / out across the day arc * night fades in / out across the night arc * wind always on, low volume * water always on, low volume * * One-shots (random scheduling, time-gated): * bubbles every 3 – 9 sec, anytime * birds every 5 – 15 sec, day only * crane every 40 – 90 sec, day only (also fired by Crane.update) * goat every 25 – 60 sec, day only * * Drop new files into /public/sounds// and add the filename * to /public/sounds/manifest.json — see public/sounds/README.txt. */ const MANIFEST_URL = '/sounds/manifest.json'; // Per-category playback config. // loop — true for continuous beds // gain — base volume 0..1 // gate — 'day' | 'night' | 'always' // minGap/maxGap — seconds between one-shots const CONFIG = { day: { loop: true, gain: 0.35, gate: 'day' }, night: { loop: true, gain: 0.35, gate: 'night' }, wind: { loop: true, gain: 0.18, gate: 'always' }, water: { loop: true, gain: 0.22, gate: 'always' }, bubbles: { loop: false, gain: 0.45, gate: 'always', minGap: 3, maxGap: 9 }, birds: { loop: false, gain: 0.55, gate: 'day', minGap: 5, maxGap: 15 }, crane: { loop: false, gain: 0.55, gate: 'day', minGap: 40, maxGap: 90 }, goat: { loop: false, gain: 0.50, gate: 'day', minGap: 25, maxGap: 60 }, // Weather-stage continuous beds — playable only when the active stage matches. rain: { loop: true, gain: 0.50, gate: 'weather', stages: [2, 4] }, // drizzle, thunder 'storm-wind': { loop: true, gain: 0.55, gate: 'weather', stages: [3, 4] }, // mist, thunder 'snow-wind': { loop: true, gain: 0.45, gate: 'weather', stages: [5, 6] }, // first snow, ice age // One-shots fired from scene events. thunder: { loop: false, gain: 0.85, gate: 'always' }, winner: { loop: false, gain: 0.80, gate: 'always' }, award: { loop: false, gain: 0.75, gate: 'always' }, // Penguin honks — random while penguins are on screen (Snow + Ice Age only). penguin: { loop: false, gain: 0.55, gate: 'weather', stages: [5, 6], minGap: 12, maxGap: 35 }, }; export class SoundLibrary { constructor() { this.enabled = true; this.started = false; this._clock = null; /** category -> Audio[] (one per file listed in manifest) */ this._pool = {}; /** category -> currently-playing Audio (looped beds only) */ this._active = {}; /** category -> next scheduled fire time (seconds since start) */ this._nextAt = {}; this._elapsed = 0; this._raf = null; this._weatherStage = 0; this._loadPromise = this._load(); } setClock(clock) { this._clock = clock; } setWeatherStage(idx) { this._weatherStage = idx | 0; } setEnabled(on) { this.enabled = !!on; if (!this.enabled) { for (const cat in this._active) { const a = this._active[cat]; if (a) { try { a.pause(); } catch (e) {} } } } else if (this.started) { this._kickLoops(); } } /** Public hook so other scene code (e.g. Crane) can trigger a one-shot. */ fire(category) { if (!this.enabled || !this.started) return; this._playOneShot(category); } async _load() { let manifest; try { const res = await fetch(MANIFEST_URL, { cache: 'no-store' }); if (!res.ok) throw new Error(`manifest ${res.status}`); manifest = await res.json(); } catch (e) { console.warn('[SoundLibrary] manifest.json missing or unreadable — no sounds will play.', e); return; } let total = 0; for (const cat of Object.keys(CONFIG)) { const files = Array.isArray(manifest[cat]) ? manifest[cat] : []; this._pool[cat] = files.map(name => { const url = `/sounds/${cat}/${name}`; const a = new Audio(url); a.preload = 'auto'; a.crossOrigin = 'anonymous'; a.addEventListener('error', () => console.warn(`[SoundLibrary] failed to load ${url}`)); return a; }); total += this._pool[cat].length; } console.log(`[SoundLibrary] loaded ${total} clips across ${Object.keys(this._pool).length} categories.`); } /** Call once after the first user gesture so autoplay is unlocked. */ async start() { if (this.started) return; await this._loadPromise; this.started = true; if (!this.enabled) return; this._kickLoops(); this._lastTickMs = performance.now(); const loop = () => { const now = performance.now(); const dt = Math.min(0.25, (now - this._lastTickMs) / 1000); this._lastTickMs = now; this._tick(dt); this._raf = requestAnimationFrame(loop); }; this._raf = requestAnimationFrame(loop); } stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } for (const cat in this._active) { const a = this._active[cat]; if (a) { try { a.pause(); a.currentTime = 0; } catch (e) {} } } this._active = {}; this.started = false; } // ---- internals ---- _kickLoops() { // Every loop-category gets a backing Audio kicked at volume 0; _tick fades it in/out. for (const cat of Object.keys(CONFIG)) { if (!CONFIG[cat].loop) continue; const pool = this._pool[cat] || []; if (!pool.length) continue; if (this._active[cat]) continue; const a = pool[Math.floor(Math.random() * pool.length)]; a.loop = true; a.volume = 0; const p = a.play(); if (p && p.catch) p.catch(() => {}); this._active[cat] = a; } } _tick(dt) { this._elapsed += dt; if (!this.enabled) return; // Adjust looped bed volumes by time-of-day + weather stage. const isDay = this._isDay(); this._fade('wind', CONFIG.wind.gain, dt); this._fade('water', CONFIG.water.gain, dt); this._fade('day', isDay ? CONFIG.day.gain : 0, dt); this._fade('night', isDay ? 0 : CONFIG.night.gain, dt); // Weather-stage beds for (const cat of Object.keys(CONFIG)) { const cfg = CONFIG[cat]; if (!cfg.loop || cfg.gate !== 'weather') continue; const active = cfg.stages.includes(this._weatherStage); this._fade(cat, active ? cfg.gain : 0, dt); } // Schedule one-shots — any non-loop category with minGap/maxGap is // randomly scheduled, gated by time-of-day or weather stage. for (const cat of Object.keys(CONFIG)) { const cfg = CONFIG[cat]; if (cfg.loop) continue; if (cfg.minGap === undefined) continue; // event-only one-shots (fire()) if (!this._pool[cat] || !this._pool[cat].length) continue; if (cfg.gate === 'day' && !isDay) continue; if (cfg.gate === 'night' && isDay) continue; if (cfg.gate === 'weather' && !(cfg.stages || []).includes(this._weatherStage)) continue; if (this._nextAt[cat] === undefined) { this._nextAt[cat] = this._elapsed + this._rand(cfg.minGap, cfg.maxGap); continue; } if (this._elapsed >= this._nextAt[cat]) { this._playOneShot(cat); this._nextAt[cat] = this._elapsed + this._rand(cfg.minGap, cfg.maxGap); } } } _fade(cat, target, dt) { const a = this._active[cat]; if (!a) return; const speed = 0.4; // gain units per second const step = speed * dt; if (a.volume < target) a.volume = Math.min(target, a.volume + step); else if (a.volume > target) a.volume = Math.max(target, a.volume - step); } _playOneShot(cat) { const pool = this._pool[cat] || []; if (!pool.length) return; const src = pool[Math.floor(Math.random() * pool.length)]; // Clone so overlapping plays don't cut each other. const a = src.cloneNode(true); a.volume = CONFIG[cat].gain; const p = a.play(); if (p && p.catch) p.catch(() => {}); } _isDay() { if (this._clock?.isDay) return this._clock.isDay(); // Fallback if no clock — assume day. return true; } _rand(min, max) { return min + Math.random() * (max - min); } }