summaryrefslogtreecommitdiff
path: root/tools/sounds
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 /tools/sounds
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 'tools/sounds')
-rw-r--r--tools/sounds/build-sounds.py107
1 files changed, 107 insertions, 0 deletions
diff --git a/tools/sounds/build-sounds.py b/tools/sounds/build-sounds.py
new file mode 100644
index 0000000..0b64aeb
--- /dev/null
+++ b/tools/sounds/build-sounds.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# Cuts the night sounds in public/assets/sounds/ out of their source
+# recordings: trims them, cleans up the owls, makes the fire and wind loop
+# seamlessly, and encodes them as Ogg Opus (gapless when looped).
+#
+# python3 tools/sounds/build-sounds.py SOURCE_DIR public/assets/sounds
+#
+# SOURCE_DIR holds the downloads under these names. BigSoundBank files are
+# https://bigsoundbank.com/UPLOAD/bwf-en/<number>.wav (CC0, Joseph Sardin).
+# 0988.wav "Big branching fire #2" bigsoundbank.com/big-branching-fire-2-s0988.html
+# 0904.wav "Wind in the Trees" bigsoundbank.com/wind-in-the-trees-s0904.html
+# 1299.wav "Broken twigs #1" bigsoundbank.com/broken-twigs-1-s1299.html
+# 1301.wav "Steps on the Twigs" bigsoundbank.com/steps-on-the-twigs-s1301.html
+# 0137.wav "Feet in Leaves #1" bigsoundbank.com/feet-in-leaves-1-s0137.html
+# 1588.wav "Gavel, 1 blow" bigsoundbank.com/gavel-1-blow-s1588.html
+# 0581.wav "Two dice on wooden table" bigsoundbank.com/two-dice-on-wooden-table-s0581.html
+# gho.mp3 "Bubo virginianus - Great Horned Owl XC450919.mp3" by Michael &
+# Katie LaTour (xeno-canto), CC BY-SA 4.0, on Wikimedia Commons
+# bdow.oga "Barred Owl, Yellowstone National Park.oga", National Park
+# Service, public domain, on Wikimedia Commons
+#
+# Needs ffmpeg (with libopus) and numpy.
+import os
+import subprocess
+import sys
+
+import numpy as np
+
+RATE = 48000
+
+
+def load(path, start=0.0, length=None, filters=None, channels=1):
+ cmd = ['ffmpeg', '-v', 'error', '-ss', str(start)]
+ if length:
+ cmd += ['-t', str(length)]
+ cmd += ['-i', path, '-ac', str(channels), '-ar', str(RATE)]
+ if filters:
+ cmd += ['-af', filters]
+ raw = subprocess.run(cmd + ['-f', 'f32le', '-'], capture_output=True, check=True).stdout
+ return np.frombuffer(raw, np.float32).reshape(-1, channels).copy()
+
+
+def save(audio, path, bitrate='64k'):
+ audio = audio / np.abs(audio).max() * 0.89 # peak -1 dB; loudness is set in ambience.js
+ channels = audio.shape[1]
+ subprocess.run(
+ ['ffmpeg', '-v', 'error', '-y', '-f', 'f32le', '-ar', str(RATE), '-ac', str(channels), '-i', '-',
+ '-c:a', 'libopus', '-b:a', bitrate, path],
+ input=audio.astype(np.float32).tobytes(), check=True)
+ print(f'{os.path.basename(path)}: {len(audio) / RATE:.1f} s, {os.path.getsize(path) // 1024} KB')
+
+
+def fade(audio, fade_in=0.05, fade_out=0.3):
+ n_in, n_out = int(fade_in * RATE), int(fade_out * RATE)
+ audio[:n_in] *= np.linspace(0, 1, n_in)[:, None]
+ audio[-n_out:] *= np.linspace(1, 0, n_out)[:, None]
+ return audio
+
+
+def loop(audio, overlap):
+ """Folds the last `overlap` seconds over the start with an equal-power crossfade."""
+ n = int(overlap * RATE)
+ body = audio[:-n].copy()
+ t = np.linspace(0, 1, n)[:, None]
+ body[:n] = body[:n] * np.sqrt(t) + audio[-n:] * np.sqrt(1 - t)
+ return body
+
+
+def main(src, dst):
+ os.makedirs(dst, exist_ok=True)
+ s = lambda name: os.path.join(src, name)
+ d = lambda name: os.path.join(dst, name)
+
+ # Fire: 40 s of an outdoor branch fire, looped, without the rumble under it
+ # (around 20 Hz: the microphone or distant traffic, not the fire).
+ save(loop(load(s('0988.wav'), 4, 42, 'highpass=f=70:poles=2,highpass=f=70:poles=2'), 2.0), d('fire.ogg'))
+
+ # Wind in the trees: stereo, looped. Under the rustling there's a steady roar
+ # of distant traffic or planes (it doesn't follow the gusts), so only the
+ # leaves are kept. (From 33 s: earlier there's a bump, later a few birds.)
+ save(loop(load(s('0904.wav'), 33, 63, 'highpass=f=500:poles=2,highpass=f=500:poles=2', channels=2), 3.0),
+ d('wind.ogg'), '80k')
+
+ # Owls: two phrases of the great horned owl, with the low hum under them taken out.
+ owl = 'highpass=f=250:poles=2,highpass=f=250:poles=2,lowpass=f=1400'
+ save(fade(load(s('gho.mp3'), 2.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-1.ogg'), '48k')
+ save(fade(load(s('gho.mp3'), 10.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-2.ogg'), '48k')
+ save(fade(load(s('bdow.oga'), 0, None, 'highpass=f=200:poles=2,highpass=f=200:poles=2'), 0.05, 0.4), d('owl-barred.ogg'), '48k')
+
+ # Something out in the dark: twigs snapping, and footsteps on twigs and in
+ # leaves. The footsteps were recorded close up and thud (mostly below 40 Hz),
+ # which something small walking past 20 m away wouldn't.
+ light = 'highpass=f=150:poles=2,highpass=f=150:poles=2'
+ save(fade(load(s('1299.wav'), 3.55, 0.75, light)), d('twig-1.ogg'))
+ save(fade(load(s('1299.wav'), 0.0, 2.3, light)), d('twig-2.ogg'))
+ save(fade(load(s('1301.wav'), 0.0, 6.4, light), 0.05, 0.5), d('steps-twigs.ogg'))
+ save(fade(load(s('0137.wav'), 0.0, 7.8, light), 0.05, 0.6), d('steps-leaves.ogg'))
+
+ # A chess piece set down (chess.js): a light gavel tap, and the first knock of
+ # dice landing on a wooden table, cut before they bounce.
+ save(fade(load(s('1588.wav'), 0.02, 0.25, light), 0.003, 0.15), d('chess-1.ogg'), '48k')
+ save(fade(load(s('0581.wav'), 4.095, 0.17, light), 0.003, 0.08), d('chess-2.ogg'), '48k')
+ save(fade(load(s('0581.wav'), 12.825, 0.13, light), 0.003, 0.06), d('chess-3.ogg'), '48k')
+
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])