prep_sfx.py
Python, 128 lines from Sound and VFX overview
#!/usr/bin/env python3
"""prep_sfx.py: get audio files ready for a Roblox upload.
Per file: decode (ffmpeg) -> trim silence -> loudness-normalise to the group target
(gated RMS) -> peak limit -> edge fades (one-shots) or a seamless crossfade wrap (loops)
-> Ogg Vorbis ~q6, 44.1 kHz. The output is named after the input stem, which should be
the registry key (cash_collect.wav -> cash_collect.ogg).
python3 prep_sfx.py --group UI --out final/ raw/ui_click.wav raw/ui_reward.wav
python3 prep_sfx.py --group Ambience --loop 20 --out final/ raw/rain_loop.wav
Needs ffmpeg on PATH and numpy.
"""
import argparse
import os
import subprocess
import sys
import numpy as np
SR = 44100
TARGET_DB = {"UI": -17.0, "SFX": -14.0, "Ambience": -20.0, "Music": -18.0}
CEILING = 0.89 # about -1 dBFS
def decode(path):
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "f32le", "-ac", "2", "-ar", str(SR), "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype=np.float32).reshape(-1, 2).copy()
def encode(x, path):
data = np.clip(x, -1, 1).astype(np.float32).tobytes()
base = ["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "2", "-i", "-"]
for codec in (["-c:a", "libvorbis", "-q:a", "6"], ["-c:a", "vorbis", "-strict", "-2", "-q:a", "6"]):
r = subprocess.run(base + codec + [path], input=data, capture_output=True)
if r.returncode == 0:
return
sys.exit("ffmpeg could not encode Ogg Vorbis: " + r.stderr.decode()[-300:])
def trim(x, thresh_db=-50.0, pad=0.01):
"""Cut leading/trailing silence; a gap at the start makes clicks feel laggy."""
env = np.abs(x).max(axis=1)
idx = np.where(env > 10 ** (thresh_db / 20))[0]
if len(idx) == 0:
return x
p = int(pad * SR)
return x[max(0, idx[0] - p): min(len(x), idx[-1] + p)]
def loudnorm(x, target_db):
"""Gated RMS: only 50 ms frames within 30 dB of the loudest count, so tails don't skew it."""
f = int(0.05 * SR)
n = max(1, len(x) // f)
frames = x[: n * f].reshape(n, f, 2) if len(x) >= f else x[None, :, :]
rms = np.sqrt((frames ** 2).mean(axis=(1, 2)) + 1e-12)
gated = rms[rms > rms.max() * 10 ** (-30 / 20)]
level = np.sqrt((gated ** 2).mean())
return x * (10 ** (target_db / 20) / level)
def limit(x, ceiling=CEILING, block=0.005, release=0.06):
"""Block look-ahead limiter: per-5 ms gain, instant attack, exponential release."""
b = max(1, int(block * SR))
n = -(-len(x) // b)
padded = np.zeros((n * b, 2), dtype=x.dtype)
padded[: len(x)] = x
peaks = np.abs(padded).reshape(n, b, 2).max(axis=(1, 2))
want = np.minimum(1.0, ceiling / np.maximum(peaks, 1e-9))
rel = np.exp(-block / release)
g = np.empty(n)
cur = 1.0
for i in range(n):
cur = want[i] if want[i] < cur else want[i] + (cur - want[i]) * rel
g[i] = cur
gain = np.repeat(g, b)[: len(x)]
return x * gain[:, None]
def fade(x, fade_in=0.003, fade_out=0.06):
y = x.copy()
a = min(int(fade_in * SR), len(y) // 2)
z = min(int(fade_out * SR), len(y) // 4)
if a:
y[:a] *= np.linspace(0, 1, a)[:, None]
if z:
y[-z:] *= np.linspace(1, 0, z)[:, None]
return y
def loop_wrap(x, seconds, xfade=0.5):
"""Seamless loop of `seconds`: crossfade the material after the loop point into the head."""
n, xf = int(seconds * SR), int(xfade * SR)
if len(x) < n + xf:
n = len(x) - xf
if n <= xf:
sys.exit("file too short to loop")
out = x[:n].copy()
t = np.linspace(0, 1, xf)[:, None]
out[:xf] = x[:xf] * np.sqrt(t) + x[n: n + xf] * np.sqrt(1 - t) # equal-power
return out
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+")
ap.add_argument("--group", choices=sorted(TARGET_DB), required=True)
ap.add_argument("--gain", type=float, default=0.0, help="extra dB on top of the group target")
ap.add_argument("--loop", type=float, help="make a seamless loop of this many seconds")
ap.add_argument("--out", default="final")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
for path in a.files:
x = decode(path)
if not a.loop:
x = trim(x)
x = limit(loudnorm(x, TARGET_DB[a.group] + a.gain))
x = loop_wrap(x, a.loop) if a.loop else fade(x)
name = os.path.splitext(os.path.basename(path))[0]
dest = os.path.join(a.out, name + ".ogg")
encode(x, dest)
print(f"{dest} {len(x) / SR:.2f}s peak {np.abs(x).max():.2f}")
if __name__ == "__main__":
main()