Version 3.4.0: Edit tab (edit list, large subtitle, replacements with effects and presets), Sounds tab only browses, ackit fx (pedalboard + own reverb) and replace --fx, first Linux build, catalog without a mod package

This commit is contained in:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

View File

@@ -3,9 +3,9 @@
Library layers (low -> high):
formats wad, pack, bnk (soundbanks), upk + lzo (UE3 packages), wem, ogg, bits, codebooks, wwvorbis,
allocsize, builder, convert, known_setups
audio audio (load/convert/decode), encoder (PCM -> game WEM)
audio audio (load/convert/decode), encoder (PCM -> game WEM), fx (effects for replacements)
game paths, game (install discovery), catalog + classify (names/categories/characters),
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
"""
__version__ = "3.3.0"
__version__ = "3.4.0"

View File

@@ -14,6 +14,7 @@ The game folder is detected from Steam (Windows, Linux, Proton); override with -
language (default: the game's); languages without their own voices use the INT voice lines, like the game.
"""
import argparse
import json
import os
import sys
@@ -124,6 +125,9 @@ def _process(a, x, sr, ws, s):
x = audio.trim(x, sr, float(start or 0), float(end) if end else None)
if a.trim_silence:
x = audio.trim_silence(x, rate=sr)
if a.fx:
from . import fx
x = fx.render(x, sr, json.load(open(a.fx, encoding="utf-8")), s.channels)
if a.match_loudness:
ref, _ = ws.original_audio(s)
x = audio.match_loudness(x, ref)
@@ -134,6 +138,14 @@ def _process(a, x, sr, ws, s):
return x
def cmd_fx_template(a):
from . import fx
with open(a.out, "w", encoding="utf-8") as f:
json.dump(fx.defaults(), f, indent=1)
print("wrote %s: set \"on\": true on the effects you want (blocks: %s)"
% (a.out, ", ".join(b.key for b in fx.BLOCKS)))
def cmd_replace(a):
ws = _workspace(a)
s = ws.sound(a.name)
@@ -364,6 +376,7 @@ def build_parser():
proc = argparse.ArgumentParser(add_help=False)
proc.add_argument("--trim", metavar="START:END", help="keep only this part (seconds), e.g. 0.5:4.2")
proc.add_argument("--trim-silence", action="store_true", help="cut leading/trailing silence")
proc.add_argument("--fx", metavar="FILE", help="effects settings (JSON, see `ackit fx-template`)")
proc.add_argument("--gain", type=float, metavar="DB", help="volume change in dB")
proc.add_argument("--normalize", action="store_true", help="peak-normalize to -1 dBFS")
proc.add_argument("--match-loudness", action="store_true", help="match the original's loudness")
@@ -382,6 +395,9 @@ def build_parser():
s.add_argument("--group"); s.add_argument("--category"); s.add_argument("--character")
s.add_argument("--inferred", action="store_true", help="also match characters inferred from names")
s.add_argument("--modified", action="store_true"); s.add_argument("--csv", metavar="FILE")
s = add("fx-template", cmd_fx_template, "write effects settings with every effect off (edit, then "
"use with replace --fx)")
s.add_argument("out")
s = add("info", cmd_info, "show details of a sound (or of a .wem file)")
s.add_argument("name")
s = add("extract", cmd_extract, "save a sound as .wav (decoded) or .wem")

508
ackit/fx.py Normal file
View File

@@ -0,0 +1,508 @@
"""Audio effects for replacements (any sound: voices, effects, music), set by hand. The blocks cover the effects
found in the game's sounds (docs/history/mission4_dubbing_plan.md sections 9-10).
`BLOCKS` is the single description of every effect block and its parameters (the editor builds its controls from
it). Settings are plain dicts {block key: {"on": bool, param key: value}} (JSON-safe). `render` applies the blocks
that are on, in the order of `BLOCKS`; random parts (glitches, reverse reverb, noise) use a fixed seed, so the same
settings always give the same audio. Audio is float32 (frames, channels) like `ackit.audio`; effects can make it
longer (glitch repeats, echo and reverb tails).
Built on pedalboard (GPL-3.0): filters, distortion, delay, reverb, codec and Rubber Band pitch/formant shifting;
the rest (ring modulator, glitches, noise, reverse reverb, channel layout) is numpy.
"""
from collections import namedtuple
import numpy as np
Param = namedtuple("Param", "key label kind lo hi default step unit tip", defaults=("", ""))
Block = namedtuple("Block", "key label tip params", defaults=((),))
BLOCKS = (
Block("pitch", "Pitch", "Pitch shifter (moves the sound and its formants together, like the game's Clayface), "
"optional formant shift and a pitch that drifts over time (Mr. Freeze).", (
Param("shift", "Shift", "float", -24, 24, 0.0, 0.5, "st", "Pitch shift in semitones"),
Param("formant", "Formant", "float", -12, 12, 0.0, 0.5, "st",
"Moves only the formants (voice size/character), the pitch stays"),
Param("drift", "Drift", "float", 0, 12, 0.0, 0.1, "st", "Pitch over time: depth of the slow swing"),
Param("drift_rate", "Drift rate", "float", 0.05, 5, 0.3, 0.05, "Hz", "Speed of the swing"))),
Block("octaves", "Octave layers", "Pitch-shifted copies of the sound mixed under it (Ra's al Ghul, Poison Ivy).", (
Param("interval1", "Layer 1", "float", -24, 12, -12.0, 1, "st", "Interval of the first layer"),
Param("level1", "Level 1", "float", -40, 6, -6.0, 0.5, "dB", "Level of the first layer"),
Param("interval2", "Layer 2", "float", -24, 12, -24.0, 1, "st", "Interval of the second layer"),
Param("level2", "Level 2", "float", -60, 6, -60.0, 0.5, "dB", "Level of the second layer (-60 = off)"))),
Block("robot", "Robot", "Ring modulator and metallic comb (Mr. Freeze's suit).", (
Param("ring_freq", "Ring freq", "float", 10, 1000, 80.0, 1, "Hz", "Ring modulator carrier frequency"),
Param("ring_mix", "Ring mix", "float", 0, 1, 0.5, 0.05, "", "0 = dry, 1 = fully modulated"),
Param("comb_ms", "Comb", "float", 0, 20, 0.0, 0.1, "ms", "Metallic comb delay (0 = off)"),
Param("comb_feedback", "Comb feedback", "float", 0, 0.95, 0.6, 0.05, "", "Ringing of the comb"))),
Block("glitch", "Glitch", "Short pieces of the sound repeated right after themselves, optionally at another "
"pitch (the Riddler's hacked broadcasts). Makes the line longer.", (
Param("rate", "Events", "float", 0, 20, 3.0, 0.5, "/10 s", "How many glitches per 10 seconds of audio"),
Param("length", "Length", "float", 20, 400, 120.0, 5, "ms", "Length of the repeated piece"),
Param("repeats", "Repeats", "int", 1, 6, 2, 1, "", "How many times a piece repeats"),
Param("pitch", "Pitch", "float", -12, 12, 5.0, 0.5, "st", "Pitch of the repeats (0 = same)"),
Param("seed", "Variation", "int", 0, 999, 1, 1, "", "Another number = glitches at other places"))),
Block("eq", "Band-pass EQ", "Low and high cut with a mid boost: radio, phone, phone microphone, TV speaker, "
"small speakers.", (
Param("low_cut", "Low cut", "float", 20, 2000, 300.0, 10, "Hz", "Frequencies below are removed"),
Param("high_cut", "High cut", "float", 500, 20000, 3400.0, 50, "Hz", "Frequencies above are removed"),
Param("slope", "Slope", "int", 6, 48, 24, 6, "dB/oct", "How steeply the cuts fall"),
Param("mid_freq", "Mid freq", "float", 200, 5000, 1500.0, 10, "Hz", "Centre of the boost"),
Param("mid_gain", "Mid gain", "float", -12, 18, 6.0, 0.5, "dB", "Boost (or cut) around the centre"),
Param("mid_q", "Mid Q", "float", 0.3, 5, 1.0, 0.1, "", "Width of the boost (higher = narrower)"))),
Block("distortion", "Distortion", "Speaker/radio distortion, clipping, low bit depth, phone codec.", (
Param("drive", "Drive", "float", 0, 40, 10.0, 0.5, "dB", "Soft saturation (0 = off)"),
Param("clip", "Clip", "float", -30, 0, 0.0, 0.5, "dB", "Hard clipping threshold (0 = off)"),
Param("bits", "Bits", "int", 4, 16, 16, 1, "", "Bit depth (16 = off)"),
Param("gsm", "Phone codec", "bool", 0, 1, False, 1, "", "GSM mobile phone codec"))),
Block("noise", "Noise", "Radio static or hiss under the sound.", (
Param("level", "Level", "float", -60, 0, -30.0, 0.5, "dB", "Level relative to the sound"),
Param("colour", "Colour", "float", 200, 8000, 2000.0, 50, "Hz", "Centre of the noise band"),
Param("crackle", "Crackle", "float", 0, 1, 0.0, 0.05, "", "Random clicks and pops"))),
Block("echo", "Echo", "Delay (PA announcements: about 120-170 ms; very short times widen the sound).", (
Param("time", "Time", "float", 1, 1000, 160.0, 1, "ms", "Delay time"),
Param("feedback", "Feedback", "float", 0, 0.9, 0.3, 0.05, "", "How many repeats"),
Param("mix", "Mix", "float", 0, 1, 0.35, 0.05, "", "Level of the echo"))),
Block("reverb", "Reverb", "Room reverb (a generated room response: early reflections and a tail that gets "
"darker as it fades), and an occasional reverse swell before phrases (Riddler, Ivy).", (
Param("decay", "Decay", "float", 0.1, 6, 0.6, 0.05, "s", "Reverb time (RT60): small room 0.3, "
"hall 2, church 4"),
Param("mix", "Mix", "float", 0, 1, 0.25, 0.05, "", "Dry sound against reverb: 0 = dry only, "
"1 = reverb only"),
Param("predelay", "Pre-delay", "float", 0, 150, 10.0, 1, "ms", "Gap before the reverb starts "
"(bigger spaces: longer)"),
Param("early", "Reflections", "float", 0, 1, 0.5, 0.05, "", "Early echoes from nearby walls"),
Param("damping", "Damping", "float", 0, 1, 0.5, 0.05, "", "How much darker the tail gets "
"(soft rooms high, tiled rooms low)"),
Param("width", "Width", "float", 0, 1, 1.0, 0.05, "", "Different reverb per channel (2+ channels)"),
Param("reverse", "Reverse", "float", 0, 1, 0.0, 0.05, "", "Level of the reverse swells (0 = off)"),
Param("reverse_every", "Reverse often", "float", 0, 1, 0.3, 0.05, "",
"Share of the phrases that get a reverse swell"),
Param("seed", "Variation", "int", 0, 999, 1, 1, "", "Another number = swells on other phrases"))),
Block("channels", "Channels", "Only for sounds with 2-5 channels (L R C SL SR): stereo width, pan over time, "
"the PA layout (dry sound in the centre, echo in the surrounds).", (
Param("width", "Width", "float", 0, 30, 0.0, 0.5, "ms", "Delay of the right side (0 = off)"),
Param("pan_rate", "Pan rate", "float", 0, 2, 0.0, 0.05, "Hz", "Pan over time (0 = off)"),
Param("pan_depth", "Pan depth", "float", 0, 1, 0.5, 0.05, "", "How far the sound moves"),
Param("front", "Front L/R", "float", -60, 0, -12.0, 0.5, "dB", "Front L/R level when there is a centre"),
Param("surround_delay", "Surround delay", "float", 0, 500, 160.0, 1, "ms",
"Echo delay of the surrounds (4-5 channels)"),
Param("surround", "Surround", "float", -60, 0, -9.0, 0.5, "dB", "Level of the surrounds"))),
)
BY_KEY = {b.key: b for b in BLOCKS}
# Starting points (values set in place; the user tunes them by ear). Per block: {block: [(name, values)]}.
PRESETS = {
"pitch": [("Down (Clayface)", {"shift": -5.0, "formant": 0.0, "drift": 0.0}),
("Deep, same voice", {"shift": -3.0, "formant": 2.0, "drift": 0.0}),
("Up", {"shift": 4.0, "formant": 0.0, "drift": 0.0}),
("Drift (Mr. Freeze)", {"shift": 0.0, "formant": 0.0, "drift": 1.5, "drift_rate": 0.35})],
"octaves": [("One octave down", {"interval1": -12.0, "level1": -4.0, "level2": -60.0}),
("Giant (two down)", {"interval1": -12.0, "level1": -3.0, "interval2": -24.0, "level2": -9.0}),
("Fifth down", {"interval1": -7.0, "level1": -8.0, "level2": -60.0})],
"robot": [("Suit (Mr. Freeze)", {"ring_freq": 60.0, "ring_mix": 0.3, "comb_ms": 4.0, "comb_feedback": 0.4}),
("Metallic", {"ring_freq": 30.0, "ring_mix": 0.1, "comb_ms": 9.0, "comb_feedback": 0.7}),
("Dalek", {"ring_freq": 30.0, "ring_mix": 1.0, "comb_ms": 0.0})],
"glitch": [("Light", {"rate": 2.0, "length": 90.0, "repeats": 1, "pitch": 0.0}),
("Hacked (Riddler)", {"rate": 4.0, "length": 120.0, "repeats": 2, "pitch": 5.0}),
("Heavy", {"rate": 8.0, "length": 70.0, "repeats": 3, "pitch": -7.0})],
"eq": [("Radio", {"low_cut": 400.0, "high_cut": 3200.0, "slope": 24, "mid_freq": 1800.0, "mid_gain": 6.0,
"mid_q": 1.0}),
("Phone", {"low_cut": 300.0, "high_cut": 3400.0, "slope": 36, "mid_freq": 1500.0, "mid_gain": 4.0,
"mid_q": 0.8}),
("Phone mic (tape)", {"low_cut": 200.0, "high_cut": 5000.0, "slope": 18, "mid_freq": 2500.0,
"mid_gain": 5.0, "mid_q": 0.7}),
("TV speaker", {"low_cut": 250.0, "high_cut": 6000.0, "slope": 18, "mid_freq": 1200.0, "mid_gain": 3.0,
"mid_q": 0.7}),
("Megaphone / PA", {"low_cut": 500.0, "high_cut": 3000.0, "slope": 30, "mid_freq": 2000.0,
"mid_gain": 8.0, "mid_q": 1.2})],
"distortion": [("Speaker", {"drive": 8.0, "clip": 0.0, "bits": 16, "gsm": False}),
("Overdriven radio", {"drive": 18.0, "clip": -6.0, "bits": 16, "gsm": False}),
("Phone codec", {"drive": 0.0, "clip": 0.0, "bits": 16, "gsm": True}),
("Lo-fi", {"drive": 4.0, "clip": 0.0, "bits": 8, "gsm": False})],
"noise": [("Radio static", {"level": -26.0, "colour": 2500.0, "crackle": 0.1}),
("Soft hiss", {"level": -36.0, "colour": 6000.0, "crackle": 0.0}),
("Crackle", {"level": -40.0, "colour": 1500.0, "crackle": 0.5})],
"echo": [("PA announcement", {"time": 160.0, "feedback": 0.3, "mix": 0.35}),
("Slapback", {"time": 90.0, "feedback": 0.0, "mix": 0.3}),
("Short (width)", {"time": 12.0, "feedback": 0.0, "mix": 0.3}),
("Canyon", {"time": 400.0, "feedback": 0.45, "mix": 0.3})],
"reverb": [("Small room", {"decay": 0.35, "mix": 0.15, "predelay": 3.0, "early": 0.6, "damping": 0.6}),
("Room", {"decay": 0.7, "mix": 0.18, "predelay": 8.0, "early": 0.5, "damping": 0.5}),
("Tiled / cell", {"decay": 1.0, "mix": 0.22, "predelay": 5.0, "early": 0.8, "damping": 0.15}),
("Hall", {"decay": 2.0, "mix": 0.22, "predelay": 25.0, "early": 0.4, "damping": 0.5}),
("Church", {"decay": 4.0, "mix": 0.26, "predelay": 40.0, "early": 0.3, "damping": 0.4}),
("Outdoors (city)", {"decay": 1.5, "mix": 0.15, "predelay": 60.0, "early": 0.15, "damping": 0.7})],
"channels": [("PA layout", {"width": 0.0, "pan_rate": 0.0, "front": -12.0, "surround_delay": 160.0,
"surround": -9.0}),
("Wide stereo", {"width": 12.0, "pan_rate": 0.0, "front": 0.0, "surround": -12.0,
"surround_delay": 20.0}),
("Slow pan (Mr. Freeze)", {"width": 0.0, "pan_rate": 0.25, "pan_depth": 0.6, "front": -6.0})],
}
# Whole presets (several blocks) for the sounds heard in the game (docs/history/mission4_dubbing_plan.md
# section 9): every other block off. (name, {block: preset name of PRESETS or values})
LOOKS = [
("Radio", {"eq": "Radio", "distortion": "Speaker", "noise": "Radio static"}),
("Phone", {"eq": "Phone", "distortion": "Phone codec"}),
("Tape / phone mic", {"eq": "Phone mic (tape)", "noise": "Soft hiss"}),
("TV in a room", {"eq": "TV speaker", "reverb": "Small room"}),
("PA announcement", {"eq": "Megaphone / PA", "echo": "PA announcement", "channels": "PA layout"}),
("Mr. Freeze", {"pitch": "Drift (Mr. Freeze)", "robot": "Suit (Mr. Freeze)", "channels": "Slow pan (Mr. Freeze)"}),
("Clayface", {"pitch": "Down (Clayface)"}),
("Riddler broadcast", {"glitch": "Hacked (Riddler)", "eq": "Phone", "echo": "Short (width)",
"reverb": {"decay": 0.6, "mix": 0.11, "reverse": 0.6, "reverse_every": 0.3}}),
("Giant (Ra's al Ghul)", {"octaves": "Giant (two down)", "pitch": {"formant": -3.0},
"reverb": "Hall"}),
("Car radio", {"eq": "Radio", "distortion": "Overdriven radio", "noise": "Radio static",
"pitch": {"formant": 3.0}}),
]
def preset(block, name):
"""Values of a block preset (without 'on')."""
return dict(next(v for n, v in PRESETS[block] if n == name))
def with_preset(settings, block, name):
"""Settings with `block` on and set to preset `name`."""
s = normalize(settings)
s[block].update(preset(block, name), on=True)
return normalize(s)
def look(name):
"""Settings of a whole preset (LOOKS): its blocks on (preset or values over the defaults), every other off."""
s = defaults()
for block, v in dict(LOOKS)[name].items():
s[block].update(preset(block, v) if isinstance(v, str) else v, on=True)
return normalize(s)
LAYOUTS = {2: "L R", 3: "L R C", 4: "L R SL SR", 5: "L R C SL SR", 6: "L R C LFE SL SR"} # WAVE channel masks
def defaults():
"""Every block off, every parameter at its default."""
return {b.key: dict({"on": False}, **{p.key: p.default for p in b.params}) for b in BLOCKS}
RENAMED = {("reverb", "mix"): "wet"} # parameter -> its name in settings of earlier versions
def normalize(settings):
"""Complete and clamp settings (unknown keys dropped), so old or hand-edited settings always render."""
out = defaults()
for b in BLOCKS:
s = (settings or {}).get(b.key) or {}
out[b.key]["on"] = bool(s.get("on", False))
for p in b.params:
v = s.get(p.key, s.get(RENAMED.get((b.key, p.key)), p.default))
try:
v = bool(v) if p.kind == "bool" else int(round(float(v))) if p.kind == "int" else float(v)
except (TypeError, ValueError):
v = p.default
out[b.key][p.key] = v if p.kind == "bool" else min(max(v, p.lo), p.hi)
return out
def active(settings):
return any((settings or {}).get(b.key, {}).get("on") for b in BLOCKS)
def summary(settings):
"""Short text of the blocks that are on, e.g. 'Pitch, Band-pass EQ'."""
return ", ".join(b.label for b in BLOCKS if (settings or {}).get(b.key, {}).get("on"))
# --- helpers -------------------------------------------------------------------------------------
def _db(v):
return 10 ** (v / 20.0)
def _pb(plugins, x, rate):
"""Run pedalboard plugins on (frames, channels) audio."""
import pedalboard
y = pedalboard.Pedalboard(list(plugins))(np.ascontiguousarray(x.T, dtype=np.float32), rate)
return y.T
def _shift(x, rate, semitones, formants=False):
"""Pitch shift without changing the length (Rubber Band); `semitones` may be a per-frame curve."""
import pedalboard
st = semitones if np.ndim(semitones) else float(semitones)
y = pedalboard.time_stretch(np.ascontiguousarray(x.T, dtype=np.float32), rate,
pitch_shift_in_semitones=st, preserve_formants=formants)
y = y.T
if len(y) != len(x): # same length as the input (Rubber Band may differ by a few frames)
y = y[:len(x)] if len(y) > len(x) else np.pad(y, ((0, len(x) - len(y)), (0, 0)))
return y
def _rms(x):
return float(np.sqrt(np.mean(np.square(x)))) if len(x) else 0.0
def _envelope_db(x, rate, hop_s=0.01):
hop = max(1, int(rate * hop_s))
m = x.mean(axis=1)
n = len(m) // hop
if n == 0:
return np.zeros(0)
return 10 * np.log10(np.mean(m[:n * hop].reshape(n, hop) ** 2, axis=1) + 1e-12)
def _phrase_starts(x, rate, gap=0.15):
"""Frame indices (in samples) where speech starts after at least `gap` seconds of quiet."""
env = _envelope_db(x, rate)
if not len(env):
return []
on = env > np.max(env) - 30
starts, quiet = [], int(gap / 0.01)
run = quiet # the start of the audio counts as after a pause
for i, a in enumerate(on):
if a and run >= quiet:
starts.append(int(i * 0.01 * rate))
run = 0 if a else run + 1
return starts
def _band(x, rate, low=None, high=None, order=4):
"""Zero-phase Butterworth high-pass (`low`) and/or low-pass (`high`) magnitude applied in the frequency domain
(exact slope of 6 dB/octave per order, flat pass band; pedalboard's cuts are first order only)."""
pad = int(0.2 * rate)
n = 1 << int(np.ceil(np.log2(len(x) + 2 * pad)))
f = np.fft.rfftfreq(n, 1 / rate)
h = np.ones_like(f)
with np.errstate(divide="ignore"):
if low:
h /= np.sqrt(1 + (low / np.maximum(f, 1e-9)) ** (2 * order))
if high and high < rate / 2:
h /= np.sqrt(1 + (f / high) ** (2 * order))
spec = np.fft.rfft(np.pad(x, ((pad, 0), (0, 0))), n, axis=0) * h[:, None]
return np.fft.irfft(spec, n, axis=0)[pad:pad + len(x)].astype(np.float32)
def _with_tail(fn, x, rate, seconds):
"""Run `fn` on x padded with `seconds` of silence, then drop the silent end (never shorter than x)."""
pad = np.zeros((int(seconds * rate), x.shape[1]), np.float32)
y = fn(np.concatenate([x, pad]))
lvl = np.max(np.abs(y), axis=1)
loud = np.flatnonzero(lvl > max(np.max(lvl, initial=0.0), 1e-9) * 10 ** (-60 / 20))
end = max(len(x), (loud[-1] + 1) if len(loud) else len(x))
return y[:end]
def _fade(seg, rate, ms=5):
n = min(len(seg) // 2, int(rate * ms / 1000))
if n:
r = np.linspace(0, 1, n, dtype=np.float32)[:, None]
seg[:n] *= r
seg[-n:] *= r[::-1]
return seg
# --- blocks --------------------------------------------------------------------------------------
def _pitch(x, rate, p):
if p["formant"]:
x = _shift(_shift(x, rate, p["formant"]), rate, -p["formant"], formants=True)
if p["drift"]:
t = np.arange(len(x)) / rate
return _shift(x, rate, p["shift"] + p["drift"] * np.sin(2 * np.pi * p["drift_rate"] * t))
return _shift(x, rate, p["shift"]) if p["shift"] else x
def _octaves(x, rate, p):
y = x.copy()
for i in (1, 2):
if p["level%d" % i] > -60 and p["interval%d" % i]:
y += _shift(x, rate, p["interval%d" % i]) * _db(p["level%d" % i])
return y
def _robot(x, rate, p):
t = (np.arange(len(x)) / rate)[:, None]
y = x * (1 - p["ring_mix"]) + x * np.sin(2 * np.pi * p["ring_freq"] * t) * p["ring_mix"]
if p["comb_ms"] > 0:
import pedalboard
y = _pb([pedalboard.Delay(p["comb_ms"] / 1000.0, p["comb_feedback"], 0.5)], y, rate)
return y.astype(np.float32)
def _glitch(x, rate, p):
n = int(round(p["rate"] * len(x) / rate / 10))
seg = int(rate * p["length"] / 1000)
if n <= 0 or seg < 8 or len(x) <= seg:
return x
env = _envelope_db(x, rate)
voiced = np.flatnonzero(env > np.max(env) - 25) * int(rate * 0.01) if len(env) else np.zeros(0, int)
voiced = voiced[voiced + seg < len(x)]
if not len(voiced):
return x
rng = np.random.default_rng(p["seed"])
at = np.unique(rng.choice(voiced, size=min(n, len(voiced)), replace=False))
parts, last = [], 0
for a in at:
if a < last:
continue
piece = _fade(x[a:a + seg].copy(), rate)
parts.append(x[last:a + seg])
for k in range(p["repeats"]):
parts.append(_fade(_shift(piece, rate, p["pitch"] * (k + 1)), rate) if p["pitch"] else piece)
last = a + seg
parts.append(x[last:])
return np.concatenate(parts).astype(np.float32)
def _eq(x, rate, p):
import pedalboard
y = _band(x, rate, p["low_cut"], p["high_cut"], max(1, int(p["slope"]) // 6))
if p["mid_gain"] and p["mid_freq"] < rate / 2 * 0.95:
y = _pb([pedalboard.PeakFilter(p["mid_freq"], p["mid_gain"], p["mid_q"])], y, rate)
return y
def _distortion(x, rate, p):
import pedalboard
plugins = []
if p["drive"] > 0:
plugins.append(pedalboard.Distortion(p["drive"]))
if p["clip"] < 0:
plugins.append(pedalboard.Clipping(p["clip"]))
if p["bits"] < 16:
plugins.append(pedalboard.Bitcrush(p["bits"]))
if p["gsm"]:
plugins.append(pedalboard.GSMFullRateCompressor())
if not plugins:
return x
y = _pb(plugins, x, rate)
r = _rms(y)
return y * (_rms(x) / r) if r > 0 else y # same loudness as before: only the colour changes
def _noise(x, rate, p):
rng = np.random.default_rng(0)
n = _band(rng.standard_normal((len(x), 1)).astype(np.float32), rate, p["colour"] / 2, p["colour"] * 2, 2)
if p["crackle"] > 0:
clicks = (rng.random(len(x)) < p["crackle"] * 20 / rate) * rng.uniform(-1, 1, len(x))
n = n / (_rms(n) or 1) + clicks[:, None].astype(np.float32) * 8
r = _rms(n)
return x + (n / r if r else n) * _rms(x) * _db(p["level"])
def _echo(x, rate, p):
import pedalboard
fb = p["feedback"]
tail = p["time"] / 1000 * (1 + (np.log(1e-3) / np.log(fb) if fb > 0.01 else 0))
return _with_tail(lambda a: _pb([pedalboard.Delay(p["time"] / 1000.0, fb, p["mix"])], a, rate),
x, rate, min(tail, 6.0))
REVERB_BANDS = ((0, 500), (500, 2000), (2000, 6000), (6000, None))
REVERB_DARK = (0.0, 0.3, 0.6, 0.85) # share of the decay a band loses at damping 1 (highs die first)
def room_response(rate, channels, decay, predelay=10.0, early=0.5, damping=0.5, width=1.0, seed=7):
"""Generated room impulse response -> float32 (frames, channels), energy 1 per channel: sparse early
reflections, then a dense exponentially decaying noise tail in 4 bands, the higher ones shorter with damping.
Each channel gets its own noise (decorrelated), mixed towards a common one as `width` goes to 0."""
rng = np.random.default_rng(seed)
n = int(rate * (decay * 1.2 + predelay / 1000)) + 1
t = np.arange(n) / rate
pre = int(rate * predelay / 1000)
def one(r):
tail = np.zeros(n, np.float32)
noise = r.standard_normal((n, 1)).astype(np.float32)
for (lo, hi), dark in zip(REVERB_BANDS, REVERB_DARK):
rt = max(0.05, decay * (1 - damping * dark))
tail += _band(noise, rate, lo or None, hi, 4)[:, 0] * 10 ** (-3 * t / rt)
tail *= np.minimum(1, t / 0.015) # the dense tail builds up over ~15 ms
er = np.zeros(n, np.float32)
span = 0.01 + 0.03 * min(decay, 2) # reflections within 10-70 ms
for k in range(12):
i = int(rate * r.uniform(0.002, span))
er[i] += r.choice((-1, 1)) * (1 - k / 14) * 3
h = tail / (np.sqrt(np.sum(tail ** 2)) or 1) + early * er / (np.sqrt(np.sum(er ** 2)) or 1)
return np.concatenate([np.zeros(pre, np.float32), h])[:n]
common = one(np.random.default_rng(seed + 1000))
out = np.stack([width * one(rng) + (1 - width) * common for _ in range(channels)], axis=1)
return (out / np.sqrt(np.sum(out ** 2, axis=0, keepdims=True)).clip(1e-9)).astype(np.float32)
def _convolve(x, ir):
"""Per channel FFT convolution (full length)."""
n = len(x) + len(ir) - 1
m = 1 << int(np.ceil(np.log2(n)))
return np.fft.irfft(np.fft.rfft(x, m, axis=0) * np.fft.rfft(ir, m, axis=0), m, axis=0)[:n].astype(np.float32)
def _reverb(x, rate, p):
ir = room_response(rate, x.shape[1], p["decay"], p["predelay"], p["early"], p["damping"], p["width"])
y = x
if p["mix"] > 0:
wet = _convolve(x, ir)
wet *= _rms(x) / (_rms(wet) or 1) # the reverb alone is as loud as the sound
a = p["mix"] * np.pi / 2 # equal-power crossfade: steady level at any mix
y = np.concatenate([x, np.zeros((len(wet) - len(x), x.shape[1]), np.float32)]) * np.cos(a)
y = y + wet * np.sin(a)
lvl = np.max(np.abs(y), axis=1) # drop the inaudible end of the tail
loud = np.flatnonzero(lvl > max(np.max(lvl, initial=0.0), 1e-9) * 10 ** (-60 / 20))
y = y[:max(len(x), loud[-1] + 1 if len(loud) else len(x))]
if p["reverse"] > 0:
rng = np.random.default_rng(p["seed"])
swell_ir = room_response(rate, x.shape[1], max(0.6, p["decay"]), 0, p["early"], p["damping"], p["width"])
y = y.copy()
for s in _phrase_starts(x, rate):
if rng.random() >= p["reverse_every"] or s < rate // 20:
continue
head = x[s:s + int(0.3 * rate)][::-1]
sw = _convolve(head, swell_ir)[::-1]
sw = sw[:len(sw) - len(head)] # the swell ends where the phrase starts
a = max(0, s - len(sw))
sw = sw[len(sw) - (s - a):]
peak = float(np.max(np.abs(sw), initial=0.0))
if peak > 0:
y[a:s] += sw / peak * float(np.max(np.abs(x[s:s + int(0.3 * rate)]), initial=0.0)) * p["reverse"]
return y
def _channels(x, rate, p, channels):
"""Mono sound -> `channels` outputs in WAVE order (LAYOUTS)."""
v = x.mean(axis=1)
names = LAYOUTS.get(channels, " ".join(["L"] * channels)).split()
t = np.arange(len(v)) / rate
pan = p["pan_depth"] * np.sin(2 * np.pi * p["pan_rate"] * t) if p["pan_rate"] > 0 else np.zeros(len(v))
ang = (pan + 1) * np.pi / 4 # equal power: centre = both sides at 1.0
g_left, g_right = np.cos(ang) * np.sqrt(2), np.sin(ang) * np.sqrt(2)
def delayed(sig, ms):
d = int(rate * ms / 1000)
return np.concatenate([np.zeros(d, np.float32), sig])[:len(sig)] if d else sig
front = _db(p["front"]) if "C" in names else 1.0
sur = delayed(v, p["surround_delay"]) * _db(p["surround"])
out = []
for nm in names:
out.append({"L": v * front * g_left, "R": delayed(v, p["width"]) * front * g_right, "C": v,
"LFE": np.zeros_like(v), "SL": sur * g_left, "SR": sur * g_right}[nm])
return np.stack(out, axis=1).astype(np.float32)
_ORDER = (("pitch", _pitch), ("octaves", _octaves), ("robot", _robot), ("glitch", _glitch), ("eq", _eq),
("distortion", _distortion), ("noise", _noise), ("echo", _echo), ("reverb", _reverb))
def render(x, rate, settings, channels=None):
"""Apply the blocks that are on -> float32 (frames, channels). `channels` = the target sound's channel count:
with the Channels block on and 2+ channels the result has that many channels in the game's order; otherwise
the channel count of `x` is kept (the encoder up/down-mixes as before)."""
s = normalize(settings)
x = np.asarray(x, dtype=np.float32)
x = x[:, None] if x.ndim == 1 else x
if not len(x):
return x
for key, fn in _ORDER:
if s[key]["on"]:
x = np.asarray(fn(x, rate, s[key]), dtype=np.float32)
if s["channels"]["on"] and channels and channels >= 2:
x = _channels(x, rate, s["channels"], channels)
return np.nan_to_num(x).astype(np.float32)