Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Each instrument gets its own wavelength colour and a **live waveform of its own

- **Live layer cards** (`d1`–`d16`) — each shows its code, a plain-English explanation, per-layer knobs, and a **live waveform of that channel's own audio**
- **Per-channel oscilloscope** — every card draws a real, phase-locked waveform tapped from its own voice; plus a master L/R meter and a **wavelength-coloured spectrum** (low freq red → high freq blue)
- **Mixer view** with all 12 channels as strips: a volume fader, pan, reverb + delay sends, mute/solo, a meter, and a live mini-scope each
- **Step sequencer** with **per-step velocity** (scroll a pad), **drag-to-paint** + right-click erase, swing, and a playhead **locked to the audio** so each lit step flashes exactly when you hear it
- **Pattern slots (A/B/C/D)** you can chain into a song that advances each bar
- **Drag-and-drop sample browser** — drag a sound onto a channel to swap it; drag channels to reorder
Expand Down
17 changes: 17 additions & 0 deletions mcp/dashboard-mixer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Ambient types for dashboard-mixer.js (window.AbxMixer), the mixer module. The browser loads it
// as a classic <script>; Node tests import it for side-effect and read globalThis.AbxMixer. Only
// the pure math is typed here (the tested surface); the DOM/UI methods are runtime-only.
export interface AbxMixerApi {
/** Fader position 0..1 -> SuperDirt gain. dB-linear; unity at 0.75; top (1.0) = 2.0; <=0.02 = silence. */
faderToGain(p: number): number;
/** Inverse of faderToGain; out-of-range gains park at the nearest fader end. */
gainToFader(g: number): number;
/** Pan position 0..1 -> formatted pan value string (0.5 = centre). */
panPositionToString(p: number): string;
/** Parse a pan value string back to position; clamps to 0..1, centre on garbage. */
panStringToPosition(s: string): number;
}
declare global {
// eslint-disable-next-line no-var
var AbxMixer: AbxMixerApi;
}
140 changes: 140 additions & 0 deletions mcp/dashboard-mixer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// dashboard-mixer.js — mixer drawer (window.AbxMixer): vertical channel strips with a fader,
// pan, two FX sends (reverb/delay), mute/solo, a meter, and a mini-scope.
//
// CONSTRAINT: 12 strips, not 16. Driven by the SuperDirt boot config (numOrbits = 12 in
// sc/superdirt_startup.scd). d13-d16 exist in Tidal but have NO orbit, so no per-channel
// reverb/delay (room/delay are per-orbit effects) and no scope. Do NOT raise MIX_CH to 16
// without first raising SuperDirt's numOrbits, or you get four dead strips.
//
// The pure math (faderToGain/gainToFader/pan*) is single-sourced here and unit-tested via
// mixer.test.ts (which imports this file under Node). The DOM/UI is guarded by `typeof document`
// so that Node import stays clean. The browser loads this as a classic <script> AFTER dashboard.js
// (consumes Abx.*) and dashboard-scope.js (reuses AbxScope.draw).
(function(){
// ---------------- pure math (single source; tested by mixer.test.ts) ----------------
// Top of travel is a true doubling (+6.02 dB) so the fader top equals the card gain knob's max (2.0).
var TOP_DB = 20 * Math.log10(2);
function faderToGain(p){
// Deadband: the bottom 2% is HARD silence, not -54 dB bleeding through. A fader resting near
// the bottom should be silent. (This makes the round-trip lossy below 0.02 by design; see below.)
if (p <= 0.02) return 0;
var dB = p <= 0.75 ? (p / 0.75) * 60 - 60 : (p - 0.75) / 0.25 * TOP_DB;
return Math.pow(10, dB / 20);
}
function gainToFader(g){
// Inverse of faderToGain. The deadband is lossy by design: gainToFader(faderToGain(0.01)) === 0,
// NOT 0.01 -- do not "fix" the round-trip below the deadband, or you reintroduce the silent-leak bug.
// Out-of-range gains park at the nearest valid end: gain is never < 0 in valid Tidal, and the fader
// cannot represent > max, so clamping (NaN included -> bottom) is the right answer, not a junk position.
if (!(g > 0)) return 0;
var dB = 20 * Math.log10(g);
var f = dB <= 0 ? (dB + 60) / 60 * 0.75 : 0.75 + (dB / TOP_DB) * 0.25;
return Math.max(0, Math.min(1, f));
}
function clamp01(v){ return v < 0 ? 0 : v > 1 ? 1 : v; }
function panPositionToString(p){ return clamp01(p).toFixed(2); }
// garbage-in-centred-out: an unparseable pan defaults to centre (0.5); left or right would surprise.
function panStringToPosition(s){ var v = parseFloat(s); return isNaN(v) ? 0.5 : clamp01(v); }

// ---------------- UI (browser only) ----------------
var MIX_CH = 12; // see CONSTRAINT above
var GAIN_DP = 4; // gain is written with this many decimals (clean code; the wiring test mirrors it)
var LABEL = { bd:"kick", sn:"snare", sd:"snare", hh:"hat", hc:"hat", ho:"hat", cp:"clap", cr:"crash", rim:"rim", perc:"perc", arpy:"pluck", jvbass:"bass", bass:"bass", wind:"atmos", "808":"808" };
var mdisp = {}; // eased meter level per slot (matches the master meter's feel)

function labelFor(code){
if (!code) return "";
var syn = code.match(/(?:s|sound)\s+"(super\w+|default)"/); // a synth (noteful layer)
if (syn) return syn[1].replace(/^super/, "");
var sm = code.match(/(?:s|sound)\s+"([a-z0-9]+)/i); // first sample token
if (sm) return LABEL[sm[1]] || sm[1];
return "";
}
function meterVal(slot){ var sc = (Abx.state().scopes || {})[slot]; return sc ? Math.min(1, sc.peak || 0) : 0; }

function stripHTML(slot){
var st = Abx.state(), code = (st.slots || {})[slot], active = !!code, col = Abx.color.chan(slot);
var muted = (st.muted || []).indexOf(slot) >= 0, solo = st.solo === slot;
var gain = active ? Abx.fnum(code, "gain") : null; if (gain == null) gain = 1;
var pan = active ? Abx.fnum(code, "pan") : null; if (pan == null) pan = 0.5;
var room = active ? Abx.fnum(code, "room") : null; if (room == null) room = 0;
var dly = active ? Abx.fnum(code, "delay"): null; if (dly == null) dly = 0;
var dis = active ? "" : " disabled"; // ghost strips render the layout but their controls are inert
return '<div class="strip' + (active ? "" : " empty") + (muted ? " muted" : "") + (solo ? " solo" : "") + '" data-slot="' + slot + '" style="--rc:' + col + '">'
+ '<div class="striphd"><span class="slot" style="color:' + col + '">' + slot + '</span><span class="lbl">' + Abx.esc(labelFor(code)) + '</span></div>'
+ '<canvas class="mixscope" data-slot="' + slot + '" height="40"></canvas>'
+ '<div class="sends">'
+ '<label class="send">FX1<input type="range" class="fx1" data-slot="' + slot + '" min="0" max="1" step="0.02" value="' + room + '"' + dis + ' title="reverb send (room)"></label>'
+ '<label class="send">FX2<input type="range" class="fx2" data-slot="' + slot + '" min="0" max="1" step="0.02" value="' + dly + '"' + dis + ' title="delay send"></label>'
+ '</div>'
+ '<div class="panrow"><input type="range" class="pan" data-slot="' + slot + '" min="0" max="1" step="0.02" value="' + panPositionToString(pan) + '"' + dis + ' title="pan (0.5 = centre)"></div>'
+ '<div class="fadarea"><span class="vmeter"><i></i></span>'
+ '<input type="range" class="fader" data-slot="' + slot + '" min="0" max="1" step="0.005" value="' + gainToFader(gain) + '"' + dis + ' title="volume (dB fader, unity at 75%)"></div>'
+ '<div class="msbtns">'
+ '<button class="ms' + (muted ? " on" : "") + '" data-cmd="' + (muted ? "unmute" : "mute") + '" data-slot="' + slot + '"' + dis + ' title="mute">M</button>'
+ '<button class="ms' + (solo ? " on" : "") + '" data-cmd="' + (solo ? "unsolo" : "solo") + '" data-slot="' + slot + '"' + dis + ' title="solo">S</button>'
+ '</div></div>';
}
function render(){
var wrap = document.getElementById("mixrows"); if (!wrap) return;
var h = ""; for (var i = 1; i <= MIX_CH; i++) h += stripHTML("d" + i);
wrap.innerHTML = h;
}
function isOpen(){ var w = document.getElementById("mixwrap"); return !!(w && w.classList.contains("open")); }
function openMixer(){
var w = document.getElementById("mixwrap"); if (!w) return;
var nowOpen = !w.classList.contains("open");
w.classList.toggle("open", nowOpen);
var b = document.getElementById("mixBtn"); if (b) b.classList.toggle("on", nowOpen);
if (nowOpen) render(); // CSS handles the slide; we only build the strips on open
}

// write a param via the same cmd:set path the card knobs use; ghost strips have nothing to write to.
function setParam(slot, param, value){
if (!((Abx.state().slots || {})[slot])) return;
Abx.send({ cmd: "set", slot: slot, param: param, value: value });
}
// delegates (mirror the AbxSeq/AbxCurves contract). M/S go through the core data-cmd dispatcher
// (shared with the cards), so mute/solo stay in sync both ways for free.
function handleInput(e){
var el = e.target, slot = el.dataset && el.dataset.slot; if (!slot) return false;
if (el.classList.contains("fader")) { setParam(slot, "gain", +faderToGain(+el.value).toFixed(GAIN_DP)); return true; }
if (el.classList.contains("pan")) { setParam(slot, "pan", panStringToPosition(el.value)); return true; }
if (el.classList.contains("fx1")) { setParam(slot, "room", +(+el.value).toFixed(2)); return true; }
if (el.classList.contains("fx2")) { setParam(slot, "delay", +(+el.value).toFixed(2)); return true; }
return false;
Comment on lines +92 to +105
}
function handleClick(b){ if (b.dataset.act === "mixer") { openMixer(); return true; } return false; }

// re-render strips when the live slot set / mute / solo changes (called from dashboard.js render()).
// Keyed on structure only (not per-value), so dragging a fader does not yank itself mid-gesture.
var lastSig = "";
function maybeRerender(){
if (!isOpen()) return;
var st = Abx.state();
var sig = JSON.stringify({ s: Object.keys(st.slots || {}), m: st.muted, so: st.solo });
Comment on lines +109 to +115
if (sig !== lastSig) { lastSig = sig; render(); }
}
// animate meters + mini-scopes while the drawer is open (reuses AbxScope.draw; own rAF, idle when shut)
function tick(){
if (isOpen()) {
var strips = document.querySelectorAll("#mixrows .strip");
for (var i = 0; i < strips.length; i++) {
var slot = strips[i].dataset.slot;
var tgt = meterVal(slot);
mdisp[slot] = (mdisp[slot] || 0) + (tgt - (mdisp[slot] || 0)) * 0.4; // master-meter feel
var bar = strips[i].querySelector(".vmeter i"); if (bar) bar.style.height = (Math.sqrt(mdisp[slot]) * 100).toFixed(1) + "%";
var cv = strips[i].querySelector(".mixscope"); if (cv) AbxScope.draw(cv, slot);
}
}
requestAnimationFrame(tick);
}

if (typeof document !== "undefined") { // DOM wiring only in the browser; the Node test imports the math
requestAnimationFrame(tick);
}
(typeof window !== "undefined" ? window : globalThis).AbxMixer = {
faderToGain: faderToGain, gainToFader: gainToFader, panPositionToString: panPositionToString, panStringToPosition: panStringToPosition,
openMixer: openMixer, render: render, handleInput: handleInput, handleClick: handleClick, maybeRerender: maybeRerender
};
})();
35 changes: 35 additions & 0 deletions mcp/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@
#consoleIn{flex:1;background:#010402;color:#bff7c9;border:1px solid var(--line);border-radius:4px;padding:7px 10px;
font:13px/1.5 ui-monospace,Consolas,monospace;resize:none;height:34px;}
#consoleIn:focus{outline:none;border-color:var(--green);box-shadow:0 0 8px rgba(0,255,94,.2);}
/* ---- mixer drawer (bottom, collapsible; CSS height transition = no re-render) ---- */
#mixwrap{max-height:0;overflow:hidden;transition:max-height .22s ease;background:rgba(4,10,6,.92);}
#mixwrap.open{max-height:440px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);}
.mixbar{display:flex;align-items:center;gap:12px;padding:8px 16px;border-bottom:1px solid var(--line);}
.mixbar .seqlabel{color:var(--green);font-size:11px;letter-spacing:.6px;text-transform:uppercase;}
#mixrows{display:flex;gap:6px;padding:10px 16px;overflow-x:auto;}
.strip{flex:0 0 76px;display:flex;flex-direction:column;align-items:center;gap:5px;padding:7px 5px;
border:1px solid var(--line);border-radius:4px;background:rgba(8,20,12,.6);}
.strip.empty{opacity:.3;}
.strip.muted{border-color:var(--amber);}
.strip.solo{border-color:var(--green);box-shadow:0 0 8px rgba(0,255,94,.25);}
.striphd{display:flex;flex-direction:column;align-items:center;line-height:1.2;}
.striphd .slot{font-weight:700;font-size:12px;}
.striphd .lbl{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.4px;height:11px;overflow:hidden;}
.mixscope{width:64px;height:40px;background:#020503;border:1px solid var(--line);border-radius:2px;display:block;}
.strip .sends{display:flex;gap:4px;width:100%;}
.strip .send{flex:1;display:flex;flex-direction:column;align-items:center;color:var(--muted);font-size:8px;gap:2px;}
.strip .send input[type=range]{width:30px;}
.strip .panrow{width:100%;display:flex;justify-content:center;}
.strip .panrow input[type=range]{width:58px;}
.fadarea{display:flex;align-items:flex-end;gap:5px;height:150px;padding:3px 0;}
.vmeter{width:5px;height:100%;background:#020503;border:1px solid var(--line);border-radius:2px;display:flex;align-items:flex-end;overflow:hidden;}
.vmeter i{display:block;width:100%;height:0%;background:linear-gradient(0deg,var(--gdim),var(--green) 70%,var(--amber));}
.fadarea input[type=range].fader{writing-mode:vertical-lr;direction:rtl;width:6px;height:100%;}
.msbtns{display:flex;gap:4px;}
.msbtns .ms{padding:3px 7px;font-size:10px;}
.msbtns .ms.on{border-color:var(--green);color:var(--green);}
</style>
</head>
<body>
Expand Down Expand Up @@ -187,6 +214,7 @@
<button data-act="cheats">&#128214; Cheat sheet</button>
<button data-act="steps">&#127929; Step grid</button>
<button data-act="curves">&#12316; Curves</button>
<button data-act="mixer" id="mixBtn">&#127898; Mixer</button>
<span class="sep"></span>
<div class="deckgroup"><span style="color:var(--muted);font-size:12px" title="audio output">&#128264;</span><select id="audioSelect" title="switch audio output (speakers / headphones)" style="max-width:185px"><option value="">audio out…</option></select></div>
</div>
Expand Down Expand Up @@ -231,6 +259,12 @@
</div>
</div>
<main><div class="grid" id="grid"></div></main>
<div id="mixwrap">
<div class="mixbar"><span class="seqlabel">&#127898; MIXER</span>
<span class="seqhint">12 channels (orbits) · fader = volume (dB, unity at 75%) · FX1 = reverb send · FX2 = delay send · pan · M/S. Empty channels are ghosted.</span>
</div>
<div id="mixrows"></div>
</div>
<div id="console">
Comment on lines 261 to 268
<div id="consoleOut">type Tidal &amp; press Enter — e.g. d1 $ s "bd sn hh cp" · hush = stop · d3 silence = clear layer 3</div>
<div id="consoleRow"><span class="prompt">&gt;_</span>
Expand All @@ -243,5 +277,6 @@
<script src="dashboard-cheats.js"></script>
<script src="dashboard-curves.js"></script>
<script src="dashboard-seq.js"></script>
<script src="dashboard-mixer.js"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions mcp/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
if(el.type==="range") lastInput=Date.now(); // suppress card re-render while dragging any slider
if(window.AbxSeq && AbxSeq.handleInput(e)) return; // seqname / seqvol / seqSwing
if(el.type!=="range")return;
if(window.AbxMixer && AbxMixer.handleInput(e)) return; // mixer fader / pan / FX1 / FX2 (range + data-slot, no data-param)
if(el.dataset.tempo!==undefined){ document.getElementById("bpm").textContent=el.value; pendingTempo=+el.value; return; }
if(el.dataset.slot){ var vb=document.getElementById("v-"+el.dataset.slot+"-"+el.dataset.param); if(vb)vb.textContent=el.value;
pending[el.dataset.slot+"|"+el.dataset.param]={slot:el.dataset.slot,param:el.dataset.param,value:+el.value}; }
Expand All @@ -210,6 +211,7 @@
if(!b)return;
if(window.AbxSeq && AbxSeq.handleClick(b)) return; // steps / seqadd / seqclear / row mute+clear / pattern / song
if(window.AbxCurves && AbxCurves.handleClick(b)) return; // curves panel / curve toggle+preset
if(window.AbxMixer && AbxMixer.handleClick(b)) return; // mixer drawer toggle (strip M/S use data-cmd, shared with cards)
if(b.dataset.act==="run"){ runCode(); return; }
if(b.dataset.act==="boot"){ var o=document.getElementById("consoleOut"); o.textContent="booting the sound engine… (~30-40s) — watch the status light top-left"; o.className=""; send({cmd:"boot"}).then(function(){poll();}); return; }
if(b.dataset.act==="reset"){ if(confirm("Reboot the engine? Wipes everything, fresh start (~30s).")) cmd("reset"); return; }
Expand Down Expand Up @@ -318,6 +320,7 @@
}
grid.innerHTML=h;
if(window.AbxCurves) AbxCurves.maybeRerender(keys);
if(window.AbxMixer) AbxMixer.maybeRerender();
}
function poll(){ fetch("/state").then(function(r){return r.json();}).then(render).catch(function(){
document.getElementById("engstate").textContent="disconnected"; document.getElementById("engdot").className="dot error"; }); }
Expand Down
2 changes: 1 addition & 1 deletion mcp/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { dirname, join } from "node:path";
import { run } from "node:test";
import { spec } from "node:test/reporters";

const FLOOR = 19; // current test count on this branch (track 7 + dsp 12)
const FLOOR = 31; // current test count: track 7 + dsp 12 + mixer 12 (raised with the mixer tests)

const distDir = join(dirname(fileURLToPath(import.meta.url)), "dist");
let files = [];
Expand Down
Loading
Loading