4CM split routing: stereo pipeline + breakpoint + Web UI

Pipeline:
- process() dispatches to _process_mono() or _process_4cm() based on routing_mode
- _process_4cm() splits chain at routing_breakpoint: pre blocks on ch0, post on ch1
- _process_single_block() extracted for reuse in both mono and 4cm paths
- routing_mode/routing_breakpoint load from preset via load_preset()
- set_routing() for runtime configuration
- Properties: routing_mode (mono|4cm), routing_breakpoint with validation

Web server:
- GET /api/routing — current routing mode and breakpoint
- POST /api/routing — set routing mode/breakpoint, persist to current preset, WS broadcast
- _gather_state() includes routing_mode and routing_breakpoint

Web UI:
- Settings page: 4CM toggle + breakpoint slider with routing description
- Dashboard: routing badge (4CM/Mono) with breakpoint info
- app.js: WebSocket handler, updateRoutingUI(), toggle4cm(), set4cmBreakpoint()
- style.css: .badge, .badge-4cm, .badge-mono, .routing-status, .routing-desc

Tests: mock pipeline fixture updated with routing_mode/routing_breakpoint
This commit is contained in:
2026-06-08 10:57:47 -04:00
parent c2071a9724
commit 8ff584cea9
7 changed files with 382 additions and 47 deletions
+149 -46
View File
@@ -242,6 +242,10 @@ class AudioPipeline:
self._tuner_enabled: bool = False
self._bypassed: bool = False # Global bypass
# 4-Cable Method routing
self._routing_mode: str = "mono" # "mono" or "4cm"
self._routing_breakpoint: int = 7 # chain index where pre/post split occurs
# Per-block DSP state: {f"fx_{idx}": {state_dict}}
self._state: dict[str, dict] = {}
@@ -278,72 +282,139 @@ class AudioPipeline:
self._master_volume = preset.master_volume
self._tuner_enabled = preset.tuner_enabled
logger.info("Preset '%s' loaded: %d blocks", preset.name, len(self._chain))
# Set 4CM routing from preset
self._routing_mode = preset.routing_mode
self._routing_breakpoint = preset.routing_breakpoint
logger.info("Preset '%s' loaded: %d blocks, routing=%s breakpoint=%d",
preset.name, len(self._chain),
self._routing_mode, self._routing_breakpoint)
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the entire FX chain.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
Mono mode: shape (N,) — single audio channel.
4CM mode: shape (2, N) — two channels, [guitar_in, return_in].
Returns:
Processed audio block.
Mono mode: shape (N,) — processed output.
4CM mode: shape (2, N) — [send_out, return_out].
"""
if self._bypassed:
return audio_in * self._master_volume
if self._routing_mode == "4cm":
return self._process_4cm(audio_in)
else:
return self._process_mono(audio_in)
def _process_mono(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a mono block through the full chain (all blocks)."""
buf = audio_in.copy()
for idx, entry in enumerate(self._chain):
if entry["bypass"] or not entry["enabled"]:
continue
buf = self._process_single_block(buf, idx, entry)
return buf * self._master_volume
def _process_4cm(self, audio_in: np.ndarray) -> np.ndarray:
"""Process stereo block with 4CM split routing.
audio_in has shape (2, N):
ch0 = guitar input (Input 1)
ch1 = FX loop return (Input 2)
Splits at _routing_breakpoint:
pre blocks → ch0 processed through [0..breakpoint)
post blocks → ch1 processed through [breakpoint..]
Returns (2, N):
ch0 = Send output (to amp input)
ch1 = Return output (to amp FX return)
"""
ch0 = audio_in[0, :].copy()
ch1 = audio_in[1, :].copy()
bp = self._routing_breakpoint
for idx, entry in enumerate(self._chain):
if entry["bypass"] or not entry["enabled"]:
continue
fx_type = entry["fx_type"]
params = entry["params"]
fx_state = self._state.setdefault(f"fx_{idx}", {})
if idx < bp:
# Pre-amp block — process on guitar (ch0)
ch0 = self._process_single_block(ch0, idx, entry)
else:
# Post-amp block — process on return (ch1)
ch1 = self._process_single_block(ch1, idx, entry)
match fx_type:
case FXType.NOISE_GATE:
buf = self._apply_gate(buf, params, fx_state)
case FXType.COMPRESSOR:
buf = self._apply_compressor(buf, params, fx_state)
case FXType.BOOST:
buf = self._apply_boost(buf, params, fx_state)
case FXType.OVERDRIVE:
buf = self._apply_overdrive(buf, params, fx_state)
case FXType.DISTORTION:
buf = self._apply_distortion(buf, params, fx_state)
case FXType.FUZZ:
buf = self._apply_fuzz(buf, params, fx_state)
case FXType.EQ:
buf = self._apply_eq(buf, params, fx_state)
case FXType.CHORUS:
buf = self._apply_chorus(buf, params, fx_state)
case FXType.FLANGER:
buf = self._apply_flanger(buf, params, fx_state)
case FXType.PHASER:
buf = self._apply_phaser(buf, params, fx_state)
case FXType.TREMOLO:
buf = self._apply_tremolo(buf, params, fx_state)
case FXType.VIBRATO:
buf = self._apply_vibrato(buf, params, fx_state)
case FXType.DELAY:
buf = self._apply_delay(buf, params, fx_state)
case FXType.REVERB:
buf = self._apply_reverb(buf, params, fx_state)
case FXType.VOLUME:
buf = self._apply_volume(buf, params, fx_state)
case FXType.NAM_AMP:
if self.nam.is_loaded:
buf = self.nam.process(buf)
# Apply crossfade if a model switch is in progress
if self.nam._crossfade_buf is not None:
buf = self.nam.apply_crossfade(buf)
case FXType.IR_CAB:
if self.ir.is_loaded:
buf = self._apply_ir_cab(buf, params, fx_state)
out = np.zeros_like(audio_in)
out[0, :] = ch0 * self._master_volume
out[1, :] = ch1 * self._master_volume
return out
return buf * self._master_volume
def _process_single_block(self, buf: np.ndarray, idx: int,
entry: dict) -> np.ndarray:
"""Process a single mono audio block through one FX block.
Args:
buf: Mono audio block (N,) to process.
idx: Chain index for state lookup.
entry: Chain entry dict with fx_type, params.
Returns:
Processed mono block (N,).
"""
fx_type = entry["fx_type"]
params = entry["params"]
fx_state = self._state.setdefault(f"fx_{idx}", {})
match fx_type:
case FXType.NOISE_GATE:
return self._apply_gate(buf, params, fx_state)
case FXType.COMPRESSOR:
return self._apply_compressor(buf, params, fx_state)
case FXType.BOOST:
return self._apply_boost(buf, params, fx_state)
case FXType.OVERDRIVE:
return self._apply_overdrive(buf, params, fx_state)
case FXType.DISTORTION:
return self._apply_distortion(buf, params, fx_state)
case FXType.FUZZ:
return self._apply_fuzz(buf, params, fx_state)
case FXType.EQ:
return self._apply_eq(buf, params, fx_state)
case FXType.CHORUS:
return self._apply_chorus(buf, params, fx_state)
case FXType.FLANGER:
return self._apply_flanger(buf, params, fx_state)
case FXType.PHASER:
return self._apply_phaser(buf, params, fx_state)
case FXType.TREMOLO:
return self._apply_tremolo(buf, params, fx_state)
case FXType.VIBRATO:
return self._apply_vibrato(buf, params, fx_state)
case FXType.DELAY:
return self._apply_delay(buf, params, fx_state)
case FXType.REVERB:
return self._apply_reverb(buf, params, fx_state)
case FXType.VOLUME:
return self._apply_volume(buf, params, fx_state)
case FXType.NAM_AMP:
if self.nam.is_loaded:
processed = self.nam.process(buf)
if self.nam._crossfade_buf is not None:
processed = self.nam.apply_crossfade(processed)
return processed
return buf
case FXType.IR_CAB:
if self.ir.is_loaded:
return self._apply_ir_cab(buf, params, fx_state)
return buf
case _:
return buf
# ── LFO helpers ─────────────────────────────────────────────────
@@ -846,4 +917,36 @@ class AudioPipeline:
@tuner_enabled.setter
def tuner_enabled(self, value: bool) -> None:
self._tuner_enabled = value
self._tuner_enabled = value
# ── 4CM routing properties ────────────────────────────────────
@property
def routing_mode(self) -> str:
return self._routing_mode
@routing_mode.setter
def routing_mode(self, value: str) -> None:
if value not in ("mono", "4cm"):
raise ValueError(f"routing_mode must be 'mono' or '4cm', got {value!r}")
self._routing_mode = value
logger.info("Routing mode: %s", value)
@property
def routing_breakpoint(self) -> int:
return self._routing_breakpoint
@routing_breakpoint.setter
def routing_breakpoint(self, value: int) -> None:
self._routing_breakpoint = max(0, value)
logger.info("Routing breakpoint: %d", self._routing_breakpoint)
def set_routing(self, mode: str, breakpoint: int = 7) -> None:
"""Set 4CM routing configuration at runtime.
Args:
mode: ``\"mono\"`` or ``\"4cm\"``.
breakpoint: Chain index where pre/post split occurs.
"""
self.routing_mode = mode
self.routing_breakpoint = breakpoint