Download src/audio/SoundLibrary.js from mlmihjaz/Echo: direct link, hf CLI and curl.
- Browser
- Download file 8.42 kB
-
https://huggingface.co/spaces/mlmihjaz/Echo/resolve/main/src/audio/SoundLibrary.js
- Command line
-
hf download hf://spaces/mlmihjaz/Echo/src/audio/SoundLibrary.js
-
curl -L -o SoundLibrary.js https://huggingface.co/spaces/mlmihjaz/Echo/resolve/main/src/audio/SoundLibrary.js
8.42 kB
| /** | |
| * 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/<category>/ 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); } | |
| } | |