summaryrefslogtreecommitdiff
path: root/public/js/components/ambience.js
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/ambience.js
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 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/ambience.js')
-rw-r--r--public/js/components/ambience.js130
1 files changed, 130 insertions, 0 deletions
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);
+ }
+ },
+ });
+})();