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 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"]