Initial commit
This commit is contained in:
373
backend/utils/effects.py
Normal file
373
backend/utils/effects.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Audio post-processing effects engine.
|
||||
|
||||
Uses Spotify's pedalboard library to apply professional-grade DSP effects
|
||||
to generated audio. Effects are described as a JSON-serializable chain
|
||||
(list of effect dicts) so they can be stored in the database and sent
|
||||
over the API.
|
||||
|
||||
Supported effect types:
|
||||
- chorus (flanger-style with short delays)
|
||||
- reverb (room reverb)
|
||||
- delay (echo / delay line)
|
||||
- compressor (dynamic range compression)
|
||||
- gain (volume adjustment in dB)
|
||||
- highpass (high-pass filter)
|
||||
- lowpass (low-pass filter)
|
||||
- pitch_shift (semitone pitch shifting)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pedalboard import (
|
||||
Pedalboard,
|
||||
Chorus,
|
||||
Reverb,
|
||||
Compressor,
|
||||
Gain,
|
||||
HighpassFilter,
|
||||
LowpassFilter,
|
||||
Delay,
|
||||
PitchShift,
|
||||
)
|
||||
|
||||
|
||||
# Each param definition: (default, min, max, description)
|
||||
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
"chorus": {
|
||||
"cls": Chorus,
|
||||
"label": "Chorus / Flanger",
|
||||
"description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
|
||||
"params": {
|
||||
"rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
|
||||
"depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
|
||||
"feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
|
||||
"centre_delay_ms": {
|
||||
"default": 7.0,
|
||||
"min": 0.5,
|
||||
"max": 50.0,
|
||||
"step": 0.1,
|
||||
"description": "Centre delay (ms)",
|
||||
},
|
||||
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
|
||||
},
|
||||
},
|
||||
"reverb": {
|
||||
"cls": Reverb,
|
||||
"label": "Reverb",
|
||||
"description": "Room reverb effect.",
|
||||
"params": {
|
||||
"room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
|
||||
"damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
|
||||
"wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
|
||||
"dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
|
||||
"width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
|
||||
},
|
||||
},
|
||||
"delay": {
|
||||
"cls": Delay,
|
||||
"label": "Delay",
|
||||
"description": "Echo / delay line.",
|
||||
"params": {
|
||||
"delay_seconds": {
|
||||
"default": 0.3,
|
||||
"min": 0.01,
|
||||
"max": 2.0,
|
||||
"step": 0.01,
|
||||
"description": "Delay time (seconds)",
|
||||
},
|
||||
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
|
||||
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
|
||||
},
|
||||
},
|
||||
"compressor": {
|
||||
"cls": Compressor,
|
||||
"label": "Compressor",
|
||||
"description": "Dynamic range compression for consistent loudness.",
|
||||
"params": {
|
||||
"threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
|
||||
"ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
|
||||
"attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
|
||||
"release_ms": {
|
||||
"default": 100.0,
|
||||
"min": 10.0,
|
||||
"max": 1000.0,
|
||||
"step": 1.0,
|
||||
"description": "Release time (ms)",
|
||||
},
|
||||
},
|
||||
},
|
||||
"gain": {
|
||||
"cls": Gain,
|
||||
"label": "Gain",
|
||||
"description": "Volume adjustment in decibels.",
|
||||
"params": {
|
||||
"gain_db": {"default": 0.0, "min": -40.0, "max": 40.0, "step": 0.5, "description": "Gain (dB)"},
|
||||
},
|
||||
},
|
||||
"highpass": {
|
||||
"cls": HighpassFilter,
|
||||
"label": "High-Pass Filter",
|
||||
"description": "Removes frequencies below the cutoff.",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": {
|
||||
"default": 80.0,
|
||||
"min": 20.0,
|
||||
"max": 8000.0,
|
||||
"step": 1.0,
|
||||
"description": "Cutoff frequency (Hz)",
|
||||
},
|
||||
},
|
||||
},
|
||||
"lowpass": {
|
||||
"cls": LowpassFilter,
|
||||
"label": "Low-Pass Filter",
|
||||
"description": "Removes frequencies above the cutoff.",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": {
|
||||
"default": 8000.0,
|
||||
"min": 200.0,
|
||||
"max": 20000.0,
|
||||
"step": 1.0,
|
||||
"description": "Cutoff frequency (Hz)",
|
||||
},
|
||||
},
|
||||
},
|
||||
"pitch_shift": {
|
||||
"cls": PitchShift,
|
||||
"label": "Pitch Shift",
|
||||
"description": "Shift pitch up or down by semitones.",
|
||||
"params": {
|
||||
"semitones": {"default": 0.0, "min": -12.0, "max": 12.0, "step": 0.5, "description": "Semitones to shift"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"robotic": {
|
||||
"name": "Robotic",
|
||||
"sort_order": 0,
|
||||
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)",
|
||||
"effects_chain": [
|
||||
{
|
||||
"type": "chorus",
|
||||
"enabled": True,
|
||||
"params": {
|
||||
"rate_hz": 0.2,
|
||||
"depth": 1.0,
|
||||
"feedback": 0.35,
|
||||
"centre_delay_ms": 7.0,
|
||||
"mix": 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio",
|
||||
"sort_order": 1,
|
||||
"description": "Thin AM-radio voice with band-pass filtering and light compression",
|
||||
"effects_chain": [
|
||||
{
|
||||
"type": "highpass",
|
||||
"enabled": True,
|
||||
"params": {"cutoff_frequency_hz": 300.0},
|
||||
},
|
||||
{
|
||||
"type": "lowpass",
|
||||
"enabled": True,
|
||||
"params": {"cutoff_frequency_hz": 3500.0},
|
||||
},
|
||||
{
|
||||
"type": "compressor",
|
||||
"enabled": True,
|
||||
"params": {
|
||||
"threshold_db": -15.0,
|
||||
"ratio": 6.0,
|
||||
"attack_ms": 5.0,
|
||||
"release_ms": 50.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "gain",
|
||||
"enabled": True,
|
||||
"params": {"gain_db": 6.0},
|
||||
},
|
||||
],
|
||||
},
|
||||
"echo_chamber": {
|
||||
"name": "Echo Chamber",
|
||||
"sort_order": 2,
|
||||
"description": "Spacious reverb with trailing echo",
|
||||
"effects_chain": [
|
||||
{
|
||||
"type": "reverb",
|
||||
"enabled": True,
|
||||
"params": {
|
||||
"room_size": 0.85,
|
||||
"damping": 0.3,
|
||||
"wet_level": 0.45,
|
||||
"dry_level": 0.55,
|
||||
"width": 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "delay",
|
||||
"enabled": True,
|
||||
"params": {
|
||||
"delay_seconds": 0.25,
|
||||
"feedback": 0.3,
|
||||
"mix": 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"deep_voice": {
|
||||
"name": "Deep Voice",
|
||||
"sort_order": 99,
|
||||
"description": "Lower pitch with added warmth",
|
||||
"effects_chain": [
|
||||
{
|
||||
"type": "pitch_shift",
|
||||
"enabled": True,
|
||||
"params": {"semitones": -3.0},
|
||||
},
|
||||
{
|
||||
"type": "lowpass",
|
||||
"enabled": True,
|
||||
"params": {"cutoff_frequency_hz": 6000.0},
|
||||
},
|
||||
{
|
||||
"type": "compressor",
|
||||
"enabled": True,
|
||||
"params": {
|
||||
"threshold_db": -18.0,
|
||||
"ratio": 3.0,
|
||||
"attack_ms": 10.0,
|
||||
"release_ms": 150.0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_available_effects() -> List[Dict[str, Any]]:
|
||||
"""Return the list of available effect types with their parameter definitions.
|
||||
|
||||
Used by the frontend to build the effects chain editor UI.
|
||||
"""
|
||||
result = []
|
||||
for effect_type, info in EFFECT_REGISTRY.items():
|
||||
result.append(
|
||||
{
|
||||
"type": effect_type,
|
||||
"label": info["label"],
|
||||
"description": info["description"],
|
||||
"params": {name: {k: v for k, v in pdef.items()} for name, pdef in info["params"].items()},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return all built-in effect presets."""
|
||||
return BUILTIN_PRESETS
|
||||
|
||||
|
||||
def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Validate an effects chain configuration.
|
||||
|
||||
Returns None if valid, or an error message string.
|
||||
"""
|
||||
if not isinstance(effects_chain, list):
|
||||
return "effects_chain must be a list"
|
||||
|
||||
for i, effect in enumerate(effects_chain):
|
||||
if not isinstance(effect, dict):
|
||||
return f"Effect at index {i} must be a dict"
|
||||
|
||||
effect_type = effect.get("type")
|
||||
if effect_type not in EFFECT_REGISTRY:
|
||||
return f"Unknown effect type '{effect_type}' at index {i}. Available: {list(EFFECT_REGISTRY.keys())}"
|
||||
|
||||
params = effect.get("params", {})
|
||||
if not isinstance(params, dict):
|
||||
return f"Effect '{effect_type}' at index {i}: params must be a dict"
|
||||
|
||||
registry = EFFECT_REGISTRY[effect_type]
|
||||
for param_name, value in params.items():
|
||||
if param_name not in registry["params"]:
|
||||
return f"Effect '{effect_type}' at index {i}: unknown param '{param_name}'"
|
||||
|
||||
pdef = registry["params"][param_name]
|
||||
if not isinstance(value, (int, float)):
|
||||
return f"Effect '{effect_type}' at index {i}: param '{param_name}' must be a number"
|
||||
if value < pdef["min"] or value > pdef["max"]:
|
||||
return (
|
||||
f"Effect '{effect_type}' at index {i}: param '{param_name}' "
|
||||
f"must be between {pdef['min']} and {pdef['max']} (got {value})"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
|
||||
"""Build a Pedalboard instance from an effects chain config.
|
||||
|
||||
Skips effects where ``enabled`` is ``False``.
|
||||
"""
|
||||
plugins = []
|
||||
for effect in effects_chain:
|
||||
if not effect.get("enabled", True):
|
||||
continue
|
||||
|
||||
effect_type = effect["type"]
|
||||
registry = EFFECT_REGISTRY[effect_type]
|
||||
cls = registry["cls"]
|
||||
|
||||
# Merge defaults with provided params
|
||||
params = {}
|
||||
for pname, pdef in registry["params"].items():
|
||||
params[pname] = effect.get("params", {}).get(pname, pdef["default"])
|
||||
|
||||
plugins.append(cls(**params))
|
||||
|
||||
return Pedalboard(plugins)
|
||||
|
||||
|
||||
def apply_effects(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int,
|
||||
effects_chain: List[Dict[str, Any]],
|
||||
) -> np.ndarray:
|
||||
"""Apply an effects chain to audio data.
|
||||
|
||||
Args:
|
||||
audio: Input audio array (1-D mono float32).
|
||||
sample_rate: Sample rate in Hz.
|
||||
effects_chain: List of effect configuration dicts.
|
||||
|
||||
Returns:
|
||||
Processed audio array.
|
||||
"""
|
||||
if not effects_chain:
|
||||
return audio
|
||||
|
||||
board = build_pedalboard(effects_chain)
|
||||
|
||||
# pedalboard expects shape (channels, samples)
|
||||
if audio.ndim == 1:
|
||||
audio_2d = audio[np.newaxis, :]
|
||||
else:
|
||||
audio_2d = audio
|
||||
|
||||
processed = board(audio_2d.astype(np.float32), sample_rate)
|
||||
|
||||
# Return same dimensionality as input
|
||||
if audio.ndim == 1:
|
||||
return processed[0]
|
||||
return processed
|
||||
Reference in New Issue
Block a user