/* global AFRAME, THREE */ // Quiet night sounds, from real recordings (public/assets/sounds, made by // tools/sounds/build-sounds.py): the fire crackling at the fire ring, wind in // the trees all around, and now and then something out in the dark, from a // random spot among the trees: an owl calling (often again, from the same // tree), a twig snapping, or footsteps that wander past. // // They play through the same listener as the voices (networked-audio-source // creates it the same way), so they come from where they happen. Nothing is // heard until app.js resumes the audio context on Join. (function () { const SOUND_DIR = 'assets/sounds/'; const FIRE = { file: 'fire', volume: 0.7, refDistance: 1.2, rolloff: 1.5 }; const WIND = { file: 'wind', volume: 0.12 }; // What can be heard in the forest: recordings, loudness, how often (relative // to each other), and how high up it happens. const CRITTERS = { owl: { files: ['owl-great-horned-1', 'owl-great-horned-2', 'owl-barred'], volume: 0.55, weight: 2, height: [5, 9] }, twig: { files: ['twig-1', 'twig-2'], volume: 0.6, weight: 3, height: [0, 0.3] }, steps: { files: ['steps-twigs', 'steps-leaves'], volume: 0.5, weight: 3, height: [0, 0.3] }, }; const POOL = 4; const pick = (list) => list[Math.floor(Math.random() * list.length)]; AFRAME.registerComponent('ambience', { init() { const sceneEl = this.el.sceneEl; // Share the voices' listener; whichever starts first makes it. if (!sceneEl.audioListener) { sceneEl.audioListener = new THREE.AudioListener(); if (sceneEl.camera) sceneEl.camera.add(sceneEl.audioListener); sceneEl.addEventListener('camera-set-active', (e) => e.detail.cameraEl.getObject3D('camera').add(sceneEl.audioListener)); } this.listener = sceneEl.audioListener; this.buffers = {}; this.pool = []; const loader = new THREE.AudioLoader(); const load = (name) => new Promise((resolve, reject) => loader.load(`${SOUND_DIR}${name}.ogg`, resolve, undefined, reject)) .then((buffer) => { this.buffers[name] = buffer; }); Promise.all([load(FIRE.file), load(WIND.file)]) .then(() => this.startLoops()) .catch((err) => console.warn("Couldn't load the fire and wind sounds", err)); Promise.all(Object.values(CRITTERS).flatMap((c) => c.files.map(load))) .then(() => this.scheduleCritter(5000)) .catch((err) => console.warn("Couldn't load the forest sounds", err)); }, remove() { clearTimeout(this.timer); clearTimeout(this.owlTimer); for (const s of [this.fire, this.wind, ...this.pool]) { if (!s) continue; if (s.isPlaying) s.stop(); s.removeFromParent(); } }, startLoops() { this.fire = new THREE.PositionalAudio(this.listener); this.fire.setBuffer(this.buffers[FIRE.file]); this.fire.setLoop(true); this.fire.setVolume(FIRE.volume); this.fire.setRefDistance(FIRE.refDistance); this.fire.setRolloffFactor(FIRE.rolloff); this.fire.position.set(0, 0.5, 0); this.el.sceneEl.object3D.add(this.fire); this.fire.play(); this.wind = new THREE.Audio(this.listener); this.wind.setBuffer(this.buffers[WIND.file]); this.wind.setLoop(true); this.wind.setVolume(WIND.volume); this.wind.play(); }, scheduleCritter(delay) { this.timer = setTimeout(() => { // Only while someone can hear it, so they don't pile up while suspended. if (this.listener.context.state === 'running') this.critter(); this.scheduleCritter(5000 + Math.random() * 12000); }, delay); }, critter(kind, file, where) { if (!kind) { const names = Object.keys(CRITTERS); let n = Math.random() * names.reduce((sum, k) => sum + CRITTERS[k].weight, 0); kind = names.find((k) => (n -= CRITTERS[k].weight) < 0) || names[0]; } const spec = CRITTERS[kind]; file = file || pick(spec.files); const angle = Math.random() * Math.PI * 2; if (!where) { const radius = 14 + Math.random() * 16; const height = spec.height[0] + Math.random() * (spec.height[1] - spec.height[0]); where = new THREE.Vector3(Math.sin(angle) * radius, height, Math.cos(angle) * radius); } let sound = this.pool.find((s) => !s.isPlaying); if (!sound) { if (this.pool.length >= POOL) return; sound = new THREE.PositionalAudio(this.listener); sound.setRefDistance(6); sound.setRolloffFactor(1); this.el.sceneEl.object3D.add(sound); this.pool.push(sound); } sound.setBuffer(this.buffers[file]); sound.setVolume(spec.volume * (0.6 + 0.4 * Math.random())); sound.position.copy(where); // Footsteps walk past, about 1 m/s, along the edge of the trees. sound.userData.velocity = kind === 'steps' ? new THREE.Vector3(Math.cos(angle), 0, -Math.sin(angle)).multiplyScalar(Math.random() < 0.5 ? 1 : -1) : null; sound.play(); if (kind === 'owl' && Math.random() < 0.6) { this.owlTimer = setTimeout(() => this.critter('owl', file, where), 7000 + Math.random() * 8000); } }, tick(time, delta) { const dt = Math.min(delta, 100) / 1000; for (const s of this.pool) { if (s.isPlaying && s.userData.velocity) s.position.addScaledVector(s.userData.velocity, dt); } }, }); })();