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:
@@ -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"
|
||||
|
||||
16
ackit/cli.py
16
ackit/cli.py
@@ -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
508
ackit/fx.py
Normal 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)
|
||||
Reference in New Issue
Block a user