244 lines
10 KiB
Python
244 lines
10 KiB
Python
# Prepares the real instrument samples the reel's music is built from.
|
|
#
|
|
# python3 util/reelSamples.py <downloads> <out>
|
|
#
|
|
# The sources are FreePats sound banks, fetched by hand and kept outside the repository because they
|
|
# are hundreds of megabytes:
|
|
#
|
|
# * Electric Guitar FSBS (dist2) a Fender through an amplifier and an effects rack, CC0 1.0
|
|
# https://freepats.zenvoid.org/ElectricGuitar/distorted-electric-guitar.html
|
|
# * Picked Bass YR a Yamaha RBX, CC0 1.0
|
|
# https://freepats.zenvoid.org/ElectricGuitar/clean-electric-bass.html
|
|
# * MuldjordKit a real drum kit, Creative Commons Attribution 4.0
|
|
# https://freepats.zenvoid.org/Percussion/acoustic-drum-kit.html
|
|
#
|
|
# The guitar and the bass are public domain and carry no obligation at all. The kit is CC BY, so a
|
|
# reel that uses it owes Bjorn Muldjord a credit; everything here is arranged so the kit can be
|
|
# swapped for the synthesised one in reelMusic.py if that is unwelcome.
|
|
#
|
|
# What this does to each sample: down to one channel at 32000 Hz, the silence in front cut off so a
|
|
# hit lands the moment it is triggered, a length that suits its part, a short fade so a truncated
|
|
# tail cannot click, and a peak normalised into 16 bits with headroom. Nothing else -- the playing
|
|
# is theirs.
|
|
import math
|
|
import os
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import wave
|
|
|
|
import numpy
|
|
from scipy.signal import fftconvolve
|
|
|
|
|
|
RATE = 32000
|
|
PEAK = 30000
|
|
SILENT = 0.012 # A hit starts where the signal first passes this fraction of its peak.
|
|
FADE = 0.02 # Seconds of fade at the end, so a cut tail does not click.
|
|
|
|
# What to take, what to call it, the MIDI note it was recorded at (None for a drum), and how many
|
|
# seconds of it to keep.
|
|
# What to take, what to call it, the MIDI note it was recorded at (None for a drum), and how many
|
|
# seconds of it to keep. Where a name ends in a letter -- kickB, gtrA2c -- it is another take of the
|
|
# same thing, and the module rotates through them so no two hits are the same waveform. That
|
|
# rotation is the difference between a sampled instrument and a machine gun.
|
|
WANTED = [
|
|
# The kit: three velocities of the kick, the snare and the hat, and three takes of each of
|
|
# those, so a bar of eighths never repeats a sample.
|
|
("25-KdrumL.flac", "kick", None, 0.60),
|
|
("24-KdrumL.flac", "kickB", None, 0.60),
|
|
("23-KdrumL.flac", "kickC", None, 0.60),
|
|
("13-KdrumL.flac", "kickMid", None, 0.55),
|
|
("21-KdrumL.flac", "kickMidB", None, 0.55),
|
|
("56-Snare.flac", "snare", None, 0.70),
|
|
("55-Snare.flac", "snareB", None, 0.70),
|
|
("53-Snare.flac", "snareC", None, 0.70),
|
|
("51-Snare.flac", "snareD", None, 0.70),
|
|
("30-Snare.flac", "snareMid", None, 0.60),
|
|
("5-Snare.flac", "ghost", None, 0.40),
|
|
("29-HihatClosed.flac", "hat", None, 0.22),
|
|
("28-HihatClosed.flac", "hatB", None, 0.22),
|
|
("27-HihatClosed.flac", "hatC", None, 0.22),
|
|
("26-HihatClosed.flac", "hatD", None, 0.22),
|
|
("14-HihatClosed.flac", "hatMid", None, 0.20),
|
|
("29-HihatOpen.flac", "open", None, 0.60),
|
|
("5-RideL.flac", "ride", None, 1.10),
|
|
("1-China.flac", "china", None, 1.80),
|
|
("9-CrashL.flac", "crash", None, 2.40),
|
|
("8-CrashL.flac", "crashB", None, 2.40),
|
|
("13-Tom2.flac", "tom", None, 0.55),
|
|
("10-Tom2.flac", "tomB", None, 0.55),
|
|
("13-Tom4.flac", "tomLow", None, 0.70),
|
|
# The guitar, four takes of everything the riff and the solo actually use.
|
|
("E2_s1_01.flac", "gtrE2", 40, 1.70),
|
|
("E2_s1_02.flac", "gtrE2b", 40, 1.70),
|
|
("E2_s1_03.flac", "gtrE2c", 40, 1.70),
|
|
("E2_s1_04.flac", "gtrE2d", 40, 1.70),
|
|
("A2_s2_01.flac", "gtrA2", 45, 1.70),
|
|
("A2_s2_02.flac", "gtrA2b", 45, 1.70),
|
|
("A2_s2_03.flac", "gtrA2c", 45, 1.70),
|
|
("A2_s2_04.flac", "gtrA2d", 45, 1.70),
|
|
("C2_s1_01.flac", "gtrC2", 36, 1.70),
|
|
("D3_s3_01.flac", "gtrD3", 50, 1.50),
|
|
("D3_s3_02.flac", "gtrD3b", 50, 1.50),
|
|
("D3_s3_03.flac", "gtrD3c", 50, 1.50),
|
|
("E3_s3_01.flac", "gtrE3", 52, 1.50),
|
|
("E3_s3_02.flac", "gtrE3b", 52, 1.50),
|
|
("G3_s4_01.flac", "gtrG3", 55, 1.60),
|
|
("G3_s4_02.flac", "gtrG3b", 55, 1.60),
|
|
("G3_s4_03.flac", "gtrG3c", 55, 1.60),
|
|
("G3_s4_04.flac", "gtrG3d", 55, 1.60),
|
|
("B3_s5_01.flac", "gtrB3", 59, 2.00),
|
|
("C#4_s5_01.flac", "gtrCs4", 61, 2.00),
|
|
("C#4_s5_02.flac", "gtrCs4b", 61, 2.00),
|
|
("E4_s6_01.flac", "gtrE4", 64, 2.20),
|
|
("E4_s6_02.flac", "gtrE4b", 64, 2.20),
|
|
("E4_s6_03.flac", "gtrE4c", 64, 2.20),
|
|
("G4_s6_01.flac", "gtrG4", 67, 2.20),
|
|
("B4_s6_01.flac", "gtrB4", 71, 2.40),
|
|
("B4_s6_02.flac", "gtrB4b", 71, 2.40),
|
|
("C5_s6_01.flac", "gtrC5", 72, 2.40),
|
|
("C5_s6_02.flac", "gtrC5b", 72, 2.40),
|
|
("D5_s6_01.flac", "gtrD5", 74, 2.60),
|
|
("D5_s6_02.flac", "gtrD5b", 74, 2.60),
|
|
# The softer takes, for the doubled side and the second voice.
|
|
("E2_s1_soft_01.flac", "sftE2", 40, 1.60),
|
|
("A2_s2_soft_01.flac", "sftA2", 45, 1.60),
|
|
("C2_s1_soft_01.flac", "sftC2", 36, 1.60),
|
|
("G3_s4_soft_01.flac", "sftG3", 55, 1.50),
|
|
("B3_s5_soft_01.flac", "sftB3", 59, 1.80),
|
|
("D5_s6_soft_01.flac", "sftD5", 74, 2.20),
|
|
("E.flac", "bass", 28, 1.30),
|
|
]
|
|
|
|
|
|
# ===== The room ===============================================================================
|
|
#
|
|
# These recordings are close miked and dry, and a record is not: a kit is in a room and a guitar
|
|
# comes back off the far wall. So each sample gets the space baked into it, which is exactly what the
|
|
# sample libraries of the era did -- their drums arrived with the room already on them.
|
|
#
|
|
# The reverb is built rather than sampled: a handful of discrete early reflections, which is what
|
|
# tells the ear how big the room is, and then exponentially decaying noise for the tail. The guitars
|
|
# get a slap instead, one repeat a tenth of a second later, which is the oldest guitar effect there
|
|
# is and the reason a single tracked part sounds wide.
|
|
|
|
# By instrument: how wet the room is, how long it rings, and the slap in milliseconds with its level.
|
|
TREATMENT = {
|
|
"kick": (0.10, 0.28, None), # Tight: a kick with a long tail is a mess.
|
|
"snare": (0.30, 0.45, None),
|
|
"ghost": (0.26, 0.40, None),
|
|
"hat": (0.16, 0.30, None),
|
|
"open": (0.20, 0.40, None),
|
|
"ride": (0.20, 0.50, None),
|
|
"china": (0.24, 0.60, None),
|
|
"crash": (0.22, 0.60, None),
|
|
"tom": (0.28, 0.45, None),
|
|
"gtr": (0.08, 0.30, (112, 0.34)), # A slap a tenth of a second back, a third of the level.
|
|
"sft": (0.08, 0.30, (112, 0.28)),
|
|
"bass": (0.00, 0.00, None), # Dry: reverb on a bass is mud.
|
|
}
|
|
|
|
|
|
def treatmentFor(label):
|
|
for prefix in sorted(TREATMENT, key=len, reverse=True):
|
|
if label.startswith(prefix):
|
|
return TREATMENT[prefix]
|
|
|
|
return (0.15, 0.35, None)
|
|
|
|
|
|
def impulse(rate, seconds, seed=1234):
|
|
# The early reflections first, at the times a room this size would give them, then the tail.
|
|
length = int(seconds * rate)
|
|
out = numpy.zeros(length)
|
|
state = seed
|
|
for at, gain in ((0.011, 0.5), (0.017, 0.38), (0.023, 0.30), (0.031, 0.26), (0.043, 0.20)):
|
|
index = int(at * rate)
|
|
if index < length:
|
|
out[index] += gain
|
|
for i in range(int(0.008 * rate), length):
|
|
state = (state * 1103515245 + 12345) & 0x7FFFFFFF
|
|
noise = ((state >> 13) % 2001 - 1000) / 1000.0
|
|
out[i] += noise * math.exp(-5.0 * i / float(length)) * 0.22
|
|
# Nothing below eighty hertz or above eight kilohertz: a room does not reflect either well.
|
|
out[0] += 1.0 # The direct sound, so the mix keeps its front.
|
|
|
|
return out
|
|
|
|
|
|
def space(samples, rate, wet, seconds, slap):
|
|
if not samples:
|
|
return samples
|
|
x = numpy.array(samples, dtype=float)
|
|
if slap:
|
|
ms, level = slap
|
|
for tap, gain in ((ms, level), (ms * 2, level * 0.45)):
|
|
shift = int(ms and tap * rate / 1000.0)
|
|
if shift < len(x):
|
|
padded = numpy.concatenate([numpy.zeros(shift), x]) * gain
|
|
x = numpy.concatenate([x, numpy.zeros(shift)]) + padded[:len(x) + shift]
|
|
if wet > 0:
|
|
wetted = fftconvolve(x, impulse(rate, seconds), mode="full")
|
|
wetted = wetted[:len(x) + int(seconds * rate)]
|
|
dry = numpy.concatenate([x, numpy.zeros(len(wetted) - len(x))])
|
|
x = dry * (1.0 - wet) + wetted / (numpy.abs(wetted).max() or 1) * numpy.abs(dry).max() * wet
|
|
peak = numpy.abs(x).max() or 1.0
|
|
|
|
return [int(max(-32768, min(32767, round(v * PEAK / peak)))) for v in x]
|
|
|
|
|
|
def find(root, name):
|
|
for base, dirs, files in os.walk(root):
|
|
if name in files:
|
|
return os.path.join(base, name)
|
|
return None
|
|
|
|
|
|
def decode(path):
|
|
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", str(RATE),
|
|
"-f", "s16le", "-"], capture_output=True, check=True).stdout
|
|
|
|
return list(struct.unpack("<%dh" % (len(raw) // 2), raw))
|
|
|
|
|
|
def prepare(samples, seconds):
|
|
peak = max(abs(v) for v in samples) or 1
|
|
floor = peak * SILENT
|
|
start = next((i for i, v in enumerate(samples) if abs(v) > floor), 0)
|
|
start = max(0, start - int(0.002 * RATE)) # Two milliseconds of run up.
|
|
out = samples[start:start + int(seconds * RATE)]
|
|
fade = int(FADE * RATE)
|
|
for i in range(max(0, len(out) - fade), len(out)):
|
|
out[i] = int(out[i] * (len(out) - i) / float(fade))
|
|
peak = max(abs(v) for v in out) or 1
|
|
|
|
return [int(max(-32768, min(32767, v * PEAK / peak))) for v in out]
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 3:
|
|
sys.exit("usage: reelSamples.py <downloads> <out>")
|
|
source, out = sys.argv[1], sys.argv[2]
|
|
os.makedirs(out, exist_ok=True)
|
|
missing = []
|
|
for name, label, midi, seconds in WANTED:
|
|
path = find(source, name)
|
|
if path is None:
|
|
missing.append(name)
|
|
continue
|
|
wet, rings, slap = treatmentFor(label)
|
|
data = space(prepare(decode(path), seconds), RATE, wet, rings, slap)
|
|
with wave.open(os.path.join(out, label + ".wav"), "wb") as handle:
|
|
handle.setnchannels(1)
|
|
handle.setsampwidth(2)
|
|
handle.setframerate(RATE)
|
|
handle.writeframes(struct.pack("<%dh" % len(data), *data))
|
|
print("%-8s %6d samples %4.2f s %s" % (label, len(data), len(data) / float(RATE),
|
|
"midi %d" % midi if midi else "one shot"))
|
|
if missing:
|
|
print("\nnot found: %s" % ", ".join(missing))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|