multiafx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multiafx/__init__.py +18 -0
- multiafx/_version.py +1 -0
- multiafx/apply.py +39 -0
- multiafx/chain.py +336 -0
- multiafx/effects/__init__.py +20 -0
- multiafx/effects/audiomentations_effects.py +293 -0
- multiafx/effects/librosa_effects.py +75 -0
- multiafx/effects/numpy_effects.py +50 -0
- multiafx/effects/pyloudnorm_effects.py +36 -0
- multiafx/effects/scipy_effects.py +154 -0
- multiafx/effects/sox_effects.py +283 -0
- multiafx/effects/torchaudio_effects.py +145 -0
- multiafx/registry.py +434 -0
- multiafx/types.py +63 -0
- multiafx-0.1.0.dist-info/METADATA +266 -0
- multiafx-0.1.0.dist-info/RECORD +19 -0
- multiafx-0.1.0.dist-info/WHEEL +5 -0
- multiafx-0.1.0.dist-info/licenses/LICENSE +21 -0
- multiafx-0.1.0.dist-info/top_level.txt +1 -0
multiafx/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""multiafx: multi-library audio effects with a unified chain-application API."""
|
|
2
|
+
|
|
3
|
+
from multiafx._version import __version__
|
|
4
|
+
from multiafx.apply import apply_chain
|
|
5
|
+
from multiafx.chain import FXChain, generate_random_chain
|
|
6
|
+
from multiafx.registry import registry
|
|
7
|
+
from multiafx.types import EffectDef, MacroCategory, ParamRange
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"__version__",
|
|
11
|
+
"FXChain",
|
|
12
|
+
"generate_random_chain",
|
|
13
|
+
"apply_chain",
|
|
14
|
+
"registry",
|
|
15
|
+
"EffectDef",
|
|
16
|
+
"MacroCategory",
|
|
17
|
+
"ParamRange",
|
|
18
|
+
]
|
multiafx/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
multiafx/apply.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Apply a chain of effects to an audio array."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from multiafx.registry import registry, INTEGER_PARAMS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_chain(audio: np.ndarray, sr: int, chain: list[dict]) -> np.ndarray:
|
|
9
|
+
"""Apply a sequence of effects to audio.
|
|
10
|
+
|
|
11
|
+
No error handling — the first failing effect raises. This is intentional:
|
|
12
|
+
if an effect fails we want the call site to know exactly which one.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
audio: (channels, samples) float32 array.
|
|
16
|
+
sr: Sample rate.
|
|
17
|
+
chain: List of dicts, each with keys ``effect`` (str) and ``params`` (dict).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Processed (channels, samples) float32 array, clipped to [-1, 1].
|
|
21
|
+
"""
|
|
22
|
+
x = audio.astype(np.float32)
|
|
23
|
+
|
|
24
|
+
for step in chain:
|
|
25
|
+
name = step["effect"]
|
|
26
|
+
params = dict(step.get("params", {}))
|
|
27
|
+
|
|
28
|
+
# Coerce integer-only params
|
|
29
|
+
for p in INTEGER_PARAMS:
|
|
30
|
+
if p in params:
|
|
31
|
+
params[p] = int(round(params[p]))
|
|
32
|
+
|
|
33
|
+
effect_def = registry.get(name)
|
|
34
|
+
x = effect_def.callable(x, sr, **params)
|
|
35
|
+
|
|
36
|
+
if not np.all(np.isfinite(x)):
|
|
37
|
+
raise ValueError(f"Effect {name!r} produced NaN/Inf output")
|
|
38
|
+
|
|
39
|
+
return np.clip(x, -1.0, 1.0).astype(np.float32)
|
multiafx/chain.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""FXChain: list-like container of effect dicts, pedalboard-style."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import pickle
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from multiafx.apply import apply_chain
|
|
18
|
+
from multiafx.registry import registry, INTEGER_PARAMS
|
|
19
|
+
from multiafx.types import MacroCategory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Process-wide lock + seed/restore for the FX chain's critical section.
|
|
23
|
+
# Several upstream effects (audiomentations.AddGaussianNoise,
|
|
24
|
+
# SevenBandParametricEQ, internal PeakingFilter / HighShelfFilter, etc.)
|
|
25
|
+
# consume the *global* ``np.random`` / ``random`` state inside their
|
|
26
|
+
# transforms — they don't accept a per-call RNG. Without serialization,
|
|
27
|
+
# two concurrent threads each calling ``np.random.seed(...)`` race on
|
|
28
|
+
# that single shared state and corrupt each other's "deterministic"
|
|
29
|
+
# output. The fix is to wrap the entire chain call in a lock so the
|
|
30
|
+
# (seed → effect → restore) sequence is atomic across threads.
|
|
31
|
+
#
|
|
32
|
+
# FluidSynth rendering and other CPU-heavy work happen *outside* this
|
|
33
|
+
# lock — only the FX chain itself is serialized.
|
|
34
|
+
_RNG_LOCK = threading.Lock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def _seeded_globals(seed: int | None):
|
|
39
|
+
"""Save the global RNG state, seed it from ``seed``, restore on exit.
|
|
40
|
+
|
|
41
|
+
When ``seed is None``, this is a no-op (no lock, no state change)
|
|
42
|
+
and the call behaves like vanilla audiomentations / sox.
|
|
43
|
+
"""
|
|
44
|
+
if seed is None:
|
|
45
|
+
yield
|
|
46
|
+
return
|
|
47
|
+
with _RNG_LOCK:
|
|
48
|
+
np_state = np.random.get_state()
|
|
49
|
+
py_state = random.getstate()
|
|
50
|
+
np.random.seed(int(seed) & 0xFFFFFFFF)
|
|
51
|
+
random.seed(int(seed))
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
np.random.set_state(np_state)
|
|
56
|
+
random.setstate(py_state)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FXChain:
|
|
60
|
+
"""An ordered list of audio effects applied in sequence.
|
|
61
|
+
|
|
62
|
+
Each step is a dict: ``{"effect": <name>, "params": {...}}``.
|
|
63
|
+
|
|
64
|
+
Construction::
|
|
65
|
+
|
|
66
|
+
chain = FXChain([
|
|
67
|
+
{"effect": "sox_highpass", "params": {"frequency": 80.0, "width_q": 0.7}},
|
|
68
|
+
{"effect": "sox_compand", "params": {"attack_time": 0.005,
|
|
69
|
+
"decay_time": 0.1,
|
|
70
|
+
"soft_knee_db": 6.0}},
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
Loading from files::
|
|
74
|
+
|
|
75
|
+
chain = FXChain.load("my_chain.yaml") # auto-detect by extension
|
|
76
|
+
chain = FXChain.from_json("my_chain.json")
|
|
77
|
+
|
|
78
|
+
Application (pedalboard-style)::
|
|
79
|
+
|
|
80
|
+
out = chain(audio, 44100) # __call__ shorthand
|
|
81
|
+
out = chain.process(audio, 44100) # explicit
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("_steps",)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Construction
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
def __init__(self, steps: list[dict] | None = None):
|
|
90
|
+
self._steps: list[dict] = []
|
|
91
|
+
if steps is not None:
|
|
92
|
+
for step in steps:
|
|
93
|
+
self._steps.append(self._validate_step(step))
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _validate_step(step: dict) -> dict:
|
|
97
|
+
"""Check a step has the right shape. Returns a shallow copy."""
|
|
98
|
+
if not isinstance(step, dict):
|
|
99
|
+
raise TypeError(f"Each step must be a dict, got {type(step).__name__}")
|
|
100
|
+
if "effect" not in step:
|
|
101
|
+
raise KeyError(f"Step missing 'effect' key: {step!r}")
|
|
102
|
+
name = step["effect"]
|
|
103
|
+
if name not in registry:
|
|
104
|
+
raise KeyError(f"Unknown effect: {name!r}")
|
|
105
|
+
params = step.get("params", {})
|
|
106
|
+
if not isinstance(params, dict):
|
|
107
|
+
raise TypeError(f"'params' must be a dict, got {type(params).__name__}")
|
|
108
|
+
return {"effect": name, "params": dict(params)}
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Loaders
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
@classmethod
|
|
114
|
+
def load(cls, source: str | Path | list | dict) -> "FXChain":
|
|
115
|
+
"""Load from a path (json/yaml/pkl) or from a Python object (list/dict).
|
|
116
|
+
|
|
117
|
+
If given a ``list`` it's treated as the steps directly.
|
|
118
|
+
If given a ``dict`` it must have a ``chain`` or ``steps`` key.
|
|
119
|
+
Paths are dispatched by extension: .json, .yaml, .yml, .pkl, .pickle.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(source, list):
|
|
122
|
+
return cls(source)
|
|
123
|
+
if isinstance(source, dict):
|
|
124
|
+
for key in ("chain", "steps"):
|
|
125
|
+
if key in source:
|
|
126
|
+
return cls(source[key])
|
|
127
|
+
raise KeyError("dict source must contain 'chain' or 'steps' key")
|
|
128
|
+
|
|
129
|
+
path = Path(source)
|
|
130
|
+
suffix = path.suffix.lower()
|
|
131
|
+
if suffix == ".json":
|
|
132
|
+
return cls.from_json(path)
|
|
133
|
+
if suffix in (".yaml", ".yml"):
|
|
134
|
+
return cls.from_yaml(path)
|
|
135
|
+
if suffix in (".pkl", ".pickle"):
|
|
136
|
+
return cls.from_pickle(path)
|
|
137
|
+
raise ValueError(f"Unsupported file extension: {suffix!r}. "
|
|
138
|
+
f"Use .json, .yaml, .yml, .pkl, or .pickle.")
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_json(cls, path: str | Path) -> "FXChain":
|
|
142
|
+
with open(path, "r") as f:
|
|
143
|
+
data = json.load(f)
|
|
144
|
+
return cls(data if isinstance(data, list) else data["chain"])
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_yaml(cls, path: str | Path) -> "FXChain":
|
|
148
|
+
with open(path, "r") as f:
|
|
149
|
+
data = yaml.safe_load(f)
|
|
150
|
+
return cls(data if isinstance(data, list) else data["chain"])
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_pickle(cls, path: str | Path) -> "FXChain":
|
|
154
|
+
with open(path, "rb") as f:
|
|
155
|
+
data = pickle.load(f)
|
|
156
|
+
if isinstance(data, cls):
|
|
157
|
+
return data
|
|
158
|
+
return cls(data if isinstance(data, list) else data["chain"])
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Savers
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
def to_list(self) -> list[dict]:
|
|
164
|
+
"""Return a deep-copy list of steps, safe to serialize."""
|
|
165
|
+
return [{"effect": s["effect"], "params": dict(s["params"])} for s in self._steps]
|
|
166
|
+
|
|
167
|
+
def to_json(self, path: str | Path, *, indent: int = 2) -> None:
|
|
168
|
+
with open(path, "w") as f:
|
|
169
|
+
json.dump(self.to_list(), f, indent=indent)
|
|
170
|
+
|
|
171
|
+
def to_yaml(self, path: str | Path) -> None:
|
|
172
|
+
with open(path, "w") as f:
|
|
173
|
+
yaml.safe_dump(self.to_list(), f, sort_keys=False)
|
|
174
|
+
|
|
175
|
+
def to_pickle(self, path: str | Path) -> None:
|
|
176
|
+
with open(path, "wb") as f:
|
|
177
|
+
pickle.dump(self.to_list(), f)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Rendering
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
def process(self, audio: np.ndarray, sr: int,
|
|
183
|
+
seed: int | None = None) -> np.ndarray:
|
|
184
|
+
"""Apply every effect in sequence and return processed audio.
|
|
185
|
+
|
|
186
|
+
If ``seed`` is set, the global ``np.random`` / ``random`` state
|
|
187
|
+
is pinned to that value for the duration of the chain call and
|
|
188
|
+
restored on exit, under a process-wide lock — so concurrent
|
|
189
|
+
callers get bit-deterministic output regardless of thread
|
|
190
|
+
interleaving. When ``seed is None``, behaviour matches
|
|
191
|
+
vanilla audiomentations / sox (i.e., process-global RNG state
|
|
192
|
+
carries through; non-deterministic across threads).
|
|
193
|
+
"""
|
|
194
|
+
with _seeded_globals(seed):
|
|
195
|
+
return apply_chain(audio, sr, self._steps)
|
|
196
|
+
|
|
197
|
+
def __call__(self, audio: np.ndarray, sr: int,
|
|
198
|
+
seed: int | None = None) -> np.ndarray:
|
|
199
|
+
return self.process(audio, sr, seed=seed)
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# List-like API
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
def __len__(self) -> int:
|
|
205
|
+
return len(self._steps)
|
|
206
|
+
|
|
207
|
+
def __iter__(self):
|
|
208
|
+
return iter(self._steps)
|
|
209
|
+
|
|
210
|
+
def __getitem__(self, idx):
|
|
211
|
+
if isinstance(idx, slice):
|
|
212
|
+
return FXChain(self._steps[idx])
|
|
213
|
+
return self._steps[idx]
|
|
214
|
+
|
|
215
|
+
def __setitem__(self, idx: int, step: dict) -> None:
|
|
216
|
+
self._steps[idx] = self._validate_step(step)
|
|
217
|
+
|
|
218
|
+
def __delitem__(self, idx) -> None:
|
|
219
|
+
del self._steps[idx]
|
|
220
|
+
|
|
221
|
+
def append(self, step: dict) -> None:
|
|
222
|
+
self._steps.append(self._validate_step(step))
|
|
223
|
+
|
|
224
|
+
def insert(self, idx: int, step: dict) -> None:
|
|
225
|
+
self._steps.insert(idx, self._validate_step(step))
|
|
226
|
+
|
|
227
|
+
def pop(self, idx: int = -1) -> dict:
|
|
228
|
+
return self._steps.pop(idx)
|
|
229
|
+
|
|
230
|
+
def clear(self) -> None:
|
|
231
|
+
self._steps.clear()
|
|
232
|
+
|
|
233
|
+
def extend(self, steps: Iterable[dict]) -> None:
|
|
234
|
+
for s in steps:
|
|
235
|
+
self.append(s)
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Helpers
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
def add(self, effect: str, **params) -> "FXChain":
|
|
241
|
+
"""Append an effect by name with params as kwargs. Returns self for chaining."""
|
|
242
|
+
self.append({"effect": effect, "params": params})
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def effects(self) -> list[str]:
|
|
247
|
+
"""Ordered list of effect names."""
|
|
248
|
+
return [s["effect"] for s in self._steps]
|
|
249
|
+
|
|
250
|
+
def categories(self) -> list[MacroCategory]:
|
|
251
|
+
"""Macro category of each step, in order."""
|
|
252
|
+
return [registry.get(s["effect"]).macro_category for s in self._steps]
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Equality & representation
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
def __eq__(self, other: Any) -> bool:
|
|
258
|
+
if not isinstance(other, FXChain):
|
|
259
|
+
return NotImplemented
|
|
260
|
+
return self._steps == other._steps
|
|
261
|
+
|
|
262
|
+
def __repr__(self) -> str:
|
|
263
|
+
return f"FXChain(num_effects={len(self._steps)})"
|
|
264
|
+
|
|
265
|
+
def __str__(self) -> str:
|
|
266
|
+
if not self._steps:
|
|
267
|
+
return "FXChain([])"
|
|
268
|
+
lines = ["FXChain(["]
|
|
269
|
+
for step in self._steps:
|
|
270
|
+
lines.append(f" {step!r},")
|
|
271
|
+
lines.append("])")
|
|
272
|
+
return "\n".join(lines)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Chain generation
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
def generate_random_chain(
|
|
279
|
+
num_fx: int | tuple[int, int],
|
|
280
|
+
seed: int | None = None,
|
|
281
|
+
*,
|
|
282
|
+
exclude_effects: Iterable[str] = (),
|
|
283
|
+
exclude_categories: Iterable[str | MacroCategory] = (),
|
|
284
|
+
no_consecutive_same_category: bool = True,
|
|
285
|
+
) -> FXChain:
|
|
286
|
+
"""Generate a random FXChain.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
num_fx: Number of effects, or (min, max) for a random count.
|
|
290
|
+
seed: RNG seed for reproducibility.
|
|
291
|
+
exclude_effects: Effect names to skip.
|
|
292
|
+
exclude_categories: Macro categories to skip entirely.
|
|
293
|
+
no_consecutive_same_category: If True, the same macro category can't
|
|
294
|
+
appear in two consecutive slots (e.g. avoids EQ→EQ).
|
|
295
|
+
"""
|
|
296
|
+
rng = random.Random(seed)
|
|
297
|
+
|
|
298
|
+
excluded_cats = {MacroCategory(c) if isinstance(c, str) else c for c in exclude_categories}
|
|
299
|
+
excluded_names = set(exclude_effects)
|
|
300
|
+
|
|
301
|
+
available = [
|
|
302
|
+
eff for eff in registry
|
|
303
|
+
if eff.name not in excluded_names and eff.macro_category not in excluded_cats
|
|
304
|
+
]
|
|
305
|
+
if not available:
|
|
306
|
+
raise ValueError("No effects remain after exclusions.")
|
|
307
|
+
|
|
308
|
+
if isinstance(num_fx, tuple):
|
|
309
|
+
n = rng.randint(int(num_fx[0]), int(num_fx[1]))
|
|
310
|
+
else:
|
|
311
|
+
n = int(num_fx)
|
|
312
|
+
|
|
313
|
+
chain = FXChain()
|
|
314
|
+
prev_cat: MacroCategory | None = None
|
|
315
|
+
for _ in range(n):
|
|
316
|
+
pool = available
|
|
317
|
+
if no_consecutive_same_category and prev_cat is not None:
|
|
318
|
+
pool = [e for e in available if e.macro_category != prev_cat]
|
|
319
|
+
if not pool:
|
|
320
|
+
pool = available
|
|
321
|
+
|
|
322
|
+
eff = rng.choice(pool)
|
|
323
|
+
params = {}
|
|
324
|
+
for pname, prange in eff.param_ranges.items():
|
|
325
|
+
if prange.log_scale:
|
|
326
|
+
val = math.exp(rng.uniform(math.log(prange.min_val), math.log(prange.max_val)))
|
|
327
|
+
else:
|
|
328
|
+
val = rng.uniform(prange.min_val, prange.max_val)
|
|
329
|
+
if pname in INTEGER_PARAMS:
|
|
330
|
+
val = int(round(val))
|
|
331
|
+
params[pname] = val
|
|
332
|
+
|
|
333
|
+
chain.append({"effect": eff.name, "params": params})
|
|
334
|
+
prev_cat = eff.macro_category
|
|
335
|
+
|
|
336
|
+
return chain
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Effect implementations grouped by source library.
|
|
2
|
+
|
|
3
|
+
Each module exposes plain functions with signature:
|
|
4
|
+
(audio: np.ndarray, sr: int, **params) -> np.ndarray
|
|
5
|
+
|
|
6
|
+
Audio is always (channels, samples) float32. Functions return the same shape
|
|
7
|
+
unless the effect inherently changes channel count (e.g. stereo synthesis).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from multiafx.effects import (
|
|
11
|
+
audiomentations_effects as am,
|
|
12
|
+
librosa_effects as lib,
|
|
13
|
+
numpy_effects as npy,
|
|
14
|
+
pyloudnorm_effects as pln,
|
|
15
|
+
scipy_effects as sci,
|
|
16
|
+
sox_effects as sx,
|
|
17
|
+
torchaudio_effects as ta,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["am", "lib", "npy", "pln", "sci", "sx", "ta"]
|