"""Audio post-FX bus for the Un-Language Slider. Closes the dry-TTS-sounds-like-a-phone-call gap with pedalboard DSP — NOT a Fraser claim, just a way to keep the toy from undermining itself. All effects are toggleable in the UI; default preset is gentle ('lush'). All processing is done on a stereo signal. Effects (in order): 1. Gain stage 2. Light chorus (modulation) 3. Slap delay (short stereo delay, low feedback) 4. Plate reverb (long tail, ~50% wet) 5. Octave-up self-layer mixed under (subtle harmonics) API: from scripts.post_fx import apply_post_fx y_wet, sr = apply_post_fx(y, sr, preset="lush", octave_mix=0.18) """ from __future__ import annotations import numpy as np PRESETS = { "dry": {"chorus": 0.0, "delay": 0.0, "reverb": 0.0, "octave_mix": 0.0}, "subtle": {"chorus": 0.20, "delay": 0.10, "reverb": 0.25, "octave_mix": 0.08}, "lush": {"chorus": 0.35, "delay": 0.15, "reverb": 0.50, "octave_mix": 0.18}, "cathedral": {"chorus": 0.30, "delay": 0.20, "reverb": 0.75, "octave_mix": 0.22}, } def _to_stereo(y: np.ndarray) -> np.ndarray: if y.ndim == 1: return np.stack([y, y], axis=0) return y def _pitch_shift(y: np.ndarray, sr: int, semitones: float) -> np.ndarray: """Phase-vocoder pitch shift via librosa (kept dependency-light).""" import librosa if y.ndim == 2: return np.stack([librosa.effects.pitch_shift(y=y[c], sr=sr, n_steps=semitones) for c in range(y.shape[0])], axis=0) return librosa.effects.pitch_shift(y=y, sr=sr, n_steps=semitones) def apply_post_fx(y: np.ndarray, sr: int, preset: str = "subtle", chorus: float | None = None, delay: float | None = None, reverb: float | None = None, octave_mix: float | None = None) -> tuple[np.ndarray, int]: """Apply the post-FX bus. Returns (wet_stereo, sr).""" p = PRESETS.get(preset, PRESETS["subtle"]).copy() if chorus is not None: p["chorus"] = chorus if delay is not None: p["delay"] = delay if reverb is not None: p["reverb"] = reverb if octave_mix is not None: p["octave_mix"] = octave_mix from pedalboard import Pedalboard, Chorus, Delay, Reverb, Gain y = _to_stereo(np.asarray(y, dtype=np.float32)) # 1. octave-up self-layer mixed under (subtle harmonic) if p["octave_mix"] > 0: y_oct = _pitch_shift(y, sr, semitones=12.0).astype(np.float32) # length-match (pitch_shift preserves length for librosa) L = min(y.shape[-1], y_oct.shape[-1]) y = y[..., :L] + p["octave_mix"] * y_oct[..., :L] # 2. effect chain via pedalboard (operates on float32 stereo of shape [2, N]) board_fx = [Gain(gain_db=-1.0)] if p["chorus"] > 0: board_fx.append(Chorus(rate_hz=0.8, depth=0.25, centre_delay_ms=8.0, feedback=0.0, mix=float(p["chorus"]))) if p["delay"] > 0: board_fx.append(Delay(delay_seconds=0.11, feedback=0.15, mix=float(p["delay"]))) if p["reverb"] > 0: board_fx.append(Reverb(room_size=0.8, damping=0.35, wet_level=float(p["reverb"]), dry_level=1.0 - 0.5 * float(p["reverb"]), width=1.0)) board = Pedalboard(board_fx) # pedalboard expects float32, shape (channels, samples) wet = board(y.astype(np.float32), sample_rate=sr) # soft clip to avoid peaks peak = float(np.max(np.abs(wet))) if wet.size else 1.0 if peak > 0.98: wet = wet * (0.98 / peak) return wet, sr def main(): import argparse import soundfile as sf p = argparse.ArgumentParser() p.add_argument("--in", dest="inp", required=True) p.add_argument("--out", required=True) p.add_argument("--preset", default="lush", choices=list(PRESETS.keys())) args = p.parse_args() y, sr = sf.read(args.inp, always_2d=False) if y.ndim == 2: y = y.T # soundfile gives (samples, channels); we want (channels, samples) wet, sr = apply_post_fx(y, sr, preset=args.preset) sf.write(args.out, wet.T, sr) print(f"wrote {args.out} (preset={args.preset}, sr={sr}, dur={wet.shape[-1]/sr:.2f}s)") if __name__ == "__main__": main()