1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
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])
|