singe/util/reelSamples.py

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()