diff --git a/src/web/server.py b/src/web/server.py
index ed96818..110e9da 100644
--- a/src/web/server.py
+++ b/src/web/server.py
@@ -997,6 +997,39 @@ class WebServer:
})
return {"ok": True, "channel_mode": mode}
+ # ── Per-channel output routing ────────────────────────────
+
+ @app.get("/api/channel-output")
+ async def get_channel_output():
+ """Get output routing for each channel."""
+ audio = self.deps.audio_system
+ if not audio or not audio.config:
+ return {"ch1": "both", "ch2": "both"}
+ cfg = audio.config.__dict__ if hasattr(audio.config, '__dict__') else {}
+ return {
+ "ch1": cfg.get("ch1_output", "both"),
+ "ch2": cfg.get("ch2_output", "both"),
+ }
+
+ @app.post("/api/channel-output")
+ async def set_channel_output(data: dict):
+ """Set output routing for a channel.
+
+ Body: { "ch": 1, "port": "playback_1"|"playback_2"|"both" }
+ """
+ ch = int(data.get("ch", 1))
+ port = data.get("port", "both")
+ if port not in ("playback_1", "playback_2", "both"):
+ raise HTTPException(status_code=400, detail=f"Invalid port: {port}")
+ audio = self.deps.audio_system
+ if audio and audio.config:
+ key = f"ch{ch}_output"
+ if hasattr(audio.config, 'input_device'):
+ setattr(audio.config, key, port)
+ # Reconnect JACK ports
+ audio.connect_fx_ports()
+ return {"ok": True, f"ch{ch}": port}
+
@app.get("/api/models")
async def list_models(channel: str = "guitar"):
"""List available NAM models for the given channel."""
diff --git a/src/web/ui-v2-dist/index.html b/src/web/ui-v2-dist/index.html
index fb40357..e16c3b7 100644
--- a/src/web/ui-v2-dist/index.html
+++ b/src/web/ui-v2-dist/index.html
@@ -300,7 +300,14 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
-
Output: Main L/R (Scarlett 1+2)
+
+ Output:
+
+
@@ -317,7 +324,14 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
-
Output: Main L/R (Scarlett 1+2)
+
+ Output:
+
+
Dual Mono: each channel has independent presets. Same instrument = shared presets.
@@ -491,6 +505,19 @@ async function setAudioOutputDevice(id){
hideLoading();
toast('Output: '+id);
}
+async function setChOutput(ch,port){
+ try{
+ await API._fetch('/api/channel-output',{method:'POST',body:JSON.stringify({ch,port})});
+ toast('CH'+ch+': '+port);
+ }catch(e){toast('Failed: '+e.message);}
+}
+async function loadChOutput(){
+ try{
+ const r=await API._fetch('/api/channel-output');
+ if(r.ch1){const el=document.getElementById('ch1Output');if(el)el.value=r.ch1;}
+ if(r.ch2){const el=document.getElementById('ch2Output');if(el)el.value=r.ch2;}
+ }catch(e){}
+}
function setChInstrument(idx,val){
const arr=JSON.parse(localStorage.getItem('chInstruments')||'["guitar","bass"]');
arr[idx]=val;
@@ -848,7 +875,7 @@ function init(){
btn.onclick=()=>{
const view=btn.dataset.view;
if(view==='main'){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');}
- else if(view==='settings'){openOverlay('Settings');loadNetworkInfo();loadSystemInfo();loadAudioParams();loadAudioDevices();loadHotspotStatus();loadBtStatus();loadBtMidi();return;}
+ else if(view==='settings'){openOverlay('Settings');loadNetworkInfo();loadSystemInfo();loadAudioParams();loadAudioDevices();loadChOutput();loadHotspotStatus();loadBtStatus();loadBtMidi();return;}
else if(view==='presets'){openOverlay('Presets');loadPresets();}
else if(view==='snapshots'){openOverlay('Snapshots');loadSnapshots();}
else if(view==='downloads')openOverlay('Downloads');
@@ -862,6 +889,7 @@ function init(){
loadSystemInfo();
loadAudioParams();
loadAudioDevices();
+ loadChOutput();
loadHotspotStatus();
loadBtStatus();
loadBtMidi();