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
+49
View File
@@ -356,6 +356,53 @@ class WebServer:
pl._master_volume = vol
return {"ok": True, "volume": vol}
# ── 4CM routing endpoints ────────────────────────────────
@app.get("/api/routing")
async def get_routing():
"""Get current routing mode and breakpoint."""
pl = self.deps.pipeline
if not pl:
raise HTTPException(status_code=503)
return {
"routing_mode": pl.routing_mode,
"routing_breakpoint": pl.routing_breakpoint,
}
@app.post("/api/routing")
async def set_routing(data: dict):
"""Set routing mode and/or breakpoint.
Body:
routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``.
routing_breakpoint (optional): chain index for split.
"""
pl = self.deps.pipeline
pm = self.deps.presets
if not pl:
raise HTTPException(status_code=503)
mode = data.get("routing_mode", pl.routing_mode)
bp = int(data.get("routing_breakpoint", pl.routing_breakpoint))
pl.set_routing(mode, bp)
# Persist to current preset if preset manager is available
if pm:
try:
bank = pm.current_bank
program = pm.current_program
preset = pm.load(bank, program)
pm.set_4cm_config(preset, mode=mode, breakpoint=bp)
except Exception:
pass
await self._manager.broadcast({
"type": "routing_changed",
"routing_mode": pl.routing_mode,
"routing_breakpoint": pl.routing_breakpoint,
})
return {"ok": True, "routing_mode": pl.routing_mode,
"routing_breakpoint": pl.routing_breakpoint}
@app.get("/api/models")
async def list_models():
"""List available NAM models."""
@@ -578,6 +625,8 @@ class WebServer:
"bypass": pl._bypassed if pl else False,
"tuner_enabled": pl._tuner_enabled if pl else False,
"master_volume": pl._master_volume if pl else 0.8,
"routing_mode": pl.routing_mode if pl else "mono",
"routing_breakpoint": pl.routing_breakpoint if pl else 7,
"nam_loaded": bool(nam and nam.is_loaded) if nam else False,
"nam_model": current_model_name,
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
+73
View File
@@ -80,6 +80,74 @@ pedalWS.on('tuner_changed', (msg) => {
}
});
pedalWS.on('routing_changed', (msg) => {
updateRoutingUI(msg.routing_mode, msg.routing_breakpoint);
});
/* ── 4CM Routing UI ────────────────────────────────────────────── */
function updateRoutingUI(mode, breakpoint) {
// Dashboard badge
const indicator = document.getElementById('routing-mode-indicator');
const info = document.getElementById('routing-info');
if (indicator) {
indicator.setAttribute('data-mode', mode);
if (mode === '4cm') {
indicator.innerHTML = '<span class="badge badge-4cm">4CM</span>';
} else {
indicator.innerHTML = '<span class="badge badge-mono">Mono</span>';
}
}
if (info) {
info.textContent = mode === '4cm' ? `Breakpoint: ${breakpoint}` : 'Full chain';
}
// Settings page controls
const toggleBtn = document.getElementById('4cm-toggle-btn');
if (toggleBtn) {
toggleBtn.textContent = mode === '4cm' ? 'ON' : 'OFF';
toggleBtn.classList.toggle('active', mode === '4cm');
}
const bpRow = document.getElementById('breakpoint-row');
if (bpRow) {
bpRow.style.opacity = mode === '4cm' ? '1' : '0.4';
bpRow.style.pointerEvents = mode === '4cm' ? 'auto' : 'none';
}
const bpSlider = document.getElementById('4cm-breakpoint');
const bpText = document.getElementById('4cm-breakpoint-text');
if (bpSlider) bpSlider.value = breakpoint;
if (bpText) bpText.textContent = breakpoint;
const desc = document.getElementById('4cm-routing-desc');
if (desc) {
if (mode === '4cm') {
desc.innerHTML = `Input 1 (Guitar) → Pre blocks [0..${breakpoint}) → Send &nbsp;|&nbsp; Input 2 (Return) → Post blocks [${breakpoint}..] → Output`;
} else {
desc.textContent = 'Mono: Guitar → Full chain → Output';
}
}
}
async function toggle4cm() {
const btn = document.getElementById('4cm-toggle-btn');
const currentMode = btn && btn.classList.contains('active') ? '4cm' : 'mono';
const newMode = currentMode === '4cm' ? 'mono' : '4cm';
try {
await apiPost('/routing', { routing_mode: newMode });
} catch (e) {
console.warn('Failed to toggle 4CM:', e);
}
}
async function set4cmBreakpoint(val) {
const bp = parseInt(val);
try {
await apiPost('/routing', { routing_breakpoint: bp });
} catch (e) {
console.warn('Failed to set breakpoint:', e);
}
}
function updateDashboardState(state) {
if (!state || !state.connected) return;
@@ -128,6 +196,11 @@ function updateDashboardState(state) {
// IR
const irNameEl = document.getElementById('ir-name');
if (irNameEl) irNameEl.textContent = state.ir_name || 'None loaded';
// 4CM Routing
if (state.routing_mode) {
updateRoutingUI(state.routing_mode, state.routing_breakpoint);
}
}
/* ── Dashboard action handlers ─────────────────────────────────── */
+44 -1
View File
@@ -651,4 +651,47 @@ body {
.mb-8 { margin-bottom: 8px; }
.flex { display: flex; }
.flex-1 { flex: 1; }
.gap-8 { gap: 8px; }
.gap-8 { gap: 8px; }
/* ── Routing / 4CM ────────────────────────────────────────────────── */
.routing-status {
display: flex;
align-items: center;
gap: 10px;
}
.routing-indicator {
display: inline-flex;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.badge-4cm {
background: var(--warning);
color: #1a1a1a;
}
.badge-mono {
background: var(--text-muted);
color: var(--text-primary);
}
.routing-info {
font-size: 0.82rem;
color: var(--text-secondary);
}
.routing-desc {
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.4;
}
+25
View File
@@ -85,5 +85,30 @@
</div>
</div>
</div>
<!-- 4CM Routing Status -->
<div class="card">
<div class="card-header">
<h2>Routing</h2>
</div>
<div class="card-body">
<div class="routing-status">
<span class="routing-indicator" id="routing-mode-indicator" data-mode="{{ routing_mode }}">
{% if routing_mode == '4cm' %}
<span class="badge badge-4cm">4CM</span>
{% else %}
<span class="badge badge-mono">Mono</span>
{% endif %}
</span>
<span class="routing-info" id="routing-info">
{% if routing_mode == '4cm' %}
Breakpoint: {{ routing_breakpoint }}
{% else %}
Full chain
{% endif %}
</span>
</div>
</div>
</div>
</div>
{% endblock %}
+40
View File
@@ -52,6 +52,46 @@
</div>
</div>
<div class="card">
<div class="card-header">
<h2>4-Cable Method (4CM)</h2>
</div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">4CM Mode</div>
<div class="setting-value">
<button class="btn toggle-btn {% if routing_mode == '4cm' %}active{% endif %}"
id="4cm-toggle-btn" onclick="toggle4cm()">
{% if routing_mode == '4cm' %}ON{% else %}OFF{% endif %}
</button>
</div>
</div>
<div class="setting-row" id="breakpoint-row"
{% if routing_mode != '4cm' %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
<div class="setting-label">Breakpoint</div>
<div class="setting-value">
<input type="range" class="slider" id="4cm-breakpoint"
min="0" max="16" value="{{ routing_breakpoint }}"
oninput="set4cmBreakpoint(this.value)">
<span id="4cm-breakpoint-text">{{ routing_breakpoint }}</span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Routing</div>
<div class="setting-value">
<span id="4cm-routing-desc" class="routing-desc">
{% if routing_mode == '4cm' %}
Input 1 (Guitar) → Pre blocks [0..{{ routing_breakpoint }}) → Send
&nbsp;|&nbsp; Input 2 (Return) → Post blocks [{{ routing_breakpoint }}..] → Output
{% else %}
Mono: Guitar → Full chain → Output
{% endif %}
</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>About</h2>