#!/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/.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])