Join the RoClicks Discord
RoClicks

prep_sfx.py

Python, 128 lines from Sound and VFX overview

Download prep_sfx.py
#!/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()