From e69ad01e700e80fa959dfee08cd556ca0d777ffd Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 05:43:24 -0800 Subject: Yesod: self-hosted WebXR rooms Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 --- public/js/components/ambience.js | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 public/js/components/ambience.js (limited to 'public/js/components/ambience.js') diff --git a/public/js/components/ambience.js b/public/js/components/ambience.js new file mode 100644 index 0000000..e7454ae --- /dev/null +++ b/public/js/components/ambience.js @@ -0,0 +1,130 @@ +/* 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); + } + }, + }); +})(); -- cgit v1.2.3