fix: add sample_rate and block_size properties to AudioPipeline

_gather_channel_state() was accessing pl.sample_rate but AudioPipeline
only had private _sample_rate. Added public properties so the /api/state
endpoint doesn't crash with AttributeError.
This commit is contained in:
2026-06-17 21:36:33 -04:00
parent 1603bfeea0
commit c65e4816c4
+246 -151
View File
@@ -15,6 +15,7 @@ same effect type at different positions in the chain.
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@@ -230,14 +231,14 @@ class _CombFilter:
__slots__ = ("delay", "feedback", "damping", "damp_filt", "buf", "_buf_size") __slots__ = ("delay", "feedback", "damping", "damp_filt", "buf", "_buf_size")
def __init__(self, delay_samples: int): def __init__(self, delay_samples: int, block_size: int = 256):
line_len = max(BLOCK_SIZE * 2, delay_samples + 1) line_len = max(block_size * 2, delay_samples + 1)
self.delay = _DelayLine(line_len) self.delay = _DelayLine(line_len)
self.feedback: float = 0.5 self.feedback: float = 0.5
self.damping: float = 0.5 # low-pass damping coefficient self.damping: float = 0.5 # low-pass damping coefficient
self.damp_filt: float = 0.0 # state variable for damping self.damp_filt: float = 0.0 # state variable for damping
self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32) self.buf = np.zeros(block_size, dtype=np.float32)
self._buf_size = BLOCK_SIZE self._buf_size = block_size
def process(self, block: np.ndarray) -> np.ndarray: def process(self, block: np.ndarray) -> np.ndarray:
# Resize internal buffer if block size changed (e.g. JACK period switch) # Resize internal buffer if block size changed (e.g. JACK period switch)
@@ -263,12 +264,12 @@ class _AllpassFilter:
__slots__ = ("delay", "gain", "buf", "_buf_size") __slots__ = ("delay", "gain", "buf", "_buf_size")
def __init__(self, delay_samples: int): def __init__(self, delay_samples: int, block_size: int = 256):
line_len = max(BLOCK_SIZE * 2, delay_samples + 1) line_len = max(block_size * 2, delay_samples + 1)
self.delay = _DelayLine(line_len) self.delay = _DelayLine(line_len)
self.gain: float = 0.5 self.gain: float = 0.5
self.buf = np.zeros(BLOCK_SIZE, dtype=np.float32) self.buf = np.zeros(block_size, dtype=np.float32)
self._buf_size = BLOCK_SIZE self._buf_size = block_size
def process(self, block: np.ndarray) -> np.ndarray: def process(self, block: np.ndarray) -> np.ndarray:
# Resize internal buffer if block size changed (e.g. JACK period switch) # Resize internal buffer if block size changed (e.g. JACK period switch)
@@ -324,12 +325,16 @@ class AudioPipeline:
# Cached filter coefficients per block # Cached filter coefficients per block
self._coeffs: dict[str, tuple] = {} self._coeffs: dict[str, tuple] = {}
# Runtime audio params (may be updated via set_audio_profile)
self._block_size: int = 256
self._sample_rate: int = 48000
# VU meter level tracking — updated on every process() call # VU meter level tracking — updated on every process() call
# Smoothed RMS levels (0.01.0) read by web server for live VU meters # Smoothed RMS levels (0.01.0) read by web server for live VU meters
self._input_level: float = 0.0 self._input_level: float = 0.0
self._output_level: float = 0.0 self._output_level: float = 0.0
# Smoothing factor: ~50ms time constant at 48kHz/256 block # Smoothing factor: ~50ms time constant — recomputed on profile change
self._vu_alpha: float = np.exp(-BLOCK_SIZE / (0.05 * SAMPLE_RATE)) self._vu_alpha: float = np.exp(-256 / (0.05 * 48000))
# ── Tuner / pitch detection state ──────────────────────────────── # ── Tuner / pitch detection state ────────────────────────────────
self._tuner_frequency: float = 0.0 # detected fundamental freq (Hz) self._tuner_frequency: float = 0.0 # detected fundamental freq (Hz)
@@ -341,14 +346,36 @@ class AudioPipeline:
self._pitch_buffer: np.ndarray = np.array([], dtype=np.float32) self._pitch_buffer: np.ndarray = np.array([], dtype=np.float32)
self._pitch_buffer_max: int = 2048 # ~43ms at 48kHz self._pitch_buffer_max: int = 2048 # ~43ms at 48kHz
# Thread-safety lock — protects config state swapped by load_preset()
# and read by process(). process() snapshots under this lock briefly;
# load_preset() holds it only for the atomic swap (not during model I/O).
self._lock = threading.Lock()
logger.info("Audio pipeline initialized (block=%d, sr=%d)", logger.info("Audio pipeline initialized (block=%d, sr=%d)",
BLOCK_SIZE, SAMPLE_RATE) self._block_size, self._sample_rate)
@property
def sample_rate(self) -> int:
"""Current sample rate in Hz."""
return self._sample_rate
@property
def block_size(self) -> int:
"""Current audio block size (frames per callback)."""
return self._block_size
def load_preset(self, preset: Preset) -> None: def load_preset(self, preset: Preset) -> None:
"""Load a complete preset (NAM, IR, and FX chain).""" """Load a complete preset (NAM, IR, and FX chain).
self._chain = []
self._state = {} Builds the new chain list off-thread, then swaps atomically under
self._coeffs = {} ``_lock`` so that the audio thread's ``process()`` never sees a
half-constructed chain.
"""
new_chain: list[dict] = []
new_routing_mode = preset.routing_mode
new_routing_breakpoint = preset.routing_breakpoint
new_master_volume = preset.master_volume
new_tuner_enabled = preset.tuner_enabled
for block in preset.chain: for block in preset.chain:
entry = { entry = {
@@ -359,7 +386,7 @@ class AudioPipeline:
"subtype": block.subtype, "subtype": block.subtype,
} }
# Load NAM model if needed # Load NAM model if needed (may do I/O — don't hold pipeline lock)
if block.fx_type == FXType.NAM_AMP and block.nam_model_path: if block.fx_type == FXType.NAM_AMP and block.nam_model_path:
self.nam.load_model(block.nam_model_path) self.nam.load_model(block.nam_model_path)
@@ -367,14 +394,17 @@ class AudioPipeline:
if block.fx_type == FXType.IR_CAB and block.ir_file_path: if block.fx_type == FXType.IR_CAB and block.ir_file_path:
self.ir.load_ir(block.ir_file_path) self.ir.load_ir(block.ir_file_path)
self._chain.append(entry) new_chain.append(entry)
self._master_volume = preset.master_volume # Atomic swap under lock — keep the window as short as possible
self._tuner_enabled = preset.tuner_enabled with self._lock:
self._chain = new_chain
# Set 4CM routing from preset self._state = {}
self._routing_mode = preset.routing_mode self._coeffs = {}
self._routing_breakpoint = preset.routing_breakpoint self._master_volume = new_master_volume
self._tuner_enabled = new_tuner_enabled
self._routing_mode = new_routing_mode
self._routing_breakpoint = new_routing_breakpoint
logger.info("Preset '%s' loaded: %d blocks, routing=%s breakpoint=%d", logger.info("Preset '%s' loaded: %d blocks, routing=%s breakpoint=%d",
preset.name, len(self._chain), preset.name, len(self._chain),
@@ -393,8 +423,21 @@ class AudioPipeline:
Mono mode: shape (N,) — processed output. Mono mode: shape (N,) — processed output.
4CM mode: shape (2, N) — [send_out, return_out]. 4CM mode: shape (2, N) — [send_out, return_out].
""" """
# ── Snapshot config under lock (brief — no I/O or heavy work) ──
with self._lock:
tuner_enabled = self._tuner_enabled
bypassed = self._bypassed
routing_mode = self._routing_mode
routing_breakpoint = self._routing_breakpoint
master_volume = self._master_volume
# Shallow-copy the chain list so iteration is isolated
chain = list(self._chain)
# Snapshot the state dict — process() mutates entries inside it
# but load_preset() replaces the whole dict under lock
state = self._state
# ── Tuner mode: mute output, keep input tracking for pitch detection ── # ── Tuner mode: mute output, keep input tracking for pitch detection ──
if self._tuner_enabled: if tuner_enabled:
# Still track input level for tuner display # Still track input level for tuner display
if audio_in.ndim == 1: if audio_in.ndim == 1:
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS) in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
@@ -413,13 +456,14 @@ class AudioPipeline:
self._detect_pitch(audio_in) self._detect_pitch(audio_in)
return np.zeros_like(audio_in) return np.zeros_like(audio_in)
if self._bypassed: if bypassed:
return audio_in * self._master_volume return audio_in * master_volume
if self._routing_mode == "4cm": if routing_mode == "4cm":
return self._process_4cm(audio_in) return self._process_4cm(audio_in, master_volume, chain, state,
routing_breakpoint)
else: else:
return self._process_mono(audio_in) return self._process_mono(audio_in, master_volume, chain, state)
# ── Pitch detection for tuner ────────────────────────────────────────────── # ── Pitch detection for tuner ──────────────────────────────────────────────
@@ -472,8 +516,8 @@ class AudioPipeline:
# Autocorrelation: find the fundamental period # Autocorrelation: find the fundamental period
# Search lag range: 30 to 1024 samples (46.9Hz to 1600Hz at 48kHz) # Search lag range: 30 to 1024 samples (46.9Hz to 1600Hz at 48kHz)
min_lag = int(SAMPLE_RATE / 1600) # ~30 min_lag = int(self._sample_rate / 1600) # ~30
max_lag = min(int(SAMPLE_RATE / 50), len(buf) // 2) # ~960 max_lag = min(int(self._sample_rate / 50), len(buf) // 2) # ~960
if max_lag <= min_lag: if max_lag <= min_lag:
self._tuner_confidence = 0.0 self._tuner_confidence = 0.0
@@ -483,10 +527,7 @@ class AudioPipeline:
# Take only the second half (positive lags) # Take only the second half (positive lags)
corr = corr[len(corr) // 2:] corr = corr[len(corr) // 2:]
# Normalize by energy at each lag (YIN-style) # Simple autocorrelation
energy = np.cumsum(buf ** 2)
energy = energy[max(1, min_lag):max_lag + len(buf) - len(corr) + min_lag + 1]
# Fallback: use simple autocorrelation
lag_slice = corr[min_lag:max_lag + 1] lag_slice = corr[min_lag:max_lag + 1]
# Find the first peak in the autocorrelation # Find the first peak in the autocorrelation
@@ -517,7 +558,7 @@ class AudioPipeline:
best_lag = best_lag + correction best_lag = best_lag + correction
# Fundamental frequency # Fundamental frequency
freq = SAMPLE_RATE / best_lag if best_lag > 0 else 0 freq = self._sample_rate / best_lag if best_lag > 0 else 0
# Clip confidence to 0-1 range # Clip confidence to 0-1 range
self._tuner_confidence = min(1.0, max(0.0, confidence / 10.0)) self._tuner_confidence = min(1.0, max(0.0, confidence / 10.0))
@@ -554,7 +595,10 @@ class AudioPipeline:
self._tuner_string = si + 1 self._tuner_string = si + 1
break break
def _process_mono(self, audio_in: np.ndarray) -> np.ndarray: def _process_mono(self, audio_in: np.ndarray,
master_volume: float,
chain: list[dict],
state: dict[str, dict]) -> np.ndarray:
"""Process a mono block through the full chain (all blocks).""" """Process a mono block through the full chain (all blocks)."""
# Update input VU level (RMS with envelope smoothing) # Update input VU level (RMS with envelope smoothing)
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS) in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
@@ -564,11 +608,11 @@ class AudioPipeline:
) )
buf = audio_in.copy() buf = audio_in.copy()
for idx, entry in enumerate(self._chain): for idx, entry in enumerate(chain):
if entry["bypass"] or not entry["enabled"]: if entry["bypass"] or not entry["enabled"]:
continue continue
buf = self._process_single_block(buf, idx, entry) buf = self._process_single_block(buf, idx, entry, state)
out = np.clip(buf * self._master_volume, -1.0, 1.0) out = np.clip(buf * master_volume, -1.0, 1.0)
# Update output VU level # Update output VU level
out_rms = np.sqrt(np.mean(out ** 2) + _EPS) out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
@@ -579,14 +623,18 @@ class AudioPipeline:
return out return out
def _process_4cm(self, audio_in: np.ndarray) -> np.ndarray: def _process_4cm(self, audio_in: np.ndarray,
master_volume: float,
chain: list[dict],
state: dict[str, dict],
routing_breakpoint: int) -> np.ndarray:
"""Process stereo block with 4CM split routing. """Process stereo block with 4CM split routing.
audio_in has shape (2, N): audio_in has shape (2, N):
ch0 = guitar input (Input 1) ch0 = guitar input (Input 1)
ch1 = FX loop return (Input 2) ch1 = FX loop return (Input 2)
Splits at _routing_breakpoint: Splits at routing_breakpoint:
pre blocks → ch0 processed through [0..breakpoint) pre blocks → ch0 processed through [0..breakpoint)
post blocks → ch1 processed through [breakpoint..] post blocks → ch1 processed through [breakpoint..]
@@ -604,22 +652,22 @@ class AudioPipeline:
+ in_rms * (1.0 - self._vu_alpha) + in_rms * (1.0 - self._vu_alpha)
) )
bp = self._routing_breakpoint bp = routing_breakpoint
for idx, entry in enumerate(self._chain): for idx, entry in enumerate(chain):
if entry["bypass"] or not entry["enabled"]: if entry["bypass"] or not entry["enabled"]:
continue continue
if idx < bp: if idx < bp:
# Pre-amp block — process on guitar (ch0) # Pre-amp block — process on guitar (ch0)
ch0 = self._process_single_block(ch0, idx, entry) ch0 = self._process_single_block(ch0, idx, entry, state)
else: else:
# Post-amp block — process on return (ch1) # Post-amp block — process on return (ch1)
ch1 = self._process_single_block(ch1, idx, entry) ch1 = self._process_single_block(ch1, idx, entry, state)
out = np.zeros_like(audio_in) out = np.zeros_like(audio_in)
out[0, :] = np.clip(ch0 * self._master_volume, -1.0, 1.0) out[0, :] = np.clip(ch0 * master_volume, -1.0, 1.0)
out[1, :] = np.clip(ch1 * self._master_volume, -1.0, 1.0) out[1, :] = np.clip(ch1 * master_volume, -1.0, 1.0)
# Update output VU level from the processed effect return (ch1) # Update output VU level from the processed effect return (ch1)
out_rms = np.sqrt(np.mean(out ** 2) + _EPS) out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
@@ -631,13 +679,15 @@ class AudioPipeline:
return out return out
def _process_single_block(self, buf: np.ndarray, idx: int, def _process_single_block(self, buf: np.ndarray, idx: int,
entry: dict) -> np.ndarray: entry: dict,
state: dict[str, dict]) -> np.ndarray:
"""Process a single mono audio block through one FX block. """Process a single mono audio block through one FX block.
Args: Args:
buf: Mono audio block (N,) to process. buf: Mono audio block (N,) to process.
idx: Chain index for state lookup. idx: Chain index for state lookup.
entry: Chain entry dict with fx_type, params. entry: Chain entry dict with fx_type, params.
state: Per-block DSP state dict (snapshotted by ``process()``).
Returns: Returns:
Processed mono block (N,). Processed mono block (N,).
@@ -651,7 +701,7 @@ class AudioPipeline:
subtype = entry.get("subtype", "") subtype = entry.get("subtype", "")
if subtype: if subtype:
params["subtype"] = subtype params["subtype"] = subtype
fx_state = self._state.setdefault(f"fx_{idx}", {}) fx_state = state.setdefault(f"fx_{idx}", {})
match fx_type: match fx_type:
case FXType.NOISE_GATE: case FXType.NOISE_GATE:
@@ -809,11 +859,10 @@ class AudioPipeline:
# ── LFO helpers ───────────────────────────────────────────────── # ── LFO helpers ─────────────────────────────────────────────────
@staticmethod def _lfo_phase(self, rate_hz: float, state: dict, block_size: int) -> np.ndarray:
def _lfo_phase(rate_hz: float, state: dict, block_size: int) -> np.ndarray:
"""Generate LFO phase ramp (0->1), update state.""" """Generate LFO phase ramp (0->1), update state."""
phase = state.get("phase", 0.0) phase = state.get("phase", 0.0)
delta = rate_hz / SAMPLE_RATE delta = rate_hz / self._sample_rate
t = np.arange(block_size, dtype=np.float64) * delta + phase t = np.arange(block_size, dtype=np.float64) * delta + phase
t %= 1.0 t %= 1.0
state["phase"] = float(t[-1] + delta) % 1.0 state["phase"] = float(t[-1] + delta) % 1.0
@@ -851,7 +900,7 @@ class AudioPipeline:
envelope = rms envelope = rms
else: else:
# Exponential release — time constant per block # Exponential release — time constant per block
release_coeff = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0)) release_coeff = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * release_coeff + rms * (1.0 - release_coeff) envelope = envelope * release_coeff + rms * (1.0 - release_coeff)
state["envelope"] = envelope state["envelope"] = envelope
@@ -876,9 +925,9 @@ class AudioPipeline:
envelope = state.get("envelope", 0.0) envelope = state.get("envelope", 0.0)
if rms > envelope: if rms > envelope:
alpha = np.exp(-BLOCK_SIZE / (attack_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else: else:
alpha = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha) envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope state["envelope"] = envelope
@@ -993,7 +1042,7 @@ class AudioPipeline:
tone_cut = 1.0 - tone # 1 = max cut tone_cut = 1.0 - tone # 1 = max cut
if tone_cut > 0.001: if tone_cut > 0.001:
fc = 2000.0 + (1.0 - tone_cut) * 18000.0 # 2kHz to 20kHz fc = 2000.0 + (1.0 - tone_cut) * 18000.0 # 2kHz to 20kHz
omega = 2.0 * np.pi * fc / SAMPLE_RATE omega = 2.0 * np.pi * fc / self._sample_rate
a0 = 1.0 + omega # one-pole approximation a0 = 1.0 + omega # one-pole approximation
b0 = omega / a0 b0 = omega / a0
a1 = (1.0 - omega) / a0 a1 = (1.0 - omega) / a0
@@ -1056,7 +1105,7 @@ class AudioPipeline:
coeffs = state.get("bd2_tshelf_coeffs") coeffs = state.get("bd2_tshelf_coeffs")
tag = round(shelf_gain_db, 2) tag = round(shelf_gain_db, 2)
if coeffs is None or state.get("bd2_tshelf_tag") != tag: if coeffs is None or state.get("bd2_tshelf_tag") != tag:
coeffs = _compute_highshelf_coeffs(3500.0, shelf_gain_db, 0.7, SAMPLE_RATE) coeffs = _compute_highshelf_coeffs(3500.0, shelf_gain_db, 0.7, self._sample_rate)
state["bd2_tshelf_coeffs"] = coeffs state["bd2_tshelf_coeffs"] = coeffs
state["bd2_tshelf_tag"] = tag state["bd2_tshelf_tag"] = tag
@@ -1131,7 +1180,7 @@ class AudioPipeline:
coeffs = state.get("muff_bass_coeffs") coeffs = state.get("muff_bass_coeffs")
tag = (round(bass_gain, 1), round(tone, 2)) tag = (round(bass_gain, 1), round(tone, 2))
if coeffs is None or state.get("muff_bass_tag") != tag: if coeffs is None or state.get("muff_bass_tag") != tag:
coeffs = _compute_lowshelf_coeffs(200.0, bass_gain, 0.707, SAMPLE_RATE) coeffs = _compute_lowshelf_coeffs(200.0, bass_gain, 0.707, self._sample_rate)
state["muff_bass_coeffs"] = coeffs state["muff_bass_coeffs"] = coeffs
state["muff_bass_tag"] = tag state["muff_bass_tag"] = tag
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
@@ -1146,7 +1195,7 @@ class AudioPipeline:
coeffs = state.get("muff_treb_coeffs") coeffs = state.get("muff_treb_coeffs")
tag = (round(treble_gain, 1), round(tone, 2)) tag = (round(treble_gain, 1), round(tone, 2))
if coeffs is None or state.get("muff_treb_tag") != tag: if coeffs is None or state.get("muff_treb_tag") != tag:
coeffs = _compute_highshelf_coeffs(3000.0, treble_gain, 0.707, SAMPLE_RATE) coeffs = _compute_highshelf_coeffs(3000.0, treble_gain, 0.707, self._sample_rate)
state["muff_treb_coeffs"] = coeffs state["muff_treb_coeffs"] = coeffs
state["muff_treb_tag"] = tag state["muff_treb_tag"] = tag
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
@@ -1189,7 +1238,7 @@ class AudioPipeline:
coeffs = state.get(f"{key}_coeffs") coeffs = state.get(f"{key}_coeffs")
param_tag = (bass_freq, mid_freq, treble_freq, bass_gain, mid_gain, treble_gain, q) param_tag = (bass_freq, mid_freq, treble_freq, bass_gain, mid_gain, treble_gain, q)
if coeffs is None or state.get(f"{key}_tag") != param_tag: if coeffs is None or state.get(f"{key}_tag") != param_tag:
coeffs = compute_fn(freq, gain_db, q, SAMPLE_RATE) coeffs = compute_fn(freq, gain_db, q, self._sample_rate)
state[f"{key}_coeffs"] = coeffs state[f"{key}_coeffs"] = coeffs
state[f"{key}_tag"] = param_tag state[f"{key}_tag"] = param_tag
@@ -1213,11 +1262,11 @@ class AudioPipeline:
mix = params.get("mix", 0.5) # wet/dry mix = params.get("mix", 0.5) # wet/dry
delay_base = params.get("delay", 20.0) # ms (typical chorus: 15-30ms) delay_base = params.get("delay", 20.0) # ms (typical chorus: 15-30ms)
base_samples = delay_base * SAMPLE_RATE / 1000.0 base_samples = delay_base * self._sample_rate / 1000.0
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0 mod_range = depth * 5.0 * self._sample_rate / 1000.0
if "delay" not in state: if "delay" not in state:
max_d = int(base_samples + mod_range + 10.0 * SAMPLE_RATE / 1000.0) + 1 max_d = int(base_samples + mod_range + 10.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d) state["delay"] = _DelayLine(max_d)
state["delay"].write_block(np.zeros(max_d)) state["delay"].write_block(np.zeros(max_d))
@@ -1244,11 +1293,11 @@ class AudioPipeline:
mix = params.get("mix", 0.5) # wet/dry mix = params.get("mix", 0.5) # wet/dry
delay_base = params.get("delay", 5.0) # ms (typical flanger: 1-10ms) delay_base = params.get("delay", 5.0) # ms (typical flanger: 1-10ms)
base_samples = delay_base * SAMPLE_RATE / 1000.0 base_samples = delay_base * self._sample_rate / 1000.0
mod_range = depth * 5.0 * SAMPLE_RATE / 1000.0 mod_range = depth * 5.0 * self._sample_rate / 1000.0
if "delay" not in state: if "delay" not in state:
max_d = int(base_samples + mod_range + 10.0 * SAMPLE_RATE / 1000.0) + 1 max_d = int(base_samples + mod_range + 10.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d) state["delay"] = _DelayLine(max_d)
state["delay"].write_block(np.zeros(max_d)) state["delay"].write_block(np.zeros(max_d))
@@ -1299,7 +1348,7 @@ class AudioPipeline:
# But coeff varies per sample (LFO-driven)! Can't use lfilter directly. # But coeff varies per sample (LFO-driven)! Can't use lfilter directly.
# Use block-constant approximation: one coeff per block at LFO centre. # Use block-constant approximation: one coeff per block at LFO centre.
freq = np.mean(freq_range) freq = np.mean(freq_range)
w = 2.0 * np.pi * freq / SAMPLE_RATE w = 2.0 * np.pi * freq / self._sample_rate
tan_half_w = np.tan(w / 2.0) tan_half_w = np.tan(w / 2.0)
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w) coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
@@ -1337,11 +1386,11 @@ class AudioPipeline:
rate = params.get("rate", 3.0) # Hz rate = params.get("rate", 3.0) # Hz
depth = params.get("depth", 0.5) # cents equivalent depth = params.get("depth", 0.5) # cents equivalent
base_samples = 2.0 * SAMPLE_RATE / 1000.0 # fixed ~2ms base base_samples = 2.0 * self._sample_rate / 1000.0 # fixed ~2ms base
mod_range = depth * 3.0 * SAMPLE_RATE / 1000.0 mod_range = depth * 3.0 * self._sample_rate / 1000.0
if "delay" not in state: if "delay" not in state:
max_d = int(base_samples + mod_range + 5.0 * SAMPLE_RATE / 1000.0) + 1 max_d = int(base_samples + mod_range + 5.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d) state["delay"] = _DelayLine(max_d)
state["delay"].write_block(np.zeros(max_d)) state["delay"].write_block(np.zeros(max_d))
@@ -1386,11 +1435,11 @@ class AudioPipeline:
if tap_tempo > 0: if tap_tempo > 0:
time_ms = tap_tempo time_ms = tap_tempo
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0) delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
# Allocate 2x requested delay for headroom # Allocate 2x requested delay for headroom
max_d = max(delay_samples * 2, SAMPLE_RATE) # at least 1s max_d = max(delay_samples * 2, self._sample_rate) # at least 1s
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
@@ -1426,10 +1475,10 @@ class AudioPipeline:
mix = params.get("mix", 0.4) mix = params.get("mix", 0.4)
tone = params.get("tone", 0.5) # 0.0=dark, 1.0=bright tone = params.get("tone", 0.5) # 0.0=dark, 1.0=bright
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0) delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
max_d = max(delay_samples * 2, SAMPLE_RATE) max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
@@ -1501,17 +1550,17 @@ class AudioPipeline:
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] # ms
ap_delays = [5, 7, 11, 13] # ms ap_delays = [5, 7, 11, 13] # ms
state["combs"] = [ state["combs"] = [
_CombFilter(int(d * SAMPLE_RATE / 1000.0)) _CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in comb_delays for d in comb_delays
] ]
state["allpasses"] = [ state["allpasses"] = [
_AllpassFilter(int(d * SAMPLE_RATE / 1000.0)) _AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in ap_delays for d in ap_delays
] ]
state["predelay"] = _DelayLine( state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1 int(predelay_ms * self._sample_rate / 1000.0) + 1
) )
state["predelay"].write_block(np.zeros(BLOCK_SIZE)) state["predelay"].write_block(np.zeros(self._block_size))
state["_computed"] = False state["_computed"] = False
combs: list[_CombFilter] = state["combs"] combs: list[_CombFilter] = state["combs"]
@@ -1530,7 +1579,7 @@ class AudioPipeline:
state["_param_tag"] = param_tag state["_param_tag"] = param_tag
delayed = predelay_line.read_block( delayed = predelay_line.read_block(
float(predelay_ms * SAMPLE_RATE / 1000.0), len(buf)) float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
wet = np.zeros_like(buf, dtype=np.float64) wet = np.zeros_like(buf, dtype=np.float64)
@@ -1568,8 +1617,8 @@ class AudioPipeline:
spring_q = [4.0, 6.0, 3.0, 5.0] # resonance Q per spring spring_q = [4.0, 6.0, 3.0, 5.0] # resonance Q per spring
state["spring_lines"] = [ state["spring_lines"] = [
{ {
"delay": _DelayLine(int(d * SAMPLE_RATE / 1000.0 "delay": _DelayLine(int(d * self._sample_rate / 1000.0
+ BLOCK_SIZE + 1)), + self._block_size + 1)),
"q": q, "q": q,
"prev": 0.0, "prev": 0.0,
"filt_prev": np.zeros(2, dtype=np.float64), "filt_prev": np.zeros(2, dtype=np.float64),
@@ -1578,12 +1627,12 @@ class AudioPipeline:
for d, q in zip(spring_delays, spring_q) for d, q in zip(spring_delays, spring_q)
] ]
state["predelay"] = _DelayLine( state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1) int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(BLOCK_SIZE)) state["predelay"].write_block(np.zeros(self._block_size))
predelay_line: _DelayLine = state["predelay"] predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block( delayed = predelay_line.read_block(
float(predelay_ms * SAMPLE_RATE / 1000.0), len(buf)) float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
# Feedback / damping scaling # Feedback / damping scaling
@@ -1597,12 +1646,12 @@ class AudioPipeline:
q_val = spring["q"] q_val = spring["q"]
# Read delayed signal # Read delayed signal
delay_samps = dl.max_len - BLOCK_SIZE - 1 delay_samps = dl.max_len - self._block_size - 1
spring_out = dl.read_block(float(delay_samps), len(buf)) spring_out = dl.read_block(float(delay_samps), len(buf))
# Bandpass filter per spring — emphasises resonant frequency # Bandpass filter per spring — emphasises resonant frequency
coeff = _compute_bpf_coeffs( coeff = _compute_bpf_coeffs(
SAMPLE_RATE / (delay_samps + 1), q_val, SAMPLE_RATE) self._sample_rate / (delay_samps + 1), q_val, self._sample_rate)
b0, b1, b2, a1, a2 = coeff b0, b1, b2, a1, a2 = coeff
b_arr = np.array([b0, b1, b2], dtype=np.float64) b_arr = np.array([b0, b1, b2], dtype=np.float64)
a_arr = np.array([1.0, a1, a2], dtype=np.float64) a_arr = np.array([1.0, a1, a2], dtype=np.float64)
@@ -1656,16 +1705,16 @@ class AudioPipeline:
comb_delays_ms = [5, 11, 19, 23, 34, 39, 42, 48, 53, 57, 62, 68] comb_delays_ms = [5, 11, 19, 23, 34, 39, 42, 48, 53, 57, 62, 68]
ap_delays_ms = [2, 5, 9, 13] ap_delays_ms = [2, 5, 9, 13]
state["combs"] = [ state["combs"] = [
_CombFilter(int(d * SAMPLE_RATE / 1000.0)) _CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in comb_delays_ms for d in comb_delays_ms
] ]
state["allpasses"] = [ state["allpasses"] = [
_AllpassFilter(int(d * SAMPLE_RATE / 1000.0)) _AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size)
for d in ap_delays_ms for d in ap_delays_ms
] ]
state["predelay"] = _DelayLine( state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1) int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(BLOCK_SIZE)) state["predelay"].write_block(np.zeros(self._block_size))
# Plate modulation LFO phase # Plate modulation LFO phase
state["mod_phase"] = 0.0 state["mod_phase"] = 0.0
@@ -1688,12 +1737,12 @@ class AudioPipeline:
# Predelay # Predelay
delayed = predelay_line.read_block( delayed = predelay_line.read_block(
float(predelay_ms * SAMPLE_RATE / 1000.0), len(buf)) float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
# Plate modulation: very slow LFO (0.15 Hz) for subtle detuning # Plate modulation: very slow LFO (0.15 Hz) for subtle detuning
mod_phase = state.get("mod_phase", 0.0) mod_phase = state.get("mod_phase", 0.0)
delta = 0.15 / SAMPLE_RATE delta = 0.15 / self._sample_rate
t = np.arange(len(buf), dtype=np.float64) * delta + mod_phase t = np.arange(len(buf), dtype=np.float64) * delta + mod_phase
t %= 1.0 t %= 1.0
state["mod_phase"] = float((t[-1] + delta) % 1.0) state["mod_phase"] = float((t[-1] + delta) % 1.0)
@@ -1748,28 +1797,29 @@ class AudioPipeline:
tail_comb_ms = [21, 29, 37, 44] tail_comb_ms = [21, 29, 37, 44]
tail_ap_ms = [4, 8, 13] tail_ap_ms = [4, 8, 13]
max_tap = int(max(base_taps_ms) * size_factor max_tap = int(max(base_taps_ms) * size_factor
* SAMPLE_RATE / 1000.0) * self._sample_rate / 1000.0)
state["er_delay"] = _DelayLine(max_tap + BLOCK_SIZE + 1) state["er_delay"] = _DelayLine(max_tap + self._block_size + 1)
state["er_taps"] = [ state["er_taps"] = [
int(t * size_factor * SAMPLE_RATE / 1000.0) int(t * size_factor * self._sample_rate / 1000.0)
for t in base_taps_ms for t in base_taps_ms
] ]
state["tail_combs"] = [ state["tail_combs"] = [
_CombFilter(int(d * size_factor * SAMPLE_RATE / 1000.0 + 1)) _CombFilter(int(d * size_factor * self._sample_rate / 1000.0 + 1), block_size=self._block_size)
for d in tail_comb_ms for d in tail_comb_ms
] ]
state["tail_allpasses"] = [ state["tail_allpasses"] = [
_AllpassFilter( _AllpassFilter(
int(d * size_factor * SAMPLE_RATE / 1000.0 + 1)) int(d * size_factor * self._sample_rate / 1000.0 + 1),
block_size=self._block_size)
for d in tail_ap_ms for d in tail_ap_ms
] ]
state["predelay"] = _DelayLine( state["predelay"] = _DelayLine(
int(predelay_ms * SAMPLE_RATE / 1000.0) + 1) int(predelay_ms * self._sample_rate / 1000.0) + 1)
state["predelay"].write_block(np.zeros(BLOCK_SIZE)) state["predelay"].write_block(np.zeros(self._block_size))
predelay_line: _DelayLine = state["predelay"] predelay_line: _DelayLine = state["predelay"]
delayed = predelay_line.read_block( delayed = predelay_line.read_block(
float(predelay_ms * SAMPLE_RATE / 1000.0), len(buf)) float(predelay_ms * self._sample_rate / 1000.0), len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
er_delay: _DelayLine = state["er_delay"] er_delay: _DelayLine = state["er_delay"]
@@ -1900,7 +1950,7 @@ class AudioPipeline:
factor = 2.0 ** (shift_semitones / 12.0) factor = 2.0 ** (shift_semitones / 12.0)
# Windowed overlap-add with grain size ~40ms # Windowed overlap-add with grain size ~40ms
grain_size = int(0.040 * SAMPLE_RATE) # 1920 samples at 48kHz grain_size = int(0.040 * self._sample_rate) # 1920 samples at 48kHz
hop_in = grain_size hop_in = grain_size
hop_out = int(grain_size / factor) if factor > 0 else grain_size hop_out = int(grain_size / factor) if factor > 0 else grain_size
@@ -1953,7 +2003,7 @@ class AudioPipeline:
return buf return buf
factor = 2.0 ** (shift_semitones / 12.0) factor = 2.0 ** (shift_semitones / 12.0)
grain_size = int(0.040 * SAMPLE_RATE) grain_size = int(0.040 * self._sample_rate)
hop_out = int(grain_size / factor) hop_out = int(grain_size / factor)
if "ring" not in state: if "ring" not in state:
@@ -2000,7 +2050,7 @@ class AudioPipeline:
shift_semitones = bend * 12.0 shift_semitones = bend * 12.0
if "ring" not in state: if "ring" not in state:
grain_size = int(0.040 * SAMPLE_RATE) grain_size = int(0.040 * self._sample_rate)
state["ring"] = np.zeros(grain_size * 2, dtype=np.float32) state["ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["wpos"] = 0 state["wpos"] = 0
state["rpos"] = 0 state["rpos"] = 0
@@ -2042,11 +2092,11 @@ class AudioPipeline:
depth = params.get("depth", 0.5) depth = params.get("depth", 0.5)
mix = params.get("mix", 0.5) mix = params.get("mix", 0.5)
base_samples = 1.0 * SAMPLE_RATE / 1000.0 # 1ms base base_samples = 1.0 * self._sample_rate / 1000.0 # 1ms base
mod_range = depth * 3.0 * SAMPLE_RATE / 1000.0 mod_range = depth * 3.0 * self._sample_rate / 1000.0
if "delay" not in state: if "delay" not in state:
max_d = int(base_samples + mod_range + 5.0 * SAMPLE_RATE / 1000.0) + 1 max_d = int(base_samples + mod_range + 5.0 * self._sample_rate / 1000.0) + 1
state["delay"] = _DelayLine(max_d) state["delay"] = _DelayLine(max_d)
state["delay"].write_block(np.zeros(max_d)) state["delay"].write_block(np.zeros(max_d))
@@ -2097,7 +2147,7 @@ class AudioPipeline:
cutoff = np.clip(cutoff, 200.0, 5000.0) cutoff = np.clip(cutoff, 200.0, 5000.0)
# BPF biquad # BPF biquad
coeffs = _compute_bpf_coeffs(cutoff, q, SAMPLE_RATE) coeffs = _compute_bpf_coeffs(cutoff, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2125,13 +2175,13 @@ class AudioPipeline:
if rms > env: if rms > env:
env = rms # instant attack env = rms # instant attack
else: else:
env = env * np.exp(-BLOCK_SIZE / (decay * 0.1 * SAMPLE_RATE)) env = env * np.exp(-self._block_size / (decay * 0.1 * self._sample_rate))
state["envelope"] = env state["envelope"] = env
cutoff = 200.0 + env * freq_range * 3800.0 cutoff = 200.0 + env * freq_range * 3800.0
cutoff = np.clip(cutoff, 100.0, 4000.0) cutoff = np.clip(cutoff, 100.0, 4000.0)
coeffs = _compute_lpf_coeffs(cutoff, 0.707, SAMPLE_RATE) coeffs = _compute_lpf_coeffs(cutoff, 0.707, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2160,10 +2210,10 @@ class AudioPipeline:
# Dual modulated delays (rotor + horn) # Dual modulated delays (rotor + horn)
if "rotor_delay" not in state: if "rotor_delay" not in state:
state["rotor_delay"] = _DelayLine(int(0.020 * SAMPLE_RATE + 1)) state["rotor_delay"] = _DelayLine(int(0.020 * self._sample_rate + 1))
state["horn_delay"] = _DelayLine(int(0.015 * SAMPLE_RATE + 1)) state["horn_delay"] = _DelayLine(int(0.015 * self._sample_rate + 1))
state["rotor_delay"].write_block(np.zeros(int(0.020 * SAMPLE_RATE))) state["rotor_delay"].write_block(np.zeros(int(0.020 * self._sample_rate)))
state["horn_delay"].write_block(np.zeros(int(0.015 * SAMPLE_RATE))) state["horn_delay"].write_block(np.zeros(int(0.015 * self._sample_rate)))
rotor_phase = self._lfo_phase(rotor_rate, state, len(buf)) rotor_phase = self._lfo_phase(rotor_rate, state, len(buf))
horn_phase = self._lfo_phase(horn_rate, state, len(buf)) horn_phase = self._lfo_phase(horn_rate, state, len(buf))
@@ -2177,11 +2227,11 @@ class AudioPipeline:
horn_delay_s = 0.001 + horn_lfo * 0.002 # 1-3ms horn_delay_s = 0.001 + horn_lfo * 0.002 # 1-3ms
rotor_wet = state["rotor_delay"].read_block_varying( rotor_wet = state["rotor_delay"].read_block_varying(
rotor_delay_s * SAMPLE_RATE) rotor_delay_s * self._sample_rate)
state["rotor_delay"].write_block(buf) state["rotor_delay"].write_block(buf)
horn_wet = state["horn_delay"].read_block_varying( horn_wet = state["horn_delay"].read_block_varying(
horn_delay_s * SAMPLE_RATE) horn_delay_s * self._sample_rate)
state["horn_delay"].write_block(buf * 0.7) state["horn_delay"].write_block(buf * 0.7)
# Mix with subtle drive (soft clip) # Mix with subtle drive (soft clip)
@@ -2219,7 +2269,7 @@ class AudioPipeline:
for stage in range(4): for stage in range(4):
freq = 200.0 + lfo * depth * 1800.0 freq = 200.0 + lfo * depth * 1800.0
freq_mean = float(np.mean(freq)) freq_mean = float(np.mean(freq))
w = 2.0 * np.pi * freq_mean / SAMPLE_RATE w = 2.0 * np.pi * freq_mean / self._sample_rate
tan_half_w = np.tan(w / 2.0) tan_half_w = np.tan(w / 2.0)
coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w) coeff = (1.0 - tan_half_w) / (1.0 + tan_half_w)
@@ -2270,12 +2320,12 @@ class AudioPipeline:
# From mono: create synthetic stereo by delaying a copy # From mono: create synthetic stereo by delaying a copy
if "delay" not in state: if "delay" not in state:
state["delay"] = _DelayLine(int(0.030 * SAMPLE_RATE + 1)) state["delay"] = _DelayLine(int(0.030 * self._sample_rate + 1))
state["delay"].write_block(np.zeros(int(0.030 * SAMPLE_RATE))) state["delay"].write_block(np.zeros(int(0.030 * self._sample_rate)))
delay_line: _DelayLine = state["delay"] delay_line: _DelayLine = state["delay"]
# Read delayed signal for "side" channel # Read delayed signal for "side" channel
side = delay_line.read_block(0.010 * SAMPLE_RATE, len(buf)) side = delay_line.read_block(0.010 * self._sample_rate, len(buf))
delay_line.write_block(buf) delay_line.write_block(buf)
# Mid = original, Side = delayed difference # Mid = original, Side = delayed difference
@@ -2371,9 +2421,9 @@ class AudioPipeline:
envelope = state.get("envelope", 0.0) envelope = state.get("envelope", 0.0)
if rms > envelope: if rms > envelope:
alpha = np.exp(-BLOCK_SIZE / (attack_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else: else:
alpha = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha) envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope state["envelope"] = envelope
@@ -2405,7 +2455,7 @@ class AudioPipeline:
# Bandpass the sibilance band # Bandpass the sibilance band
q = 3.0 # narrow Q for sibilance band q = 3.0 # narrow Q for sibilance band
coeffs = _compute_bpf_coeffs(freq, q, SAMPLE_RATE) coeffs = _compute_bpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2489,9 +2539,9 @@ class AudioPipeline:
envelope = state.get("envelope", 0.0) envelope = state.get("envelope", 0.0)
if rms > envelope: if rms > envelope:
alpha = np.exp(-BLOCK_SIZE / (attack_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (attack_ms * self._sample_rate / 1000.0))
else: else:
alpha = np.exp(-BLOCK_SIZE / (release_ms * SAMPLE_RATE / 1000.0)) alpha = np.exp(-self._block_size / (release_ms * self._sample_rate / 1000.0))
envelope = envelope * alpha + rms * (1.0 - alpha) envelope = envelope * alpha + rms * (1.0 - alpha)
state["envelope"] = envelope state["envelope"] = envelope
@@ -2527,7 +2577,7 @@ class AudioPipeline:
if freq == 0.0 or gain_db == 0.0: if freq == 0.0 or gain_db == 0.0:
continue continue
coeffs = _compute_peaking_coeffs(freq, gain_db, q, SAMPLE_RATE) coeffs = _compute_peaking_coeffs(freq, gain_db, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2546,7 +2596,7 @@ class AudioPipeline:
slope = params.get("slope", 12.0) slope = params.get("slope", 12.0)
q = 0.707 if slope >= 12 else 0.5 # 12dB = Q=0.707 (Butter), 6dB = Q=0.5 q = 0.707 if slope >= 12 else 0.5 # 12dB = Q=0.707 (Butter), 6dB = Q=0.5
coeffs = _compute_hpf_coeffs(freq, q, SAMPLE_RATE) coeffs = _compute_hpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2557,7 +2607,7 @@ class AudioPipeline:
# If 6dB slope, mix in a first-order version (less aggressive) # If 6dB slope, mix in a first-order version (less aggressive)
if slope < 12: if slope < 12:
# First-order HPF: simpler - just one pole # First-order HPF: simpler - just one pole
alpha = 1.0 / (1.0 + SAMPLE_RATE / (2.0 * np.pi * freq)) alpha = 1.0 / (1.0 + self._sample_rate / (2.0 * np.pi * freq))
zi1 = state.get("hpf_zi_1", np.float64(0.0)) zi1 = state.get("hpf_zi_1", np.float64(0.0))
out = np.zeros(len(buf), dtype=np.float64) out = np.zeros(len(buf), dtype=np.float64)
for i in range(len(buf)): for i in range(len(buf)):
@@ -2576,7 +2626,7 @@ class AudioPipeline:
slope = params.get("slope", 12.0) slope = params.get("slope", 12.0)
q = 0.707 if slope >= 12 else 0.5 q = 0.707 if slope >= 12 else 0.5
coeffs = _compute_lpf_coeffs(freq, q, SAMPLE_RATE) coeffs = _compute_lpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2585,7 +2635,7 @@ class AudioPipeline:
state["lpf_zi"] = zf state["lpf_zi"] = zf
if slope < 12: if slope < 12:
alpha = 1.0 / (1.0 + SAMPLE_RATE / (2.0 * np.pi * freq)) alpha = 1.0 / (1.0 + self._sample_rate / (2.0 * np.pi * freq))
zi1 = state.get("lpf_zi_1", np.float64(0.0)) zi1 = state.get("lpf_zi_1", np.float64(0.0))
out = np.zeros(len(buf), dtype=np.float64) out = np.zeros(len(buf), dtype=np.float64)
for i in range(len(buf)): for i in range(len(buf)):
@@ -2601,7 +2651,7 @@ class AudioPipeline:
"""Band-pass filter. Frequency, Q.""" """Band-pass filter. Frequency, Q."""
freq = params.get("frequency", 1000.0) freq = params.get("frequency", 1000.0)
q = params.get("q", 0.707) q = params.get("q", 0.707)
coeffs = _compute_bpf_coeffs(freq, q, SAMPLE_RATE) coeffs = _compute_bpf_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2615,7 +2665,7 @@ class AudioPipeline:
"""Notch filter. Frequency, Q.""" """Notch filter. Frequency, Q."""
freq = params.get("frequency", 60.0) freq = params.get("frequency", 60.0)
q = params.get("q", 10.0) # High Q for narrow notch q = params.get("q", 10.0) # High Q for narrow notch
coeffs = _compute_notch_coeffs(freq, q, SAMPLE_RATE) coeffs = _compute_notch_coeffs(freq, q, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2645,7 +2695,7 @@ class AudioPipeline:
sig = buf.astype(np.float64, copy=False) sig = buf.astype(np.float64, copy=False)
for stage, (freq, bw) in enumerate(bands): for stage, (freq, bw) in enumerate(bands):
q_val = freq / bw # Q from bandwidth q_val = freq / bw # Q from bandwidth
coeffs = _compute_peaking_coeffs(freq, 12.0, q_val, SAMPLE_RATE) coeffs = _compute_peaking_coeffs(freq, 12.0, q_val, self._sample_rate)
b0, b1, b2, a1, a2 = coeffs b0, b1, b2, a1, a2 = coeffs
b = np.array([b0, b1, b2], dtype=np.float64) b = np.array([b0, b1, b2], dtype=np.float64)
a = np.array([1.0, a1, a2], dtype=np.float64) a = np.array([1.0, a1, a2], dtype=np.float64)
@@ -2667,9 +2717,9 @@ class AudioPipeline:
feedback = params.get("feedback", 0.3) feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4) mix = params.get("mix", 0.4)
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0) delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
max_d = max(delay_samples * 2, SAMPLE_RATE) max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
state["ping"] = 1 # 1 = left, -1 = right state["ping"] = 1 # 1 = left, -1 = right
@@ -2706,9 +2756,9 @@ class AudioPipeline:
} }
taps = tap_patterns.get(pattern, tap_patterns["quarter"]) taps = tap_patterns.get(pattern, tap_patterns["quarter"])
max_delay = int(max(taps) * SAMPLE_RATE / 1000.0) max_delay = int(max(taps) * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
max_d = max(max_delay * 2, SAMPLE_RATE) max_d = max(max_delay * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
@@ -2717,7 +2767,7 @@ class AudioPipeline:
# Sum all tap reads # Sum all tap reads
wet = np.zeros(len(buf), dtype=np.float32) wet = np.zeros(len(buf), dtype=np.float32)
for tap_ms in taps: for tap_ms in taps:
tap_samples = int(tap_ms * SAMPLE_RATE / 1000.0) tap_samples = int(tap_ms * self._sample_rate / 1000.0)
tap_sig = delay_line.read_block(float(tap_samples), len(buf)) tap_sig = delay_line.read_block(float(tap_samples), len(buf))
wet += tap_sig * (1.0 / len(taps)) # Normalise wet += tap_sig * (1.0 / len(taps)) # Normalise
@@ -2734,9 +2784,9 @@ class AudioPipeline:
feedback = params.get("feedback", 0.3) feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4) mix = params.get("mix", 0.4)
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0) delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
max_d = max(delay_samples * 2, SAMPLE_RATE) max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
state["read_pos"] = 0 state["read_pos"] = 0
@@ -2773,9 +2823,9 @@ class AudioPipeline:
feedback = params.get("feedback", 0.3) feedback = params.get("feedback", 0.3)
mix = params.get("mix", 0.4) mix = params.get("mix", 0.4)
delay_samples = int(time_ms * SAMPLE_RATE / 1000.0) delay_samples = int(time_ms * self._sample_rate / 1000.0)
if "delay" not in state: if "delay" not in state:
max_d = max(delay_samples * 2, SAMPLE_RATE) max_d = max(delay_samples * 2, self._sample_rate)
state["delay"] = _DelayLine(max_d + 1) state["delay"] = _DelayLine(max_d + 1)
state["delay"].write_block(np.zeros(max_d // 2)) state["delay"].write_block(np.zeros(max_d // 2))
@@ -2821,10 +2871,10 @@ class AudioPipeline:
if "combs" not in state: if "combs" not in state:
comb_delays = [29, 37, 44, 50, 31, 39, 47, 53] comb_delays = [29, 37, 44, 50, 31, 39, 47, 53]
ap_delays = [5, 7, 11, 13] ap_delays = [5, 7, 11, 13]
state["combs"] = [_CombFilter(int(d * SAMPLE_RATE / 1000.0)) for d in comb_delays] state["combs"] = [_CombFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size) for d in comb_delays]
state["allpasses"] = [_AllpassFilter(int(d * SAMPLE_RATE / 1000.0)) for d in ap_delays] state["allpasses"] = [_AllpassFilter(int(d * self._sample_rate / 1000.0), block_size=self._block_size) for d in ap_delays]
state["predelay"] = _DelayLine(int(30.0 * SAMPLE_RATE / 1000.0 + 1)) state["predelay"] = _DelayLine(int(30.0 * self._sample_rate / 1000.0 + 1))
state["predelay"].write_block(np.zeros(int(30.0 * SAMPLE_RATE / 1000.0))) state["predelay"].write_block(np.zeros(int(30.0 * self._sample_rate / 1000.0)))
combs: list[_CombFilter] = state["combs"] combs: list[_CombFilter] = state["combs"]
allpasses: list[_AllpassFilter] = state["allpasses"] allpasses: list[_AllpassFilter] = state["allpasses"]
@@ -2839,7 +2889,7 @@ class AudioPipeline:
ap.gain = 0.3 + decay * 0.3 ap.gain = 0.3 + decay * 0.3
# Predelay # Predelay
delayed = predelay_line.read_block(30.0 * SAMPLE_RATE / 1000.0, len(buf)) delayed = predelay_line.read_block(30.0 * self._sample_rate / 1000.0, len(buf))
predelay_line.write_block(buf) predelay_line.write_block(buf)
# Comb + allpass reverb # Comb + allpass reverb
@@ -2854,7 +2904,7 @@ class AudioPipeline:
if shift != 0.0: if shift != 0.0:
factor = 2.0 ** (shift / 12.0) factor = 2.0 ** (shift / 12.0)
if "shift_ring" not in state: if "shift_ring" not in state:
grain_size = int(0.040 * SAMPLE_RATE) grain_size = int(0.040 * self._sample_rate)
state["shift_ring"] = np.zeros(grain_size * 2, dtype=np.float32) state["shift_ring"] = np.zeros(grain_size * 2, dtype=np.float32)
state["swpos"] = 0 state["swpos"] = 0
state["srpos"] = 0 state["srpos"] = 0
@@ -2892,7 +2942,7 @@ class AudioPipeline:
Controls: record, overdub, play, stop. Controls: record, overdub, play, stop.
State machine: idle -> recording -> playing -> overdub -> idle State machine: idle -> recording -> playing -> overdub -> idle
""" """
max_buffer = int(30.0 * SAMPLE_RATE) # 30 seconds max_buffer = int(30.0 * self._sample_rate) # 30 seconds
if "looper_buf" not in state: if "looper_buf" not in state:
state["looper_buf"] = np.zeros(max_buffer, dtype=np.float32) state["looper_buf"] = np.zeros(max_buffer, dtype=np.float32)
@@ -2970,10 +3020,10 @@ class AudioPipeline:
# Early reflection tap times (ms) scaled by room size # Early reflection tap times (ms) scaled by room size
base_taps = [5, 12, 20, 30, 45, 65] base_taps = [5, 12, 20, 30, 45, 65]
size_factor = 0.5 + room_size * 2.0 # 0.5-2.5x size_factor = 0.5 + room_size * 2.0 # 0.5-2.5x
taps = [int(t * size_factor * SAMPLE_RATE / 1000.0) for t in base_taps] taps = [int(t * size_factor * self._sample_rate / 1000.0) for t in base_taps]
if "delay" not in state: if "delay" not in state:
max_d = max(taps) + BLOCK_SIZE + 1 max_d = max(taps) + self._block_size + 1
state["delay"] = _DelayLine(max_d) state["delay"] = _DelayLine(max_d)
delay_line: _DelayLine = state["delay"] delay_line: _DelayLine = state["delay"]
@@ -2992,6 +3042,44 @@ class AudioPipeline:
return (buf * (1.0 - mix) + wet * mix).astype(np.float32) return (buf * (1.0 - mix) + wet * mix).astype(np.float32)
def set_audio_profile(self, block_size: int, sample_rate: int) -> None:
"""Update block size and sample rate at runtime.
Recomputes profile-dependent constants and clears DSP state so
effects reinitialise with the new buffer sizes on the next
process() call.
Called by the web server when the JACK latency profile changes
(``POST /api/audio/profile``). Follows the same pattern as
:meth:`NAMEngineRouter.set_block_size`.
Args:
block_size: JACK period size in frames (e.g. 64, 128, 256, 512).
sample_rate: Sample rate in Hz (e.g. 44100, 48000, 96000).
"""
changed = (self._block_size != block_size
or self._sample_rate != sample_rate)
if not changed:
return
self._block_size = block_size
self._sample_rate = sample_rate
self._vu_alpha = np.exp(-block_size / (0.05 * sample_rate))
# Clear DSP state — effects will reinit with new block/sample rate
self._state.clear()
self._coeffs.clear()
logger.info("Audio profile updated: block=%d, sr=%d",
block_size, sample_rate)
@property
def block_size(self) -> int:
"""Current JACK period / block size in frames."""
return self._block_size
@property
def sample_rate(self) -> int:
"""Current audio sample rate in Hz."""
return self._sample_rate
# ── Properties ───────────────────────────────────────────────── # ── Properties ─────────────────────────────────────────────────
@property @property
@@ -3041,6 +3129,13 @@ class AudioPipeline:
self._routing_breakpoint = max(0, value) self._routing_breakpoint = max(0, value)
logger.info("Routing breakpoint: %d", self._routing_breakpoint) logger.info("Routing breakpoint: %d", self._routing_breakpoint)
# ── Chain access (read-only for external inspection) ────────────
@property
def chain(self) -> list[dict]:
"""Read-only access to the DSP chain."""
return list(self._chain)
def set_routing(self, mode: str, breakpoint: int = 7) -> None: def set_routing(self, mode: str, breakpoint: int = 7) -> None:
"""Set 4CM routing configuration at runtime. """Set 4CM routing configuration at runtime.