Build IR convolution engine

- Full FFT overlap-add IR convolution in IRLoader (process(), set_mix(), toggle)
- Lazy FFT computation — IR FFT padded to correct block+ir size on first process()
- Wet/dry mix control, enabled/disabled toggle with tail clearing
- Fixed pipeline._apply_ir_cab() to delegate to IRLoader.process() instead of
  poking internals (old code had array-size mismatch bug: IR FFT at ir_len vs
  block FFT at conv_size)
- 46 tests: loading, convolution correctness, overlap-add state, mix, toggle,
  directory listing, performance budget (all <5ms even at 8192 taps), edge cases
- scripts/download_irs.sh: free IR pack downloader (God's Cab, Seacow)
This commit is contained in:
2026-06-07 23:46:02 -04:00
parent c38a7b0fd8
commit 0e77adb4c3
10 changed files with 1939 additions and 498 deletions
+18 -37
View File
@@ -338,7 +338,7 @@ class AudioPipeline:
buf = self.nam.process(buf)
case FXType.IR_CAB:
if self.ir.is_loaded:
buf = self._apply_ir_cab(buf)
buf = self._apply_ir_cab(buf, params, fx_state)
return buf * self._master_volume
@@ -797,45 +797,26 @@ class AudioPipeline:
# ── 13. IR Cabinet Simulator ─────────────────────────────────────
def _apply_ir_cab(self, buf: np.ndarray) -> np.ndarray:
"""Apply IR convolution using FFT-based overlap-add.
def _apply_ir_cab(self, buf: np.ndarray, params: dict,
state: dict) -> np.ndarray:
"""Apply IR convolution via IRLoader.process().
Uses the IR loader's pre-computed FFT for efficient
block-based convolution. Handles the overlap-add state
internally.
Delegates to the IRLoader's FFT overlap-add engine.
Supports wet/dry mix control per-preset.
Params:
- ir_file: str (path to .wav IR) — already set via load_ir()
- enabled: bool
- wet: float 0.0-1.0
- dry: float 0.0-1.0
"""
if self.ir._ir_data is None or self.ir._ir_fft is None:
return buf
# Update mix from preset params
wet = params.get("wet", 1.0)
dry = params.get("dry", 0.0)
self.ir.set_mix(wet=wet, dry=dry)
self.ir.enabled = params.get("enabled", True) and not params.get("bypass", False)
ir_len = len(self.ir._ir_data)
block_len = len(buf)
# FFT block size: next power of 2 >= block + ir - 1
fft_len = 1
while fft_len < block_len + ir_len - 1:
fft_len <<= 1
# FFT of input block
block_fft = np.fft.rfft(buf, n=fft_len)
# Multiply in frequency domain
out_fft = block_fft * self.ir._ir_fft
# IFFT
convolved = np.fft.irfft(out_fft, n=fft_len)
# Overlap-add: keep previous tail if any
tail = getattr(self.ir, '_conv_tail', np.array([], dtype=np.float32))
if len(tail) > 0:
convolved[:len(tail)] += tail
# Save tail for next block
if ir_len > 1:
self.ir._conv_tail = convolved[block_len:block_len + ir_len - 1].copy()
else:
self.ir._conv_tail = np.array([], dtype=np.float32)
return np.clip(convolved[:block_len], -1.0, 1.0).astype(np.float32)
return self.ir.process(buf)
# ── Properties ─────────────────────────────────────────────────