Echo / src /audio /SoundLibrary.js
mlmihjaz's picture
Initial commit: ECHO 3D vocab game with Node+SQLite backend, admin dashboard, Docker deploy
173e683
Raw
History Blame Contribute Delete
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); }
}