Merge remote-tracking branch 'origin/main' into workshop/synth-fw-audit
# Conflicts: # nisps/modes/base.hpp
This commit is contained in:
commit
c45aff4411
21 changed files with 1595 additions and 200 deletions
|
|
@ -58,7 +58,8 @@ firmware/MEMLNaut-NISPS/
|
|||
│ ├── midi_io.hpp # MIDI in → mode handlers; drain ControlEvent ring → MIDI UART
|
||||
│ ├── mode_select.hpp # type aliases firmware mode name → nisps::modes::*Mode
|
||||
│ ├── input_router.hpp # wire_inputs() entry point
|
||||
│ └── output_router.hpp # drain_outputs() entry point
|
||||
│ ├── output_router.hpp # drain_outputs() entry point
|
||||
│ └── settings_view.hpp # wire_settings(): TFT/rotary menu (Joystick Dual/Single for 4-in modes)
|
||||
└── src/{memllib,daisysp,nisps} # symlinks (Arduino-CLI sketch tree convention)
|
||||
```
|
||||
|
||||
|
|
|
|||
1
MAP.md
1
MAP.md
|
|
@ -23,6 +23,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
|
|||
- `mode_select.hpp` — type aliases mapping firmware mode identifiers to `nisps::modes::*Mode` C++ types. Build script rewrites the active line. Includes the six `MEMLNautModeExtSynth*` external-synth variants (one per device template in `nisps/midi`, e.g. `MEMLNautModeExtSynthSub37`). Also defines the `MEMLNautModeSelfTest` pseudo-variant (tag type) + the `NISPS_ST_*`/`NISPS_ST_CAT` token-paste macros the `.ino` uses to compute `NISPS_SELFTEST`. Note: `src/nisps/` exposes each referenced top-level nisps subdir as a symlink — `midi` was added alongside `core/dsp/engines/ml/modes`.
|
||||
- `selftest.hpp` — standalone guided hardware self-test rig (`SelfTest` variant; no engine/ML). Step-driven state machine on a `SelfTestView`: TFT prompts the operator through every control, auto-advances on detection, encoder-press skips. Ends with optional L/R/BOTH sine-sweep headphone check (core 1 block callback) + MIDI loopback-cable test. Lives firmware-side (touches TFT + raw pins) so it stays out of platform-agnostic `nisps/`.
|
||||
- `input_router.hpp`, `output_router.hpp` — top-level `wire_inputs()` / `drain_outputs()` entry points.
|
||||
- `settings_view.hpp` — `wire_settings(mode)`: adds on-device settings views to the MEMLNaut display carousel (TFT + rotary encoder). Joystick Dual/Single toggle for the 4-input ("two 2-D joystick") modes — "Single" pins ML input channels 2,3 to neutral via `ModeBase::set_input_pinned` (no net rebuild). Registered in the `.ino` after `addSystemInfoView()`.
|
||||
- `firmware/MEMLNaut-NISPS/src/{memllib,daisysp,nisps}` — symlinks (Arduino-CLI requires sketch-tree includes; preprocessor refuses `..` in headers).
|
||||
- `firmware/README.md` — structure + build instructions.
|
||||
|
||||
|
|
|
|||
484
assets/media/learn/index.html
Normal file
484
assets/media/learn/index.html
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Manifold — the parameter landscape</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0d0d0d; --bg-1:#141414; --bg-2:#1c1c1c; --bg-3:#242424;
|
||||
--fg:#e8e8e8; --fg-mute:#9a9a9a; --fg-dim:#5a5a5a;
|
||||
--line:#2a2a2a; --line-strong:#3a3a3a;
|
||||
--accent:#ff6a00; --accent-2:#00ccff; --good:#6bc26b; --bad:#5aa6c8;
|
||||
--font-mono:'JetBrains Mono','Fira Code','SF Mono',Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
|
||||
font-family:var(--font-mono);font-size:14px;-webkit-font-smoothing:antialiased;overflow:hidden}
|
||||
|
||||
#app{position:fixed;inset:0}
|
||||
#scene{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
|
||||
#scene:active{cursor:grabbing}
|
||||
|
||||
/* floating HUD blocks */
|
||||
.hud{position:absolute;z-index:5}
|
||||
/* three zones, left → right: input · MLP stack · outputs */
|
||||
.hud-l{left:24px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:10px;align-items:flex-start}
|
||||
.hud-r{right:24px;top:50%;transform:translateY(-50%)}
|
||||
.hud-bc{left:50%;bottom:22px;transform:translateX(-50%)}
|
||||
.hud-tr{right:18px;top:18px}
|
||||
.zonecap{font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--fg-dim);margin-bottom:2px}
|
||||
|
||||
/* output fader bank (1-D projection of each layer's value at the point) */
|
||||
.outs{display:flex;gap:13px;align-items:flex-start;background:rgba(20,20,20,.55);
|
||||
border:1px solid var(--line);border-radius:12px;padding:12px 14px;backdrop-filter:blur(4px)}
|
||||
.fader{display:flex;flex-direction:column;align-items:center;gap:6px;width:30px}
|
||||
.fader .track{position:relative;width:8px;height:200px;background:var(--bg-3);
|
||||
border:1px solid var(--line);border-radius:999px;overflow:hidden}
|
||||
.fader .fill{position:absolute;left:0;right:0;bottom:0;border-radius:999px;transition:height .05s linear}
|
||||
.fader .handle{position:absolute;left:50%;width:16px;height:3px;border-radius:2px;
|
||||
transform:translate(-50%,1.5px);box-shadow:0 0 8px currentColor}
|
||||
.fader .flab{font-size:9px;letter-spacing:.03em;text-transform:uppercase;max-width:34px;text-align:center;line-height:1.1}
|
||||
.fader .fval{font-size:10px;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||
|
||||
/* XY pad */
|
||||
#ndpad{display:block;border:1px solid var(--line);border-radius:8px;background:var(--bg-2);
|
||||
cursor:crosshair;touch-action:none}
|
||||
.padcap{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--fg-dim)}
|
||||
|
||||
/* parameter numberbox */
|
||||
.kbox{display:flex;align-items:center;gap:8px;background:var(--bg-1);border:1px solid var(--line);
|
||||
border-radius:8px;padding:5px 8px;width:fit-content}
|
||||
.kbox .lab{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--fg-mute)}
|
||||
.kbox .val{min-width:1.4ch;text-align:center;color:var(--fg);font-size:15px;font-variant-numeric:tabular-nums}
|
||||
.step{width:24px;height:24px;display:grid;place-items:center;padding:0;font-size:15px;line-height:1;
|
||||
background:var(--bg-2);border:1px solid var(--line-strong);border-radius:6px;color:var(--fg);cursor:pointer}
|
||||
.step:hover{background:var(--bg-3)}
|
||||
.step:disabled{opacity:.3;cursor:default}
|
||||
|
||||
/* action bar */
|
||||
.actionbar{display:flex;align-items:center;gap:8px;background:rgba(20,20,20,.82);
|
||||
border:1px solid var(--line);border-radius:12px;padding:8px;backdrop-filter:blur(6px)}
|
||||
.act{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:58px;padding:8px 6px;
|
||||
background:var(--bg-2);border:1px solid var(--line-strong);border-radius:9px;color:var(--fg);
|
||||
cursor:pointer;font-family:inherit;transition:border-color .12s,background .12s}
|
||||
.act:hover{background:var(--bg-3)}
|
||||
.act .ic{font-size:18px;line-height:1}
|
||||
.act .tx{font-size:9px;letter-spacing:.04em;color:var(--fg-mute);text-transform:uppercase}
|
||||
.act.up:hover{border-color:var(--accent);box-shadow:0 0 12px rgba(255,106,0,.25)}
|
||||
.act.down:hover{border-color:var(--accent-2);box-shadow:0 0 12px rgba(0,204,255,.22)}
|
||||
.act:disabled{opacity:.35;cursor:default;box-shadow:none}
|
||||
.sep{width:1px;align-self:stretch;background:var(--line);margin:2px 2px}
|
||||
|
||||
/* mode toggle */
|
||||
.modeseg{display:inline-flex;border:1px solid var(--line-strong);border-radius:9px;overflow:hidden}
|
||||
.modeseg button{border:0;border-radius:0;padding:0 12px;height:100%;min-height:54px;font-family:inherit;
|
||||
font-size:11px;letter-spacing:.04em;background:var(--bg-2);color:var(--fg-mute);cursor:pointer}
|
||||
.modeseg button.on{background:var(--accent);color:#0d0d0d}
|
||||
|
||||
/* info toggle */
|
||||
#infoBtn{width:34px;height:34px;border-radius:50%;border:1px solid var(--line-strong);background:rgba(20,20,20,.82);
|
||||
color:var(--fg-mute);font-size:15px;cursor:pointer;backdrop-filter:blur(6px)}
|
||||
#infoBtn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
|
||||
/* fading rotate hint */
|
||||
#hint{position:absolute;left:50%;top:18px;transform:translateX(-50%);z-index:4;
|
||||
font-size:11px;letter-spacing:.08em;color:var(--fg-dim);pointer-events:none;
|
||||
transition:opacity 1s ease;opacity:.85}
|
||||
#hint.gone{opacity:0}
|
||||
|
||||
/* loading */
|
||||
#loading{position:absolute;inset:0;display:grid;place-items:center;z-index:9;background:var(--bg);
|
||||
color:var(--fg-dim);font-size:13px;letter-spacing:.1em}
|
||||
#loading.gone{display:none}
|
||||
#loading b{color:var(--accent);letter-spacing:-.01em}
|
||||
|
||||
/* prose drawer (revealed by the info switch) */
|
||||
#prose{position:absolute;top:0;right:0;bottom:0;width:min(440px,86vw);z-index:8;overflow:auto;
|
||||
background:rgba(13,13,13,.96);border-left:1px solid var(--line);padding:40px 26px 64px;
|
||||
transform:translateX(102%);transition:transform .28s ease}
|
||||
body:not(.minimal) #prose{transform:none}
|
||||
#prose h1{font-size:24px;margin:0 0 8px}
|
||||
#prose h1 b{color:var(--accent)}
|
||||
#prose p{color:var(--fg-mute);line-height:1.6;margin:0 0 12px}
|
||||
#prose p b{color:var(--fg)}
|
||||
#prose .lbl{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin:26px 0 8px}
|
||||
#prose .ph{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--fg-dim);
|
||||
border:1px dashed var(--line-strong);border-radius:6px;padding:6px 9px;display:inline-block;margin-bottom:14px}
|
||||
#prose .bars{display:flex;flex-direction:column;gap:7px;margin-top:6px}
|
||||
#prose .ro{display:flex;align-items:center;gap:10px}
|
||||
#prose .ro span{width:74px;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-mute)}
|
||||
#prose .bar{flex:1;height:7px;background:var(--bg-3);border:1px solid var(--line);border-radius:999px;overflow:hidden;position:relative}
|
||||
#prose .bar i{position:absolute;left:0;top:0;bottom:0;border-radius:999px}
|
||||
#prose .num{width:3.2ch;text-align:right;font-variant-numeric:tabular-nums;color:var(--fg);font-size:12px}
|
||||
#prose a{color:var(--accent-2);text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body class="minimal">
|
||||
<div id="app">
|
||||
<canvas id="scene"></canvas>
|
||||
|
||||
<div id="hint">drag to rotate · top-down view = the flat 2-D map</div>
|
||||
|
||||
<!-- ZONE 1 · input: the 2-D control + parameter count (left) -->
|
||||
<div class="hud hud-l">
|
||||
<div class="zonecap">input · 2-D control</div>
|
||||
<canvas id="ndpad" width="140" height="140"></canvas>
|
||||
<div class="kbox">
|
||||
<span class="lab">params</span>
|
||||
<button class="step" id="kdown" title="fewer parameters">−</button>
|
||||
<span class="val" id="kval">1</span>
|
||||
<button class="step" id="kup" title="more parameters">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ZONE 3 · outputs: vertical faders, one per layer (right) -->
|
||||
<div class="hud hud-r">
|
||||
<div class="zonecap">outputs</div>
|
||||
<div class="outs" id="outs"></div>
|
||||
</div>
|
||||
|
||||
<!-- action bar (bottom-center) -->
|
||||
<div class="hud hud-bc actionbar">
|
||||
<button class="act" id="aRand"><span class="ic">🎲</span><span class="tx">randomise</span></button>
|
||||
<button class="act" id="aNudge"><span class="ic">〜</span><span class="tx">nudge</span></button>
|
||||
<button class="act" id="aUndo"><span class="ic">↶</span><span class="tx">undo</span></button>
|
||||
<div class="sep"></div>
|
||||
<button class="act up" id="aUp"><span class="ic">👍</span><span class="tx">keep</span></button>
|
||||
<button class="act down" id="aDown"><span class="ic">👎</span><span class="tx" id="downTx">push away</span></button>
|
||||
<div class="sep"></div>
|
||||
<div class="modeseg">
|
||||
<button id="mPush" class="on">push‑away</button>
|
||||
<button id="mExp">explore</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="infoBtn" class="hud hud-tr" title="show / hide explanation">ⓘ</button>
|
||||
|
||||
<div id="loading"><span>loading the <b> NISPS </b> engine…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- placeholder explanatory copy (revealed by the ⓘ switch; to be reworked) -->
|
||||
<div id="prose">
|
||||
<h1>The <b>parameter landscape</b></h1>
|
||||
<p class="ph">placeholder copy — to be reworked</p>
|
||||
<p>Two numbers go in — your <b>X / Y control position</b> — and a whole fistful of parameters comes
|
||||
out. You can't picture a dozen dimensions at once, so picture them <b>stacked</b>: each <b>slab</b> is one
|
||||
parameter, drawn as a 3-D landscape whose height at any spot is that parameter's value. <b>One probe</b>
|
||||
reads every slab at the same control position.</p>
|
||||
<p>Seen straight from above the slabs overlap into a single flat map — it looks 2-D. <b>Tilt it</b> and the
|
||||
reveal lands: it was a stack of 3-D surfaces all along. Add parameters to grow the sandwich.</p>
|
||||
<p>Every surface here is the <b>real NISPS network</b> sampled across the input space. <b>👍 keep</b> teaches
|
||||
the net that this sound belongs at this spot and the landscape bends to honour it; <b>👎</b> either pushes the
|
||||
mapping away from the current sound or (in explore mode) re-rolls a fresh candidate to audition. <b>nudge</b>
|
||||
and <b>randomise</b> perturb the net; <b>undo</b> steps back.</p>
|
||||
<div class="lbl">Values under the probe</div>
|
||||
<div class="bars" id="ndbars"></div>
|
||||
<p style="margin-top:24px"><a href="../">← back to the instrument</a> · <a href="../animations/">animated explainers →</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
const clamp=(v,a=0,b=1)=>v<a?a:v>b?b:v;
|
||||
const cssv=n=>getComputedStyle(document.documentElement).getPropertyValue(n).trim();
|
||||
const MONO=cssv('--font-mono')||'monospace';
|
||||
|
||||
const NAMES=['Cutoff','Resonance','Drive','Tone','Decay','Air','Pitch','Width'];
|
||||
const ABBR =['Cut','Res','Drv','Ton','Dec','Air','Pit','Wid'];
|
||||
const MAXK=NAMES.length;
|
||||
const OUT=126, IN=2, GRID=12, N=GRID, PTS=(N+1)*(N+1);
|
||||
|
||||
let K=1; // parameters (slabs) visualised — from the same net
|
||||
let cx=0.5, cy=0.5; // 2-D control position in [0,1]
|
||||
let mode='pushaway'; // 'pushaway' | 'explore'
|
||||
let exploring=false; // explore-and-place scratchpad session active
|
||||
let ready=false, auto=true, autoT=0.6;
|
||||
|
||||
// camera — START TOP-DOWN (looks 2-D); drag tilts to reveal the 3-D stack
|
||||
let yaw=0, pitch=Math.PI/2, dragRot=false, lastX=0, lastY=0;
|
||||
|
||||
// ===== engine (vanilla wrapper over nisps.wasm; same C ABI the manifold uses) =====
|
||||
const E={
|
||||
mod:null,h:0,bIn:0,bOut:0,bF:0,bL:0,bW:0,bFb:0,wc:0,
|
||||
async init(){
|
||||
// nisps.{js,wasm} sit one level up at /next/ in production; probe a few bases.
|
||||
let base=null,src=null;
|
||||
for(const b of ['../','','./']){
|
||||
try{const r=await fetch(b+'nisps.js');if(r.ok){src=await r.text();base=b;break;}}catch(e){}
|
||||
}
|
||||
if(base===null) throw new Error('nisps.js not found');
|
||||
if(!window.createNispsModule)(0,eval)(src);
|
||||
this.mod=await window.createNispsModule({locateFile:p=>p.endsWith('.wasm')?base+'nisps.wasm':p});
|
||||
const m=this.mod;
|
||||
this.h=m._nisps_ml_create(IN,OUT,0,0,(Date.now()>>>0));
|
||||
this.wc=m._nisps_ml_weight_count(this.h);
|
||||
this.bF=m._malloc(IN*4); this.bL=m._malloc(OUT*4); this.bFb=m._malloc(OUT*4);
|
||||
this.bW=m._malloc(this.wc*4);
|
||||
this.bIn=m._malloc(PTS*IN*4); this.bOut=m._malloc(PTS*OUT*4);
|
||||
},
|
||||
inferXY(x,y){const m=this.mod;m._nisps_ml_set_input(this.h,0,x);m._nisps_ml_set_input(this.h,1,y);
|
||||
m._nisps_ml_process(this.h);const p=m._nisps_ml_outputs(this.h);
|
||||
return new Float32Array(m.HEAPF32.buffer,p,OUT);},
|
||||
inferGrid(){const m=this.mod;const inv=new Float32Array(m.HEAPF32.buffer,this.bIn,PTS*IN);
|
||||
let q=0;for(let j=0;j<=N;j++)for(let i=0;i<=N;i++){inv[q++]=i/N;inv[q++]=j/N;}
|
||||
m._nisps_ml_infer_batch(this.h,this.bIn,PTS,this.bOut);
|
||||
return new Float32Array(new Float32Array(m.HEAPF32.buffer,this.bOut,PTS*OUT));},
|
||||
addExample(f,l){const m=this.mod;const fv=new Float32Array(m.HEAPF32.buffer,this.bF,IN);
|
||||
const lv=new Float32Array(m.HEAPF32.buffer,this.bL,OUT);fv[0]=f[0];fv[1]=f[1];
|
||||
for(let i=0;i<OUT;i++)lv[i]=l[i]||0;m._nisps_ml_add_example(this.h,this.bF,this.bL);},
|
||||
train(lr,it,me){return this.mod._nisps_ml_train(this.h,lr,it,me,0);},
|
||||
draw(s){this.mod._nisps_ml_draw_weights(this.h,s);},
|
||||
move(sp,s){this.mod._nisps_ml_move_weights(this.h,sp,s,0);},
|
||||
setMode(i){this.mod._nisps_ml_feedback_set_mode(this.h,i);},
|
||||
down(out,sp,s){const m=this.mod;const ov=new Float32Array(m.HEAPF32.buffer,this.bFb,OUT);
|
||||
ov.fill(0);for(let i=0;i<OUT;i++)ov[i]=out[i]||0;m._nisps_ml_feedback_down(this.h,this.bFb,sp,s,0);},
|
||||
enterExplore(s){this.mod._nisps_ml_feedback_enter_explore(this.h,s);},
|
||||
reroll(s){this.mod._nisps_ml_feedback_reroll(this.h,s);},
|
||||
coreNudge(a){this.mod._nisps_ml_feedback_nudge(this.h,a);},
|
||||
coreUndo(){this.mod._nisps_ml_feedback_undo(this.h);},
|
||||
exitExplore(){this.mod._nisps_ml_feedback_exit_explore(this.h);},
|
||||
getWeights(){this.mod._nisps_ml_get_weights(this.h,this.bW);
|
||||
return new Float32Array(new Float32Array(this.mod.HEAPF32.buffer,this.bW,this.wc));},
|
||||
setWeights(w){const v=new Float32Array(this.mod.HEAPF32.buffer,this.bW,this.wc);v.set(w);
|
||||
this.mod._nisps_ml_set_weights(this.h,this.bW);},
|
||||
};
|
||||
|
||||
// Low spread = polarised weights = strong spatial relief (measured); high spread
|
||||
// flattens the surface toward 0.5. Keep it low so the landscapes show real terrain.
|
||||
const SPREAD=0.1, NUDGE=0.05, BOOT_SPREAD=0.05;
|
||||
let grid=null, probeOut=new Float32Array(OUT);
|
||||
function H(k,i,j){return grid?grid[(j*(N+1)+i)*OUT+k]:0.5;}
|
||||
function resample(){grid=E.inferGrid();}
|
||||
function updateProbe(){const o=E.inferXY(cx,cy);probeOut=new Float32Array(o);}
|
||||
|
||||
// ---- weight-snapshot undo (push-away mode; explore mode uses the core ring) ----
|
||||
const undoStack=[];
|
||||
function pushUndo(){undoStack.push(E.getWeights());if(undoStack.length>24)undoStack.shift();}
|
||||
function popUndo(){const w=undoStack.pop();if(w){E.setWeights(w);return true;}return false;}
|
||||
|
||||
// ---- feedback examples marked on the 2-D control plane (like the main app) ----
|
||||
// {x,y,sign} — sign +1 kept (👍), -1 pushed away (👎). undo pops the last.
|
||||
const marks=[];
|
||||
|
||||
// ===== actions (all backed by the real engine) =====
|
||||
function act(name){
|
||||
if(!ready)return;
|
||||
if(mode==='pushaway'){
|
||||
if(name==='rand'){pushUndo();E.draw(SPREAD);}
|
||||
else if(name==='nudge'){pushUndo();E.move(0.04,SPREAD);}
|
||||
else if(name==='undo'){popUndo();marks.pop();}
|
||||
else if(name==='up'){pushUndo();const o=new Float32Array(E.inferXY(cx,cy));E.addExample([cx,cy],o);E.train(1.0,200,1e-4);marks.push({x:cx,y:cy,sign:1});}
|
||||
else if(name==='down'){pushUndo();const o=new Float32Array(E.inferXY(cx,cy));E.down(o,0.15,SPREAD);marks.push({x:cx,y:cy,sign:-1});}
|
||||
}else{ // explore-and-place
|
||||
if(name==='rand'||name==='down'){
|
||||
if(!exploring){E.setMode(3);E.enterExplore(SPREAD);exploring=true;}else E.reroll(SPREAD);
|
||||
}else if(name==='nudge'){
|
||||
if(!exploring){E.setMode(3);E.enterExplore(SPREAD);exploring=true;}else E.coreNudge(NUDGE);
|
||||
}else if(name==='undo'){
|
||||
if(exploring)E.coreUndo();else{popUndo();marks.pop();}
|
||||
}else if(name==='up'){
|
||||
const o=new Float32Array(E.inferXY(cx,cy));
|
||||
if(exploring){E.exitExplore();exploring=false;}
|
||||
E.addExample([cx,cy],o);E.train(1.0,200,1e-4);marks.push({x:cx,y:cy,sign:1});
|
||||
}
|
||||
}
|
||||
resample();updateProbe();reflectButtons();redraw();
|
||||
}
|
||||
function setMode(m){
|
||||
if(m===mode)return;
|
||||
if(mode==='explore'&&exploring){E.exitExplore();exploring=false;}
|
||||
mode=m;E.setMode(m==='explore'?3:0);
|
||||
document.getElementById('mPush').classList.toggle('on',m==='pushaway');
|
||||
document.getElementById('mExp').classList.toggle('on',m==='explore');
|
||||
document.getElementById('downTx').textContent=m==='explore'?'skip':'push away';
|
||||
if(ready){resample();updateProbe();reflectButtons();redraw();}
|
||||
}
|
||||
function reflectButtons(){
|
||||
const ud=document.getElementById('aUndo');
|
||||
ud.disabled=(mode==='explore'?false:undoStack.length===0)&&!exploring;
|
||||
}
|
||||
|
||||
// ===== 3-D projection: yaw + pitch orbit, bounding-sphere fit, X inverted =====
|
||||
const SX=300,SZ=300,LAYER_GAP=120,ZAMP=92,MARGIN=44;
|
||||
// Anchor the view to the TOP slab (k=0) at a FIXED world Y and a FIXED screen
|
||||
// position, independent of K. So adding parameters grows the stack DOWNWARD and
|
||||
// the top slab always stays the top — it never re-centres onto new layers.
|
||||
const AWY=ZAMP*0.5, ANCHOR_FY=0.42;
|
||||
let CW=800,CH=600,scale=1;
|
||||
function computeView(){
|
||||
let R=1; // bounding-sphere radius about the top anchor
|
||||
for(let k=0;k<K;k++)for(const z of[0,1])for(const gx of[-0.5,0.5])for(const gy of[-0.5,0.5]){
|
||||
const X=gx*SX,Y=(-k*LAYER_GAP+z*ZAMP)-AWY,Z=gy*SZ;const d=Math.sqrt(X*X+Y*Y+Z*Z);if(d>R)R=d;}
|
||||
scale=(Math.min(CW,CH)/2-MARGIN)/R;
|
||||
}
|
||||
function project(gx,gy,zv,k){
|
||||
const X=gx*SX, Y=(-k*LAYER_GAP+zv*ZAMP)-AWY, Z=-gy*SZ; // Z negated: pad-up = map-up
|
||||
const cyA=Math.cos(yaw),syA=Math.sin(yaw);
|
||||
const x1=X*cyA-Z*syA, z1=X*syA+Z*cyA;
|
||||
const cpA=Math.cos(pitch),spA=Math.sin(pitch);
|
||||
const y2=Y*cpA-z1*spA, z2=Y*spA+z1*cpA;
|
||||
return {x:CW/2+x1*scale,y:CH*ANCHOR_FY-y2*scale,depth:z2,wx:X,wy:Y,wz:Z};
|
||||
}
|
||||
|
||||
const DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
|
||||
const scene=document.getElementById('scene'),sctx=scene.getContext('2d');
|
||||
const pad=document.getElementById('ndpad'),pctx=pad.getContext('2d');
|
||||
pad.width=140*DPR;pad.height=140*DPR;pctx.scale(DPR,DPR);
|
||||
function resize(){
|
||||
const r=scene.getBoundingClientRect();CW=Math.max(320,Math.round(r.width));CH=Math.max(320,Math.round(r.height));
|
||||
scene.width=CW*DPR;scene.height=CH*DPR;sctx.setTransform(DPR,0,0,DPR,0,0);computeView();
|
||||
}
|
||||
|
||||
const hueOf=k=>K>1?(26+(196-26)*k/(K-1)):26;
|
||||
const colCss=(k,a)=>`hsla(${hueOf(k).toFixed(0)},85%,60%,${a})`;
|
||||
const LIGHT=(()=>{const v=[0.35,0.86,0.36],m=Math.hypot(v[0],v[1],v[2]);return[v[0]/m,v[1]/m,v[2]/m];})();
|
||||
|
||||
function renderScene(){
|
||||
const c=sctx;c.clearRect(0,0,CW,CH);
|
||||
const quads=[],labels=[];
|
||||
for(let k=0;k<K;k++){
|
||||
const hue=hueOf(k),P=[];
|
||||
for(let j=0;j<=N;j++){P[j]=[];for(let i=0;i<=N;i++)P[j][i]=project(i/N-0.5,j/N-0.5,H(k,i,j),k);}
|
||||
for(let j=0;j<N;j++)for(let i=0;i<N;i++){
|
||||
const a=P[j][i],b=P[j][i+1],d=P[j+1][i+1],e=P[j+1][i];
|
||||
const ux=d.wx-a.wx,uy=d.wy-a.wy,uz=d.wz-a.wz,vx=b.wx-e.wx,vy=b.wy-e.wy,vz=b.wz-e.wz;
|
||||
const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx,nm=Math.hypot(nx,ny,nz)||1;
|
||||
const L=clamp(0.32+0.62*Math.abs((nx*LIGHT[0]+ny*LIGHT[1]+nz*LIGHT[2])/nm));
|
||||
quads.push({a,b,d,e,hue,L,depth:(a.depth+b.depth+d.depth+e.depth)/4});
|
||||
}
|
||||
const lp=project(-0.5,0.5,H(k,0,N),k);labels.push({x:lp.x,y:lp.y,hue,name:NAMES[k]});
|
||||
}
|
||||
quads.sort((p,q)=>q.depth-p.depth);
|
||||
for(const Q of quads){
|
||||
c.fillStyle=`hsla(${Q.hue},80%,${(26+Q.L*44).toFixed(0)}%,0.66)`;
|
||||
c.strokeStyle=`hsla(${Q.hue},80%,82%,0.18)`;c.lineWidth=0.6;
|
||||
c.beginPath();c.moveTo(Q.a.x,Q.a.y);c.lineTo(Q.b.x,Q.b.y);c.lineTo(Q.d.x,Q.d.y);c.lineTo(Q.e.x,Q.e.y);c.closePath();
|
||||
c.fill();c.stroke();
|
||||
}
|
||||
c.font='11px '+MONO;c.textAlign='right';c.textBaseline='middle';
|
||||
for(const Lb of labels){c.fillStyle=`hsla(${Lb.hue},85%,74%,0.95)`;c.fillText(Lb.name,Lb.x-6,Lb.y);}
|
||||
// probe column at (cx,cy) through every slab
|
||||
const gx=cx-0.5,gy=cy-0.5;
|
||||
const top=project(gx,gy,1.18,0),bot=project(gx,gy,-0.18,K-1);
|
||||
c.strokeStyle='rgba(255,255,255,0.32)';c.lineWidth=1;c.setLineDash([3,4]);
|
||||
c.beginPath();c.moveTo(top.x,top.y);c.lineTo(bot.x,bot.y);c.stroke();c.setLineDash([]);
|
||||
for(let k=0;k<K;k++){
|
||||
const p=project(gx,gy,clamp(probeOut[k]),k),fl=project(gx,gy,0,k);
|
||||
c.strokeStyle=colCss(k,0.5);c.lineWidth=1;c.beginPath();c.moveTo(p.x,p.y);c.lineTo(fl.x,fl.y);c.stroke();
|
||||
c.fillStyle=colCss(k,1);c.shadowColor=colCss(k,1);c.shadowBlur=12;
|
||||
c.beginPath();c.arc(p.x,p.y,6,0,Math.PI*2);c.fill();c.shadowBlur=0;
|
||||
c.fillStyle='#0d0d0d';c.beginPath();c.arc(p.x,p.y,2.2,0,Math.PI*2);c.fill();
|
||||
}
|
||||
}
|
||||
function drawPad(){
|
||||
pctx.clearRect(0,0,140,140);pctx.fillStyle='#1c1c1c';pctx.fillRect(0,0,140,140);
|
||||
pctx.strokeStyle='rgba(255,255,255,0.06)';pctx.lineWidth=1;
|
||||
for(let i=1;i<4;i++){const t=i/4*140;pctx.beginPath();pctx.moveTo(t,0);pctx.lineTo(t,140);pctx.stroke();
|
||||
pctx.beginPath();pctx.moveTo(0,t);pctx.lineTo(140,t);pctx.stroke();}
|
||||
// feedback examples (👍 +, 👎 −) — same convention as the main app
|
||||
const TAU=Math.PI*2;
|
||||
for(const m of marks){const mx=m.x*140,my=(1-m.y)*140,col=m.sign>0?'#ff6a00':'#00ccff';
|
||||
pctx.globalAlpha=0.28;pctx.fillStyle=col;pctx.beginPath();pctx.arc(mx,my,8,0,TAU);pctx.fill();
|
||||
pctx.globalAlpha=1;pctx.strokeStyle=col;pctx.lineWidth=1.5;pctx.beginPath();pctx.arc(mx,my,5,0,TAU);pctx.stroke();
|
||||
pctx.fillStyle=col;pctx.font='9px '+MONO;pctx.textAlign='center';pctx.textBaseline='middle';
|
||||
pctx.fillText(m.sign>0?'+':'–',mx,my+0.5);}
|
||||
const dx=cx*140,dy=(1-cy)*140;
|
||||
pctx.strokeStyle='rgba(255,255,255,0.1)';pctx.beginPath();pctx.moveTo(0,dy);pctx.lineTo(140,dy);
|
||||
pctx.moveTo(dx,0);pctx.lineTo(dx,140);pctx.stroke();
|
||||
pctx.fillStyle='#ff6a00';pctx.shadowColor='#ff6a00';pctx.shadowBlur=12;
|
||||
pctx.beginPath();pctx.arc(dx,dy,6,0,Math.PI*2);pctx.fill();pctx.shadowBlur=0;
|
||||
pctx.fillStyle='#0d0d0d';pctx.beginPath();pctx.arc(dx,dy,2.4,0,Math.PI*2);pctx.fill();
|
||||
}
|
||||
|
||||
// readout bars (prose drawer only)
|
||||
const barsEl=document.getElementById('ndbars');let barI=[],barN=[];
|
||||
function buildBars(){barsEl.innerHTML='';barI=[];barN=[];
|
||||
for(let k=0;k<K;k++){const ro=document.createElement('div');ro.className='ro';
|
||||
ro.innerHTML=`<span style="color:${colCss(k,0.95)}">${NAMES[k]}</span>`+
|
||||
`<div class="bar"><i style="background:${colCss(k,1)}"></i></div><div class="num">0.50</div>`;
|
||||
barsEl.appendChild(ro);barI[k]=ro.querySelector('i');barN[k]=ro.querySelector('.num');}}
|
||||
function updateBars(){if(!barI.length)return;for(let k=0;k<K;k++){const v=clamp(probeOut[k]);
|
||||
barI[k].style.width=(v*100)+'%';barN[k].textContent=v.toFixed(2);}}
|
||||
|
||||
// output faders — the 1-D projection of each layer's value at the current point
|
||||
const TRACK_H=200;
|
||||
const outsEl=document.getElementById('outs');let fFill=[],fHan=[],fVal=[];
|
||||
function buildOuts(){outsEl.innerHTML='';fFill=[];fHan=[];fVal=[];
|
||||
for(let k=0;k<K;k++){const col=colCss(k,1);const f=document.createElement('div');f.className='fader';
|
||||
f.innerHTML=`<div class="track"><div class="fill" style="background:${col}"></div>`+
|
||||
`<div class="handle" style="background:${col};color:${col}"></div></div>`+
|
||||
`<div class="flab" style="color:${colCss(k,0.95)}" title="${NAMES[k]}">${ABBR[k]}</div><div class="fval">0.50</div>`;
|
||||
outsEl.appendChild(f);fFill[k]=f.querySelector('.fill');fHan[k]=f.querySelector('.handle');fVal[k]=f.querySelector('.fval');}}
|
||||
function updateOuts(){if(!fFill.length)return;for(let k=0;k<K;k++){const v=clamp(probeOut[k]);
|
||||
fFill[k].style.height=(v*100)+'%';fHan[k].style.bottom=(v*TRACK_H)+'px';fVal[k].textContent=v.toFixed(2);}}
|
||||
|
||||
function redraw(){renderScene();drawPad();updateBars();updateOuts();}
|
||||
|
||||
// ===== interaction =====
|
||||
function setK(n){K=clamp(n,1,MAXK);document.getElementById('kval').textContent=K;
|
||||
document.getElementById('kup').disabled=K>=MAXK;document.getElementById('kdown').disabled=K<=1;
|
||||
computeView();buildBars();buildOuts();updateProbe&&(probeOut=new Float32Array(E.inferXY(cx,cy)));redraw();}
|
||||
|
||||
function stopAuto(){auto=false;document.getElementById('hint').classList.add('gone');}
|
||||
|
||||
// pad → control position
|
||||
function setFromPad(e){const r=pad.getBoundingClientRect();
|
||||
cx=clamp((e.clientX-r.left)/r.width);cy=clamp(1-(e.clientY-r.top)/r.height);}
|
||||
let padDrag=false;
|
||||
pad.addEventListener('pointerdown',e=>{padDrag=true;pad.setPointerCapture(e.pointerId);stopAuto();setFromPad(e);updateProbe();redraw();});
|
||||
pad.addEventListener('pointermove',e=>{if(padDrag){setFromPad(e);updateProbe();redraw();}});
|
||||
pad.addEventListener('pointerup',()=>padDrag=false);
|
||||
|
||||
// scene → orbit (pitch inverted; pitch up to π/2 = exact top-down)
|
||||
scene.addEventListener('pointerdown',e=>{dragRot=true;scene.setPointerCapture(e.pointerId);stopAuto();lastX=e.clientX;lastY=e.clientY;});
|
||||
scene.addEventListener('pointermove',e=>{if(!dragRot)return;
|
||||
yaw+=(e.clientX-lastX)*0.01;
|
||||
pitch=clamp(pitch+(e.clientY-lastY)*0.008,0.06,Math.PI/2); // vertical (Y) drag — inverted
|
||||
lastX=e.clientX;lastY=e.clientY;redraw();});
|
||||
scene.addEventListener('pointerup',()=>dragRot=false);
|
||||
scene.addEventListener('pointercancel',()=>dragRot=false);
|
||||
|
||||
// buttons
|
||||
const B=(id,fn)=>document.getElementById(id).addEventListener('click',()=>{stopAuto();fn();});
|
||||
B('aRand',()=>act('rand'));B('aNudge',()=>act('nudge'));B('aUndo',()=>act('undo'));
|
||||
B('aUp',()=>act('up'));B('aDown',()=>act('down'));
|
||||
B('kup',()=>setK(K+1));B('kdown',()=>setK(K-1));
|
||||
document.getElementById('mPush').addEventListener('click',()=>{stopAuto();setMode('pushaway');});
|
||||
document.getElementById('mExp').addEventListener('click',()=>{stopAuto();setMode('explore');});
|
||||
document.getElementById('infoBtn').addEventListener('click',()=>{
|
||||
document.body.classList.toggle('minimal');
|
||||
document.getElementById('infoBtn').textContent=document.body.classList.contains('minimal')?'ⓘ':'✕';});
|
||||
|
||||
window.addEventListener('resize',()=>{resize();redraw();});
|
||||
|
||||
function loop(){
|
||||
if(auto&&ready&&!padDrag&&!dragRot){
|
||||
autoT+=0.006;cx=0.5+0.42*Math.sin(autoT*0.9);cy=0.5+0.42*Math.sin(autoT*1.27+1.3);
|
||||
updateProbe();redraw();
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ===== boot =====
|
||||
(async()=>{
|
||||
try{
|
||||
await E.init();
|
||||
E.draw(BOOT_SPREAD); // a fresh net is near-flat; draw for visible relief
|
||||
window.__learn={E,setK,resample,redraw,
|
||||
range(k){let lo=1,hi=0;for(let j=0;j<=N;j++)for(let i=0;i<=N;i++){const v=H(k,i,j);if(v<lo)lo=v;if(v>hi)hi=v;}return[lo,hi];}};
|
||||
resize();setK(1);resample();updateProbe();reflectButtons();
|
||||
ready=true;document.getElementById('loading').classList.add('gone');
|
||||
redraw();requestAnimationFrame(loop);
|
||||
// fade the hint after a while
|
||||
setTimeout(()=>{if(auto)document.getElementById('hint').classList.add('gone');},6000);
|
||||
}catch(err){
|
||||
console.error('[learn] engine init failed:',err);
|
||||
document.getElementById('loading').innerHTML='<span style="color:#c8553a">engine failed to load — see console</span>';
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
#include "glue/midi_io.hpp"
|
||||
#include "glue/mode_select.hpp"
|
||||
#include "glue/output_router.hpp"
|
||||
#include "glue/settings_view.hpp"
|
||||
|
||||
// ---- Mode selection ----
|
||||
// Build script rewrites which line is uncommented.
|
||||
|
|
@ -142,6 +143,8 @@ void setup() {
|
|||
}
|
||||
|
||||
MEMLNaut::Instance()->addSystemInfoView();
|
||||
// Settings menu (e.g. Joystick: Dual/Single for the 4-input modes).
|
||||
nisps_firmware::wire_settings(g_mode);
|
||||
Serial.println("Finished initialising core 0.");
|
||||
}
|
||||
|
||||
|
|
|
|||
64
firmware/MEMLNaut-NISPS/glue/settings_view.hpp
Normal file
64
firmware/MEMLNaut-NISPS/glue/settings_view.hpp
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// glue/settings_view.hpp — on-device settings menu views (TFT + rotary encoder).
|
||||
//
|
||||
// Adds entries to the MEMLNaut display carousel (the same DisplayDriver the
|
||||
// SystemView and SelfTest use). Navigation: rotate to move between views;
|
||||
// press to focus a view; rotate-while-focused to change its value; press
|
||||
// again to unfocus. See src/memllib/.../display/DisplayDriver.hpp.
|
||||
//
|
||||
// Currently provides:
|
||||
// * Joystick: Dual / Single — for the 4-input ("two 2-D joystick") modes,
|
||||
// toggles whether the SECOND joystick (input channels 2,3) feeds the ML
|
||||
// engine. "Single" pins those channels to the neutral value (0.5) via the
|
||||
// platform-agnostic ModeBase pin mechanism, so the network is never
|
||||
// rebuilt and trained state survives toggling. Default is Dual (the 4-D
|
||||
// variant naturally uses both joysticks).
|
||||
//
|
||||
// Only registered for modes whose input arity is exactly 4 (the two-joystick
|
||||
// modes); other modes (e.g. SoundAnalysisMIDI, whose joystick channels are not
|
||||
// 2,3) get no joystick toggle.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
|
||||
#include "../src/memllib/hardware/memlnaut/MEMLNaut.hpp"
|
||||
#include "../src/memllib/hardware/memlnaut/display/SingleSelectView.hpp"
|
||||
|
||||
namespace nisps_firmware {
|
||||
|
||||
// The second 2-D joystick maps to ML input channels 2 and 3 (joy_z / joy_w).
|
||||
inline constexpr std::size_t kSecondJoystickCh0 = 2u;
|
||||
inline constexpr std::size_t kSecondJoystickCh1 = 3u;
|
||||
|
||||
// Add the settings views for `mode` to the display carousel. Must run AFTER
|
||||
// MEMLNaut::Initialize() (so the display + TFT exist) and after
|
||||
// addSystemInfoView(), on core 0.
|
||||
template <typename ModeT>
|
||||
inline void wire_settings(ModeT& mode) {
|
||||
if constexpr (ModeT::input_channel_count() == 4u) {
|
||||
MEMLNaut* meml = MEMLNaut::Instance();
|
||||
if (!meml || !meml->disp) return;
|
||||
|
||||
// Kept alive for the program lifetime (the carousel holds a shared_ptr
|
||||
// too, but this guarantees ownership regardless of view churn).
|
||||
static std::shared_ptr<SingleSelectView> joystick_view;
|
||||
joystick_view = std::make_shared<SingleSelectView>("Joystick");
|
||||
meml->disp->AddView(joystick_view); // calls Setup() → creates the selector
|
||||
|
||||
// Index 0 = Dual (default, no pinning); index 1 = Single.
|
||||
std::array<String, 2u> options = { String("Dual"), String("Single") };
|
||||
joystick_view->setOptions(std::span<String>(options.data(), options.size()));
|
||||
joystick_view->setNewVoiceCallback([&mode](std::size_t idx) {
|
||||
const bool single = (idx == 1u);
|
||||
mode.set_input_pinned(kSecondJoystickCh0, single);
|
||||
mode.set_input_pinned(kSecondJoystickCh1, single);
|
||||
});
|
||||
} else {
|
||||
(void)mode;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace nisps_firmware
|
||||
265
manifold/ONBOARDING.md
Normal file
265
manifold/ONBOARDING.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
# Manifold UI — Agent Onboarding
|
||||
|
||||
> **Purpose of this doc:** everything an agent needs to make a tweak or fix to the Manifold
|
||||
> front-end without re-grepping the tree. Read this + the relevant section's source files; you
|
||||
> should not need to read the 40KB design specs for routine work (they're pointers at the end).
|
||||
>
|
||||
> **Voice:** ground-truth map. If something here contradicts the code, the code wins — fix this doc
|
||||
> in the same commit.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Manifold is
|
||||
|
||||
A **convertible-mode React front-end** on the real, parity-tested NISPS ML+audio engine (same
|
||||
`nisps/` C++ core that the firmware and the `playground/` use, compiled to WASM). It lets you drive
|
||||
a neural net's mapping from a small input space (XY pad / gamepad / MIDI) to many outputs, shape
|
||||
that mapping with interactive ML feedback ("explore & place" / dislike), and route the outputs to
|
||||
several backends (built-in synth, MIDI, OSC, particles, VCV, MEMLNaut hardware).
|
||||
|
||||
- **Stack:** Vite + React 18 + TypeScript (strict). No CSS-in-JS — design-token CSS variables.
|
||||
- **Lives at:** `manifold/` (beside `playground/`). Deploys to **https://meml.lnfinitemonkeys.org/next/**.
|
||||
- **Synth is labelled "Powerful Synth Engine" / "Built-in Synth" — NEVER "C15".** British spelling.
|
||||
|
||||
---
|
||||
|
||||
## 2. Run / build / deploy / test
|
||||
|
||||
```bash
|
||||
cd manifold
|
||||
bun install
|
||||
bun run dev # Vite dev server, port 5273 (COOP/COEP headers set)
|
||||
bun run typecheck # tsc --noEmit ← run before every commit
|
||||
bun run build # tsc --noEmit && vite build → dist/
|
||||
bun run preview # serve dist/ on :4273 (honours COOP/COEP — needed by WASM/worklet)
|
||||
bun run test:e2e # Playwright smoke (needs `bun run build` first; runs against preview)
|
||||
```
|
||||
|
||||
- **Typecheck is the cheap gate.** It catches most regressions; run it after any edit.
|
||||
- **E2E on the VPS** needs a non-snap node runner (bun is snap-confined and hides libs from
|
||||
Chromium): `PLAYWRIGHT_BROWSERS_PATH=/home/w1n5t0n/snap/bun-js/87/.cache/ms-playwright node node_modules/.bin/playwright test`. Preview via bun is fine. The smoke spec (`tests/e2e/smoke.spec.ts`)
|
||||
asserts: engine WASM loads, spine invariant (setInputs → outputs change), feedback runs, console
|
||||
renders, **no "C15" in the bundle**, no console errors. `shot.spec.ts` takes screenshots.
|
||||
- **Deploy is automatic on push to GitHub `main`** → webhook → builds `manifold/` → rsyncs to the
|
||||
live `/next/` subdir. See the `manifold-deploy-pipeline` memory for the full chain and gotchas
|
||||
(the `cp index.html a-immersive.html` 403 workaround; git-ignored `bun.lock`).
|
||||
- **`?debug=1`** installs `window.__nisps` (synchronous probe for Playwright/console — see
|
||||
`src/debug/probe.ts`). **`base: './'`** in `vite.config.ts` keeps asset URLs relative so one
|
||||
`dist/` mounts at both `/` and `/next/`. **WASM URLs must resolve via `document.baseURI`**, never
|
||||
hardcoded (see the assetUrl gotcha in §6).
|
||||
|
||||
---
|
||||
|
||||
## 3. The big picture — three layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UI layer (React) src/console/ src/dock/ src/primitives/
|
||||
│ ConsoleApp = spine of the UI: holds state, picks a Stage, │
|
||||
│ renders the Dock. "Convertible" = swappable Stages. │
|
||||
└───────────────┬─────────────────────────────────────────────────-┘
|
||||
│ reads engine.version (useSyncExternalStore), reads buffers imperatively
|
||||
│ writes via engine.setInput / setParam / feedback.*
|
||||
┌───────────────▼─────────────────────────────────────────────────┐
|
||||
│ Engine layer (NO React) src/engine/ src/inputs/ src/feedback/ src/backends/
|
||||
│ Spine = reactive store below React. Per-frame ML inference is │
|
||||
│ eager + synchronous, OFF the render cycle. React only watches │
|
||||
│ a monotonic version counter. │
|
||||
└───────────────┬─────────────────────────────────────────────────┘
|
||||
│ C ABI
|
||||
┌───────────────▼─────────────────────────────────────────────────┐
|
||||
│ WASM (nisps.wasm) — built from the C++ nisps/ core │
|
||||
│ Loaded TWICE: main thread (ML inference + training + feedback) │
|
||||
│ and AudioWorklet (audio DSP, separate instance). │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Golden rule of the reactivity model:** components subscribe to `engine.version()` via
|
||||
`useEngineVersion()` and then read live `Float32Array`s **imperatively** (`engine.getOutputs()`,
|
||||
`engine.routedOutput()`). They do **not** get outputs as React state — that was a deliberate perf
|
||||
decision. Buffers are reused frame-to-frame; never assume a fresh array.
|
||||
|
||||
---
|
||||
|
||||
## 4. UI layer (where most tweaks happen)
|
||||
|
||||
### Entry & composition
|
||||
- `src/App.tsx` → mounts `<EngineProvider>` (async WASM load, shows `Loading`) → `<ConsoleApp focus="composite" />`. Installs the `?debug=1` probe once the engine is live.
|
||||
- `src/console/ConsoleApp.tsx` (**~1040 lines — the UI spine**). Holds nearly all UI state:
|
||||
`focus`, `modeId`, `params` (`MFParam[]`), `pos` (2D input), feedback state
|
||||
(`feedbackMode`/`soloMode`/`exploring`/`picking`/`anchorCount`), output config
|
||||
(`outputMode`/`midiOutputId`/`oscUrl`/`vcvUrl`), and UI flags (`sandwich`, `split`, `stripPinned`,
|
||||
`snapshots`, `markers`, `health`, `rev`). Builds the flat `ConsoleCtx` passed to the Dock + drawers.
|
||||
|
||||
### The "convertible" Stages (one renders at a time, chosen by `focus` + `outputMode`)
|
||||
| Stage | File | Renders when | What it is |
|
||||
|---|---|---|---|
|
||||
| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. |
|
||||
| OutputStage | `OutputStage.tsx` | `focus==='out'` | Full-bleed output columns; drag bars set value; `InputMini` docked in a corner. |
|
||||
| SplitStage | `SplitStage.tsx` | `focus==='split'` | Manifold left, OutputStage right, equal width. |
|
||||
| CompositeStage | `CompositeStage.tsx` | `focus==='composite'` (**app default / hero**) | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. |
|
||||
| SandwichStage | `SandwichStage.tsx` | `sandwich===true` (overrides) | 3D parameter-landscape view (input → MLP heatmap grid → outputs); drag to orbit. |
|
||||
| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + macro-axis bar + corner joystick. |
|
||||
|
||||
- **Output modes** (the TOP dock selector, NOT the same axis as `focus`): `src/console/output-mode.ts`
|
||||
defines `OUTPUT_MODES` = **particles** (default) / midi / osc / synth / editor, each mapping to a
|
||||
`BackendId`. `DEFAULT_OUTPUT_MODE='particles'`.
|
||||
- `src/console/output-mode.ts`, `types.ts`, `model.ts` are the shared vocabulary — read these first
|
||||
when touching anything cross-cutting:
|
||||
- `types.ts`: `Focus`, `OutputMode`, `DrawerKey`, `DrawerDepth`, `FeedbackModeUI`, `SoloMode`,
|
||||
`Pin`, `FeedbackMarker`, `Snapshot`, and **`ConsoleCtx`** (the flat context handed to the dock).
|
||||
- `model.ts`: the static instrument catalogue `MF_MODES`, `MFParam`, `ParamStatus`
|
||||
(`off|fixed|live`), `ParamGroup`, plus `shapeValues()` (applies min/max/curve to raw engine
|
||||
outputs) and `seededGradient()`.
|
||||
|
||||
### The Dock (right-edge rail) — `src/console/Dock.tsx` + `Drawers.tsx`
|
||||
- 48px right rail: **TOP** = mode selector (the 5 output modes, popover); **MIDDLE** = 5 drawer
|
||||
icons, vertically centred macOS-dock style; **BOTTOM** = sandwich toggle.
|
||||
- Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel
|
||||
vs expanded 80vw×80vh modal):
|
||||
- **learn** — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm.
|
||||
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
|
||||
- **route** (label "Outputs") — per-output control matrix + per-backend config.
|
||||
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
|
||||
- **help** — keymap pills + loop explanation.
|
||||
- `src/dock/` holds the output-routing internals used by the `route` drawer:
|
||||
- `output-state.ts` — the per-output control model: `OutputControl` (state/muted/armed/min/max/
|
||||
curve/fixedValue + backend specs `MidiCcSpec`/`OscSpec`/`VcvSpec`), `toOutputControl()`,
|
||||
`buildArmMask()` (solo focus).
|
||||
- `OutputControlRow.tsx` — one output row: name · M(mute) · S(solo/arm) · off|fixed|live · dual-range
|
||||
· curve pad · live value. **Writes eagerly to the shared `MFParam` store via `onChange`.**
|
||||
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + per-backend config (MIDI
|
||||
CC#/channel, OSC path/range, VCV polarity).
|
||||
- `BackendAdvanced.tsx` — full-depth modal version of the same editors.
|
||||
|
||||
### Primitives — `src/primitives/` (barrel: `index.ts`)
|
||||
`Button`, `Slider`, `PillToggle`, `Panel`, `Badge`, `Switch`, `StatusLine`, `XYPad`,
|
||||
`VirtualJoystick`, `ControlAxis`, `CurvePlot`, `Sparkline`. Dumb, reusable, no engine knowledge.
|
||||
Side-effect import of `styles/primitives.css` styles the range inputs.
|
||||
|
||||
### Other shared UI files
|
||||
- `shared-ui.tsx` — `AltitudeNav` (focus IN/DUAL/OUT/FLEX switcher) + `MiniMeters` (read-only output bars).
|
||||
- `icons.tsx` — monochrome inline-SVG icons (mode icons + drawer icons + `GLYPH_FALLBACK` for when monochrome is off).
|
||||
- `ReadoutStrip.tsx` — thin horizontal heatmap strip (pinned, `focus==='in'`); same per-output control as OutputStage.
|
||||
- `VerdictCluster.tsx` — floating bottom-centre feedback UI (perturb ▽ / undo ↺ / commit △ + A/B); labels adapt to feedback mode.
|
||||
- `CurvePad.tsx` — square canvas curve editor (vertical drag reshapes [0,1]; ~0.43 ≈ linear). Used in OutputEditor + OutputControlRow.
|
||||
- `InputMini.tsx` — compact XYPad/joystick docked in a corner when input is demoted.
|
||||
- `OutputEditor.tsx` — inline min/max/curve popup for a single output (hover/click on a bar).
|
||||
|
||||
### Styling — `src/styles/`
|
||||
CSS-variable design tokens, no CSS-in-JS. `tokens.css` `@import`s `tokens/{base,colors,fonts,
|
||||
typography,spacing,effects}.css`. All vars on `:root`, consumed via `var(--name)`. Dark theme: deep
|
||||
black bg, warm-orange `--accent` (#ff6a00), cyan `--accent-2` (#00ccff). Corner radius is driven by
|
||||
a setting → `--r-*` tokens.
|
||||
|
||||
---
|
||||
|
||||
## 5. Engine / inputs / feedback / backends (touch when behaviour, not chrome, changes)
|
||||
|
||||
### Engine — `src/engine/` (no React except the two binding files)
|
||||
- `spine.ts` — **the reactive store.** `setInput(x,y)`/`setInputs(arr)` drive raw input synchronously
|
||||
through input-pipeline → `WasmIML.processInto()` → output-pipeline → backend, all off the render
|
||||
cycle, reusing buffers. Bumps a monotonic `version`. `reprocess()` re-ticks the last input after a
|
||||
weight change (stores the full N-D vector so extra axes survive).
|
||||
- `engine-api.ts` — **`EngineApi`, the framework-neutral facade** everything in the UI talks to:
|
||||
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
|
||||
weights (`getWeights/setWeights/process/randomise`), `subscribe/version/on`, plus nested
|
||||
`.feedback` and `.audio` facades.
|
||||
- `EngineProvider.tsx` / `useEngine.ts` — the **only** React coupling. `useEngine()` returns the API
|
||||
(null until WASM ready); `useEngineVersion()` = `useSyncExternalStore(subscribe, version)`.
|
||||
- `engine-host.ts` — main-thread audio wiring: AudioContext (user-gesture gated), fetch `nisps.wasm`,
|
||||
register + feed the worklet.
|
||||
- `wasm-iml.ts` (**~750 lines**) — the ML interface to `nisps.wasm`: one MLP handle, dataset, heap
|
||||
buffers, feedback C-ABI bindings (`nisps_ml_feedback_*`), lazy training worker.
|
||||
- `wasm-worker.ts` — off-thread training worker. `worklet/nisps-processor.ts` — the AudioWorklet's
|
||||
separate WASM instance (raw `WebAssembly.instantiate`, no Emscripten glue; 128-sample blocks).
|
||||
- `input-pipeline.ts` — per-axis: invert → deadzone → circular clamp → zoom → centred power curve →
|
||||
EMA smoothing → momentum. **First 2 axes get the full pad pipeline; axes 2+ feed raw to the spine.**
|
||||
- `output-pipeline.ts` — global power curve → per-output EMA smoothing → slew limit → freeze gate.
|
||||
- `dataset.ts` — JS-side example store + sample-weight modes (uniform/recency/spatial/combined).
|
||||
- `curves.ts` — math primitives. **Must stay lockstep with C++ `nisps/core/math.hpp`** (golden tests
|
||||
compare WASM vs TS). `sink.ts` — `EngineSink` framework boundary. `types.ts` — C-ABI surface types.
|
||||
|
||||
### Inputs — `src/inputs/`
|
||||
- `input-layer.ts` — composition hub. One rAF loop polls sources, pulls all axes into a vector,
|
||||
forwards N→engine. **`MAX_AXES = 32`** (WASM net over-provisioned to 32 inputs). **Dedicated
|
||||
dimensions, NO mean-blending** — each active axis drives its own engine slot 1:1; unused slots
|
||||
zero-padded (inert). Changing axis count requires a **net reset** (UI confirm modal).
|
||||
- `base-source.ts` + sources: `xy-pad-source.ts` (push, 2 axes), `gamepad-source.ts` (single=2 /
|
||||
double=4 axes, deadzone 0.08), `midi-input-source.ts` (Web MIDI, batch CC-learn, multi-port).
|
||||
- `useInputLayer.ts` — React binding; manages exclusive input mode + gamepad stick mode + MIDI
|
||||
device/learn map; exposes `pushPad`, `sources`, `channelLayout`, etc.
|
||||
- **Reshape status:** the WASM head is over-provisioned to 32 but the spine's `setInputs` historically
|
||||
treated the head as effectively 2-D — confirm current behaviour in `spine.ts`/`input-layer.ts`
|
||||
before relying on >2 active dims. See the `manifold-mixed-inputs` memory for the locked design
|
||||
(reshapeable net, reset-on-reshape modal, adaptive slider viz when >2 dims).
|
||||
|
||||
### Feedback — `src/feedback/`
|
||||
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
|
||||
Two modes: **explore-and-place** (default, Mode 2, positive-only — drives the C++ core's
|
||||
snapshot/scratchpad/undo lifecycle; caller accumulates anchors and trains on finalise with
|
||||
warm-start) and **geometric-dislike** (Mode 1, selectable). Solo/arm via per-output mask.
|
||||
- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
|
||||
the stream — not bit-identical to `nisps::Rng`.
|
||||
- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
|
||||
push (firmware k-NN), feedback nudge → `nisps_ml_feedback_nudge`, loss-history plumbing. Grep for
|
||||
`C++ GAP` before changing feedback maths.
|
||||
|
||||
### Backends — `src/backends/`
|
||||
- `manager.ts` — `BackendManager`, the **single consumer of the engine spine** for output: subscribes,
|
||||
reads `routedOutput()` each version bump, calls `active.send(routed)`. Gates audio (mutes synth when
|
||||
a non-synth backend is active).
|
||||
- Registered: `midi` (`midi-backend.ts`, CC out, ~20Hz throttle), `osc` (`osc-client.ts` → WebSocket
|
||||
to the Deno bridge in `osc-bridge/bridge.ts`), `synth`/`cvgate` (`passthrough-backend.ts`),
|
||||
`particles` (`particle-backend.ts`, no-op — the visualiser is a separate `flow-field.ts` consumer),
|
||||
`vcv` (`vcv-backend.ts`, bidirectional). `mapping.ts`, `presets.ts`, `useBackendManager.ts` support
|
||||
config. `backend.ts` defines the `OutputBackend` interface + `OutputMapping`.
|
||||
|
||||
### Misc
|
||||
- `src/serial/memlnaut-serial.ts` — **STUB** Web Serial scaffold for the MEMLNaut Editor mode (protocol TODO). `EditorPanel.tsx` is its UI.
|
||||
- `src/settings/settings-store.ts` — localStorage settings (`mf-settings`): icon style, input-map shape, corner radius.
|
||||
- `src/midi-devices/` — codegen'd external-synth device templates.
|
||||
- `src/debug/probe.ts` — `window.__nisps` synchronous probe (engine/audio/bus). Some playground
|
||||
feature-store methods are present-but-inert (not ported yet) to keep the surface stable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Gotchas & non-obvious rules
|
||||
|
||||
1. **Outputs are read imperatively, not via React state.** Subscribe to `version`, then call
|
||||
`getOutputs()`/`routedOutput()`. Buffers are reused — copy if you need to retain.
|
||||
2. **Asset URLs must resolve against `document.baseURI`, not `location.origin`.** `base: './'` +
|
||||
the `/next/` subpath mean a hardcoded `/nisps.wasm` 404s. The `assetUrl()` helper (in
|
||||
`engine-host.ts`, `wasm-iml.ts`, `wasm-worker.ts`) handles this — use it.
|
||||
3. **`nisps.js` is non-module Emscripten glue (no ES exports).** Workers/worklet fetch + indirect-eval
|
||||
to install the global `createNispsModule`; the worklet uses raw `WebAssembly.instantiate`.
|
||||
4. **WASM architecture is hardwired at build time** (`nisps/wasm/bindings.cpp`): 2→126 outputs,
|
||||
fixed hidden layers, 32 input slots. Requesting other sizes in options is a logged warning, not
|
||||
dynamic. Changing it means rebuilding WASM (`bash scripts/build-wasm.sh`) and updating the parity
|
||||
test (`tests/cpp/parity_check.cpp`) or parity CI goes red.
|
||||
5. **`curves.ts` ↔ `nisps/core/math.hpp` must stay lockstep** (golden-vector parity tests).
|
||||
6. **COOP/COEP headers are mandatory** for the WASM/worklet path — set in `vite.config.ts` for
|
||||
dev+preview, and at nginx server scope in prod (inherited by `/next/`).
|
||||
7. **Never emit "C15"** in code or bundle — the smoke test fails on it. Synth is "Powerful Synth
|
||||
Engine"/"Built-in Synth".
|
||||
8. **Two `MFParam` writers** (stage controls AND dock rows) share one store — edits in either re-render both.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deeper references (only when this doc isn't enough)
|
||||
|
||||
All in `docs/redesign/` (at the repo root, not under `manifold/`):
|
||||
- `BUILD-PLAN.md` — locked decisions + the 12-step build sequence + spec pointers (the resume anchor).
|
||||
- `engine-architecture.md` — full engine/spine/WASM design.
|
||||
- `dock-spec.md` — dock + drawers spec. `inputs-spec.md` — mixed-input design. `backends-spec.md` — backends.
|
||||
- `rl-feedback-design.md` + `feedback-modes-port-spec.md` + `findings-feedback-behaviour.md` — feedback modes (Mode 1/Mode 2).
|
||||
- `aimmersive-clone-spec.md` / `playground-2026.md` — the a-immersive feature parity target.
|
||||
- `src/backends/README.md` — backend wiring notes.
|
||||
|
||||
**Memories** (auto-loaded): `manifold-build` (status + locked decisions), `manifold-mixed-inputs`
|
||||
(N-D input design), `manifold-deploy-pipeline` (the auto-deploy chain), `single-double-joystick-toggle`.
|
||||
|
||||
---
|
||||
|
||||
*Keep this in sync with the code — update it in the same commit as any change to UI structure, the
|
||||
engine spine, the dock/drawers, or the build/deploy commands.*
|
||||
|
|
@ -28,6 +28,7 @@ import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model';
|
|||
import type { MFParam } from './model';
|
||||
import { CompositeStage } from './CompositeStage';
|
||||
import { ParticleStage } from './ParticleStage';
|
||||
import { SandwichStage } from './SandwichStage';
|
||||
import { SplitStage } from './SplitStage';
|
||||
import { OutputStage } from './OutputStage';
|
||||
import { InputMini } from './InputMini';
|
||||
|
|
@ -105,8 +106,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
const [tame, setTame] = useState(0.85);
|
||||
const [health, setHealth] = useState(0.8);
|
||||
const [rev, setRev] = useState(1);
|
||||
const [active, setActive] = useState<DrawerKey | null>('learn');
|
||||
const [depth, setDepth] = useState<DrawerDepth>('peek');
|
||||
const [active, setActive] = useState<DrawerKey | null>(null);
|
||||
const [depth, setDepth] = useState<DrawerDepth>('condensed');
|
||||
// Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon.
|
||||
const [sandwich, setSandwich] = useState(false);
|
||||
|
||||
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
|
||||
// feedback mode = "Explore and place"; default solo = "Mask gradients".
|
||||
|
|
@ -219,8 +222,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
setFollow(false);
|
||||
setPins([]);
|
||||
setMarkers([]);
|
||||
setActive('learn');
|
||||
setDepth('peek');
|
||||
setActive(null);
|
||||
setDepth('condensed');
|
||||
if (engine) engine.setInput(0.5, 0.5);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modeId]);
|
||||
|
|
@ -408,6 +411,23 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nudge — a small bounded weight perturbation of the current net (the left
|
||||
* half of the secondary pill). Routes to the scratchpad while exploring,
|
||||
* otherwise nudges the real net directly.
|
||||
*/
|
||||
const nudgeNet = () => {
|
||||
const c = controllerRef.current;
|
||||
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
|
||||
c.nudge();
|
||||
} else {
|
||||
engine?.feedback.nudge(0.05);
|
||||
engine?.process();
|
||||
}
|
||||
syncController();
|
||||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo. While exploring (Mode 2) this pops the scratchpad undo ring (reroll /
|
||||
* nudge). Otherwise it falls back to the UI snapshot stack (visual A/B seed).
|
||||
|
|
@ -523,8 +543,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
};
|
||||
if (map[e.key]) {
|
||||
setActive((a) => (a === map[e.key] ? null : map[e.key]));
|
||||
setDepth('peek');
|
||||
} else if (e.key === '\\') setDepth((d) => (d === 'full' ? 'peek' : 'full'));
|
||||
setDepth('condensed');
|
||||
} else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
|
||||
else if (focus === 'composite' && e.key === '[') {
|
||||
e.preventDefault();
|
||||
setSplit((s) => Math.max(0, s - 0.04));
|
||||
|
|
@ -771,7 +791,55 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{outputMode === 'particles' ? (
|
||||
{sandwich ? (
|
||||
// Sandwich centre-stage: shrunken input (left) · landscape stack
|
||||
// (centre, fills) · compact outputs (right). Replaces the Mode stage.
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 'clamp(200px, 24%, 320px)',
|
||||
flex: '0 0 auto',
|
||||
position: 'relative',
|
||||
borderRight: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
<Manifold
|
||||
pos={pos}
|
||||
onMove={onMove}
|
||||
noiseCap={noiseCap}
|
||||
pins={pins}
|
||||
markers={markers}
|
||||
variant={inputMapVariant}
|
||||
frozen={false}
|
||||
follow={follow}
|
||||
onLongPress={addPin}
|
||||
picking={picking}
|
||||
onPickLocation={onPickLocation}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||
{engine && (
|
||||
<SandwichStage
|
||||
engine={engine}
|
||||
version={version}
|
||||
pos={pos}
|
||||
layerCount={Math.min(params.length, 8)}
|
||||
names={params.map((p) => p.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 'clamp(200px, 24%, 320px)',
|
||||
flex: '0 0 auto',
|
||||
position: 'relative',
|
||||
borderLeft: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
<OutputStage params={params} values={values} onChange={setParam} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : outputMode === 'particles' ? (
|
||||
<ParticleStage pos={pos} onMove={onMove} />
|
||||
) : focus === 'composite' ? (
|
||||
<CompositeStage
|
||||
|
|
@ -852,6 +920,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
onUndo={undo}
|
||||
onCommit={commit}
|
||||
onReroll={reroll}
|
||||
onNudge={nudgeNet}
|
||||
onRandomise={reroll}
|
||||
canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0}
|
||||
ab={ab}
|
||||
onToggleAB={toggleAB}
|
||||
|
|
@ -951,7 +1021,19 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Dock ctx={ctx} active={active} setActive={setActive} depth={depth} setDepth={setDepth} />
|
||||
<Dock
|
||||
ctx={ctx}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
depth={depth}
|
||||
setDepth={setDepth}
|
||||
sandwich={sandwich}
|
||||
setSandwich={(v) => {
|
||||
setSandwich(v);
|
||||
// reveal the full 3-zone layout: a drawer would overlay the output zone
|
||||
if (v) setActive(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/**
|
||||
* The Console dock — right-edge 48px rail + one mutually-exclusive drawer with
|
||||
* three depth states (Peek 320 / Expand 520 / Full modal).
|
||||
* two depth states: condensed (360px side panel) and expanded (centred modal,
|
||||
* ~80% of the viewport). A vertically-centred tab on the panel's left edge
|
||||
* toggles between them; drawers are closed by default.
|
||||
*
|
||||
* RESTRUCTURED (operator dock restructure):
|
||||
* - TOP, pinned: the **Mode** selector — the active OUTPUT BACKEND/target
|
||||
|
|
@ -22,12 +24,12 @@ import type { ConsoleCtx, DrawerDepth, DrawerKey, OutputMode } from './types';
|
|||
import { OUTPUT_MODES } from './output-mode';
|
||||
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
|
||||
import {
|
||||
ModeIcon,
|
||||
ParticleIcon,
|
||||
MidiIcon,
|
||||
OscIcon,
|
||||
SynthIcon,
|
||||
EditorIcon,
|
||||
SandwichIcon,
|
||||
CloseIcon,
|
||||
ExpandIcon,
|
||||
GLYPH_FALLBACK,
|
||||
|
|
@ -79,17 +81,20 @@ function ModeSelector({
|
|||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 'var(--r-2)',
|
||||
border: '1px solid var(--accent)',
|
||||
border: '1px solid #ffffff',
|
||||
background: 'rgba(255,106,0,0.12)',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{mono ? <ModeIcon /> : '⊞'}
|
||||
M
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
|
|
@ -173,11 +178,14 @@ export interface DockProps {
|
|||
setActive: (k: DrawerKey | null) => void;
|
||||
depth: DrawerDepth;
|
||||
setDepth: (d: DrawerDepth) => void;
|
||||
/** Sandwich (parameter-landscape) centre-stage toggle. */
|
||||
sandwich: boolean;
|
||||
setSandwich: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const ORDER: DrawerKey[] = ['learn', 'inputs', 'route', 'settings', 'help'];
|
||||
|
||||
export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
||||
export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSandwich }: DockProps) {
|
||||
const { settings } = useSettings();
|
||||
const mono = settings.monochromeIcons;
|
||||
const restColour = unfocusedIconCss(settings.unfocusedIconColour);
|
||||
|
|
@ -192,7 +200,7 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
|||
title={s.label}
|
||||
onClick={() => {
|
||||
setActive(on ? null : key);
|
||||
setDepth('peek');
|
||||
setDepth('condensed');
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
|
@ -230,48 +238,105 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
|||
);
|
||||
};
|
||||
|
||||
const width = depth === 'expand' ? 520 : 320;
|
||||
const full = depth === 'full';
|
||||
const expanded = depth === 'expanded';
|
||||
const section = active ? DRAWERS[active] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{section && (
|
||||
<aside
|
||||
style={
|
||||
full
|
||||
? {
|
||||
{/* Dim backdrop behind the centred modal; click to collapse back. */}
|
||||
{section && expanded && (
|
||||
<div
|
||||
onClick={() => setDepth('condensed')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 85,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
WebkitBackdropFilter: 'blur(2px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{section && (
|
||||
// Shell: positions/sizes the drawer and is the anchor for the left-edge
|
||||
// tab. overflow:visible so the tab (at left:-22) is NOT clipped; the
|
||||
// inner wrapper handles scrolling.
|
||||
<aside
|
||||
style={
|
||||
expanded
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
zIndex: 90,
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
padding: 'var(--sp-6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--sp-3)',
|
||||
border: '1px solid var(--glass-line)',
|
||||
borderRadius: 'var(--r-3)',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
overflow: 'visible',
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 48,
|
||||
bottom: 0,
|
||||
width,
|
||||
width: 360,
|
||||
zIndex: 35,
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
borderLeft: '1px solid var(--glass-line)',
|
||||
boxShadow: '-8px 0 24px rgba(0,0,0,0.4)',
|
||||
padding: 'var(--sp-4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--sp-2)',
|
||||
overflow: 'auto',
|
||||
overflow: 'visible',
|
||||
animation: 'mfDrawerIn var(--dur-med) var(--ease-console)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Expand/collapse tab — vertically centred on the panel's left edge. */}
|
||||
<button
|
||||
type="button"
|
||||
title={expanded ? 'Condense' : 'Expand'}
|
||||
onClick={() => setDepth(expanded ? 'condensed' : 'expanded')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -22,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 22,
|
||||
height: 56,
|
||||
borderRadius: 'var(--r-2) 0 0 var(--r-2)',
|
||||
border: '1px solid var(--glass-line)',
|
||||
borderRight: 'none',
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
color: 'var(--fg-mute)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<ExpandIcon size={12} />
|
||||
</button>
|
||||
{/* Inner scroll wrapper — holds header + content, scrolls independently. */}
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'auto',
|
||||
padding: expanded ? 'var(--sp-6)' : 'var(--sp-4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: expanded ? 'var(--sp-3)' : 'var(--sp-2)',
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
|
|
@ -298,24 +363,6 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
|||
{depth}
|
||||
</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
{!full && (
|
||||
<button
|
||||
type="button"
|
||||
title={depth === 'peek' ? 'More' : 'Peek'}
|
||||
onClick={() => setDepth(depth === 'peek' ? 'expand' : 'peek')}
|
||||
style={dockMini}
|
||||
>
|
||||
<ExpandIcon size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Open full"
|
||||
onClick={() => setDepth(full ? 'peek' : 'full')}
|
||||
style={dockMini}
|
||||
>
|
||||
<ExpandIcon size={12} />
|
||||
</button>
|
||||
<button type="button" title="Close" onClick={() => setActive(null)} style={dockMini}>
|
||||
<CloseIcon size={12} />
|
||||
</button>
|
||||
|
|
@ -327,11 +374,11 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
|||
flexDirection: 'column',
|
||||
gap: 'var(--sp-2)',
|
||||
flex: 1,
|
||||
...(full ? { maxWidth: 720 } : {}),
|
||||
}}
|
||||
>
|
||||
{section.render(ctx, depth)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
|
|
@ -368,6 +415,27 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
|||
>
|
||||
{ORDER.map(iconBtn)}
|
||||
</div>
|
||||
{/* Sandwich toggle — pinned at the BOTTOM of the rail. */}
|
||||
<button
|
||||
type="button"
|
||||
title="Parameter sandwich (layer view)"
|
||||
onClick={() => setSandwich(!sandwich)}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 'var(--r-2)',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${sandwich ? 'var(--accent)' : 'transparent'}`,
|
||||
background: sandwich ? 'rgba(255,106,0,0.14)' : 'transparent',
|
||||
color: sandwich ? 'var(--accent)' : mono ? restColour : 'var(--fg-mute)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background var(--dur-fast), color var(--dur-fast)',
|
||||
}}
|
||||
>
|
||||
{mono ? <SandwichIcon /> : '≣'}
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Console — the FIVE real dock drawers (operator dock restructure). Each renderer
|
||||
* takes (ctx, depth); what shows is gated by depth (peek | expand | full):
|
||||
* takes (ctx, depth); what shows is gated by depth (condensed | expanded):
|
||||
*
|
||||
* learn — Learning : feedback-mode selector, solo/arm chooser, live params
|
||||
* inputs — Inputs : input source
|
||||
|
|
@ -162,13 +162,13 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
|
||||
<SectionLabel>Down action · feedback mode</SectionLabel>
|
||||
<Segmented value={ctx.feedbackMode} onChange={ctx.setFeedbackMode} options={FEEDBACK_OPTS} />
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
{FEEDBACK_DESC[ctx.feedbackMode]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<>
|
||||
<SectionLabel>Solo / arm scope</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
|
@ -204,7 +204,7 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{depth === 'full' && (
|
||||
{depth === 'expanded' && (
|
||||
<>
|
||||
<SectionLabel>Feedback lab</SectionLabel>
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
|
|
@ -339,21 +339,21 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
|
||||
<SectionLabel>Input source</SectionLabel>
|
||||
<Segmented value={inp.inputMode} onChange={inp.setInputMode} options={INPUT_MODE_OPTS} />
|
||||
{active && depth !== 'peek' && (
|
||||
{active && depth === 'expanded' && (
|
||||
<span style={{ fontSize: 9, color: STATUS_TONE[active.status.state] ?? 'var(--fg-dim)' }}>
|
||||
{active.status.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* ---- Internal (XY pad / manifold) ---- */}
|
||||
{inp.inputMode === 'internal' && depth !== 'peek' && (
|
||||
{inp.inputMode === 'internal' && depth === 'expanded' && (
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Drag the on-screen manifold / XY pad. Two axes feed the net directly — this is the default.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ---- Game Controller ---- */}
|
||||
{inp.inputMode === 'gamepad' && depth !== 'peek' && (
|
||||
{inp.inputMode === 'gamepad' && depth === 'expanded' && (
|
||||
<>
|
||||
<SectionLabel>Sticks</SectionLabel>
|
||||
<Segmented
|
||||
|
|
@ -384,7 +384,7 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
)}
|
||||
|
||||
{/* ---- MIDI ---- */}
|
||||
{inp.inputMode === 'midi' && depth !== 'peek' && (
|
||||
{inp.inputMode === 'midi' && depth === 'expanded' && (
|
||||
<>
|
||||
<SectionLabel>Device</SectionLabel>
|
||||
{inp.midiInputs.length === 0 ? (
|
||||
|
|
@ -466,7 +466,7 @@ function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
)}
|
||||
|
||||
{/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */}
|
||||
{reshaping && depth !== 'peek' && (
|
||||
{reshaping && depth === 'expanded' && (
|
||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
{/* TODO(workstream F, docs/redesign/inputs-spec.md — "multiple WASM modules +
|
||||
warm-start"): give every axis its own genuine input dimension by (re)loading a
|
||||
|
|
@ -509,7 +509,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>audio starts on the play gesture</span>
|
||||
</div>
|
||||
<Slider label="master volume" value={ctx.volume} min={0} max={1} step={0.01} onChange={ctx.setVolume} />
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<>
|
||||
<SectionLabel>Tempo</SectionLabel>
|
||||
<Slider label="bpm" value={ctx.bpm} min={40} max={220} step={1} onChange={ctx.setBpm} />
|
||||
|
|
@ -530,7 +530,7 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
</>
|
||||
);
|
||||
case 'particles':
|
||||
return depth !== 'peek' ? (
|
||||
return depth === 'expanded' ? (
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio).
|
||||
{/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */}
|
||||
|
|
@ -539,13 +539,13 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
case 'midi':
|
||||
// The full MIDI config (port picker, CC count, per-output CC/channel/name)
|
||||
// + preset bar render via OutputsBackendConfig in RoutingDrawer below.
|
||||
return depth !== 'peek' ? (
|
||||
return depth === 'expanded' ? (
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.
|
||||
</p>
|
||||
) : null;
|
||||
case 'osc':
|
||||
return depth !== 'peek' ? (
|
||||
return depth === 'expanded' ? (
|
||||
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Each output sends to an OSC path with a physical range, over the WebSocket bridge.
|
||||
</p>
|
||||
|
|
@ -568,23 +568,8 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
const nameFor = (idx: number, fallback: string) =>
|
||||
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
|
||||
|
||||
if (depth === 'full') {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip tone="var(--accent)">{modeDesc.label}</Chip>
|
||||
<BackendStatusChip ctx={ctx} />
|
||||
</div>
|
||||
{ModeConfig(ctx, depth)}
|
||||
{/* Specialised, editable per-backend config (MIDI/OSC) + named-preset bar. */}
|
||||
<OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />
|
||||
<SectionLabel>Advanced · {modeDesc.label}</SectionLabel>
|
||||
<BackendAdvanced backend={modeDesc.backend} params={ctx.params} setParam={ctx.setParam} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = depth === 'peek' ? ctx.params.slice(0, 6) : ctx.params;
|
||||
const expanded = depth === 'expanded';
|
||||
const rows = expanded ? ctx.params : ctx.params.slice(0, 6);
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
|
|
@ -596,15 +581,15 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
<BackendStatusChip ctx={ctx} />
|
||||
</div>
|
||||
{ModeConfig(ctx, depth)}
|
||||
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */}
|
||||
{depth !== 'peek' && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
|
||||
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden when condensed. */}
|
||||
{expanded && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
|
||||
<SectionLabel>Outputs · M mute · S arm · off/fixed/live</SectionLabel>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
maxHeight: depth === 'peek' ? 220 : 460,
|
||||
maxHeight: expanded ? 460 : 220,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
|
|
@ -617,14 +602,20 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
|||
param={labelled}
|
||||
value={values[i] ?? 0}
|
||||
onChange={(patch) => ctx.setParam(i, patch)}
|
||||
showCurve={depth !== 'peek'}
|
||||
showCurve={expanded}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{depth === 'peek' && ctx.params.length > 6 && (
|
||||
{!expanded && ctx.params.length > 6 && (
|
||||
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more — expand to edit</span>
|
||||
)}
|
||||
{expanded && (
|
||||
<>
|
||||
<SectionLabel>Advanced · {modeDesc.label}</SectionLabel>
|
||||
<BackendAdvanced backend={modeDesc.backend} params={ctx.params} setParam={ctx.setParam} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -654,7 +645,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
|
|||
onChange={(v) => set('monochromeIcons', v)}
|
||||
label="Monochrome icons"
|
||||
/>
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>Unfocused icon colour</div>
|
||||
<PillToggle
|
||||
|
|
@ -683,7 +674,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
|
|||
onChange={(v) => set('inputMap', v as InputMapMode)}
|
||||
options={INPUT_MAP_OPTS}
|
||||
/>
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode"
|
||||
uses the active mode's declared input (joystick → circular, else rectangular).
|
||||
|
|
@ -700,7 +691,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
|
|||
unit="px"
|
||||
onChange={(v) => set('cornerRadius', Math.round(v))}
|
||||
/>
|
||||
{depth !== 'peek' && (
|
||||
{depth === 'expanded' && (
|
||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
|
||||
Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict
|
||||
buttons are intentionally exempt. Default 2px.
|
||||
|
|
@ -716,7 +707,7 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
|
|||
|
||||
const KEYS: [string, string][] = [
|
||||
['1–5', 'open drawers'],
|
||||
['\\', 'full depth'],
|
||||
['\\', 'expand drawer'],
|
||||
['space / ↑', 'commit +'],
|
||||
['↓', 'perturb / down −'],
|
||||
['z', 'undo'],
|
||||
|
|
|
|||
|
|
@ -285,22 +285,8 @@ export function Manifold({
|
|||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (nc > 0.001) {
|
||||
const breathe = 1 + Math.sin(now / 600) * 0.06;
|
||||
const rCap = nc * Math.min(W, H) * 0.5 * breathe;
|
||||
const rCur = rCap * 0.55;
|
||||
ctx.setLineDash([4, 5]);
|
||||
ctx.strokeStyle = 'rgba(255,106,0,0.4)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, rCap, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(255,106,0,0.7)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, rCur, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
// Noise-cap exploration rings hidden for now (kept for possible later use).
|
||||
void nc;
|
||||
|
||||
ctx.shadowColor = fz ? cyan : accent;
|
||||
ctx.shadowBlur = 18;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* OutputEditor — the per-output menu (state · min · max · static value · curve).
|
||||
* Opens on hover over an output column/cell. Ported from `OutputEditor.jsx`.
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { CurvePad } from './CurvePad';
|
||||
import type { MFParam, ParamStatus } from './model';
|
||||
|
|
@ -51,7 +52,7 @@ function MiniSlider({
|
|||
disabled={disabled}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="mf-slider-input"
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%', ['--mf-pct' as string]: `${Math.max(0, Math.min(1, value))}` } as CSSProperties}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
|
@ -67,8 +68,35 @@ export interface OutputEditorProps {
|
|||
|
||||
export function OutputEditor({ param, onChange, onHold, onLeave, place }: OutputEditorProps) {
|
||||
const isLive = param.status === 'live';
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// After the popover lands at its anchored spot, nudge it back on-screen so it
|
||||
// never clips the viewport edges (matters when the window isn't full-screen).
|
||||
const [shift, setShift] = useState<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const fit = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
// r already includes the current shift — back it out to get the natural
|
||||
// anchored box, then derive the shift that pulls it inside the viewport.
|
||||
const left = r.left - shift.dx;
|
||||
const top = r.top - shift.dy;
|
||||
const M = 8; // keep an 8px gutter from each edge
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (left + r.width > window.innerWidth - M) dx = window.innerWidth - M - (left + r.width);
|
||||
if (left + dx < M) dx = M - left;
|
||||
if (top + r.height > window.innerHeight - M) dy = window.innerHeight - M - (top + r.height);
|
||||
if (top + dy < M) dy = M - top; // if too tall, prefer pinning the top
|
||||
if (dx !== shift.dx || dy !== shift.dy) setShift({ dx, dy });
|
||||
};
|
||||
fit();
|
||||
window.addEventListener('resize', fit);
|
||||
return () => window.removeEventListener('resize', fit);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onPointerEnter={onHold}
|
||||
onPointerLeave={onLeave}
|
||||
style={{
|
||||
|
|
@ -76,6 +104,7 @@ export function OutputEditor({ param, onChange, onHold, onLeave, place }: Output
|
|||
width: 196,
|
||||
zIndex: 80,
|
||||
...place,
|
||||
transform: `translate(${shift.dx}px, ${shift.dy}px)`,
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
|
|
|
|||
198
manifold/src/console/SandwichStage.tsx
Normal file
198
manifold/src/console/SandwichStage.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* SandwichStage — the N-dimensional "parameter sandwich" as a centre stage,
|
||||
* driven by the REAL engine (the same `EngineApi` instance the rest of the
|
||||
* console uses). Ported from the standalone /learn renderer.
|
||||
*
|
||||
* Each layer is one output channel of the MLP, sampled across the 2-D input
|
||||
* grid via `engine.inferBatch` → that surface IS the parameter's landscape.
|
||||
* The grid is re-sampled only when the net changes (`version`); a per-frame
|
||||
* rAF reads `engine.getOutputs()` for the live probe at the current input.
|
||||
*
|
||||
* Three-zone metaphor (input → MLP stack → outputs): this component owns the
|
||||
* MIDDLE zone. ConsoleApp docks a shrunken Manifold (left) and OutputStage
|
||||
* (right) around it. Drag the canvas to orbit; it lands top-down (a flat 2-D
|
||||
* map) and tilts into the 3-D stack. The stack is centred and grows to fill
|
||||
* the available height as more parameters are shown.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { EngineApi } from '../engine';
|
||||
|
||||
const GRID = 12;
|
||||
const N = GRID;
|
||||
const SX = 300, SZ = 300, LAYER_GAP = 120, ZAMP = 92, MARGIN = 34;
|
||||
// Reference scale: each layer adds a fixed ~px gap (so the stack GROWS with the
|
||||
// layer count) until it would overflow, at which point the fit scale caps it.
|
||||
const REF_SCALE = 0.8;
|
||||
|
||||
export interface SandwichStageProps {
|
||||
engine: EngineApi;
|
||||
/** Engine version counter — re-sample the landscape grid when it bumps. */
|
||||
version: number;
|
||||
/** Current 2-D input (drives the probe column). */
|
||||
pos: [number, number];
|
||||
/** How many output channels to render as layers (top→down). */
|
||||
layerCount: number;
|
||||
/** Per-layer labels (the mode's param names). */
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export function SandwichStage({ engine, version, pos, layerCount, names }: SandwichStageProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gridRef = useRef<Float32Array | null>(null);
|
||||
const posRef = useRef(pos);
|
||||
posRef.current = pos;
|
||||
const Kref = useRef(layerCount);
|
||||
Kref.current = Math.max(1, layerCount);
|
||||
const namesRef = useRef(names);
|
||||
namesRef.current = names;
|
||||
const yaw = useRef(0);
|
||||
const pitch = useRef(Math.PI / 2); // top-down (looks 2-D) until you tilt it
|
||||
const view = useRef({ cw: 800, ch: 600, scale: 1, ceny: 0 });
|
||||
|
||||
const OUT = engine.architecture.outputSize;
|
||||
|
||||
// ---- re-sample the landscape grid whenever the net changes ----
|
||||
useEffect(() => {
|
||||
const pts: [number, number][] = [];
|
||||
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) pts.push([i / N, j / N]);
|
||||
try {
|
||||
gridRef.current = engine.inferBatch(pts);
|
||||
} catch {
|
||||
gridRef.current = null;
|
||||
}
|
||||
}, [engine, version]);
|
||||
|
||||
// ---- render loop ----
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
if (!canvas || !wrap) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
|
||||
const computeView = () => {
|
||||
const v = view.current;
|
||||
const K = Kref.current;
|
||||
v.ceny = -(K - 1) * LAYER_GAP / 2 + ZAMP / 2; // centroid (centre vertically)
|
||||
let R = 1;
|
||||
for (let k = 0; k < K; k++) for (const z of [0, 1]) for (const gx of [-0.5, 0.5]) for (const gy of [-0.5, 0.5]) {
|
||||
const X = gx * SX, Y = (-k * LAYER_GAP + z * ZAMP) - v.ceny, Z = gy * SZ;
|
||||
const d = Math.sqrt(X * X + Y * Y + Z * Z);
|
||||
if (d > R) R = d;
|
||||
}
|
||||
const fit = (Math.min(v.cw, v.ch) / 2 - MARGIN) / R;
|
||||
v.scale = Math.min(REF_SCALE, fit); // grow with K, then cap to fit
|
||||
};
|
||||
const resize = () => {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
view.current.cw = Math.max(160, Math.round(r.width));
|
||||
view.current.ch = Math.max(160, Math.round(r.height));
|
||||
canvas.width = view.current.cw * DPR;
|
||||
canvas.height = view.current.ch * DPR;
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
computeView();
|
||||
};
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(wrap);
|
||||
resize();
|
||||
|
||||
const project = (gx: number, gy: number, zv: number, k: number) => {
|
||||
const v = view.current;
|
||||
const X = gx * SX, Y = (-k * LAYER_GAP + zv * ZAMP) - v.ceny, Z = -gy * SZ;
|
||||
const cyA = Math.cos(yaw.current), syA = Math.sin(yaw.current);
|
||||
const x1 = X * cyA - Z * syA, z1 = X * syA + Z * cyA;
|
||||
const cpA = Math.cos(pitch.current), spA = Math.sin(pitch.current);
|
||||
const y2 = Y * cpA - z1 * spA, z2 = Y * spA + z1 * cpA;
|
||||
return { x: v.cw / 2 + x1 * v.scale, y: v.ch / 2 - y2 * v.scale, depth: z2, wx: X, wy: Y, wz: Z };
|
||||
};
|
||||
const hueOf = (k: number, K: number) => (K > 1 ? 26 + (196 - 26) * k / (K - 1) : 26);
|
||||
const colCss = (k: number, K: number, a: number) => `hsla(${hueOf(k, K).toFixed(0)},85%,60%,${a})`;
|
||||
const LIGHT = (() => { const v = [0.35, 0.86, 0.36]; const m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; })();
|
||||
const clamp = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||
|
||||
let raf = 0;
|
||||
const draw = () => {
|
||||
const v = view.current;
|
||||
const K = Kref.current;
|
||||
const grid = gridRef.current;
|
||||
computeView();
|
||||
ctx.clearRect(0, 0, v.cw, v.ch);
|
||||
const H = (k: number, i: number, j: number) => (grid ? grid[(j * (N + 1) + i) * OUT + k] : 0.5);
|
||||
|
||||
const quads: { a: any; b: any; d: any; e: any; hue: number; L: number; depth: number }[] = [];
|
||||
const labels: { x: number; y: number; hue: number; name: string }[] = [];
|
||||
for (let k = 0; k < K; k++) {
|
||||
const hue = hueOf(k, K);
|
||||
const P: { x: number; y: number; depth: number; wx: number; wy: number; wz: number }[][] = [];
|
||||
for (let j = 0; j <= N; j++) { P[j] = []; for (let i = 0; i <= N; i++) P[j][i] = project(i / N - 0.5, j / N - 0.5, H(k, i, j), k); }
|
||||
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
|
||||
const a = P[j][i], b = P[j][i + 1], d = P[j + 1][i + 1], e = P[j + 1][i];
|
||||
const ux = d.wx - a.wx, uy = d.wy - a.wy, uz = d.wz - a.wz, vx = b.wx - e.wx, vy = b.wy - e.wy, vz = b.wz - e.wz;
|
||||
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, nm = Math.hypot(nx, ny, nz) || 1;
|
||||
const L = clamp(0.32 + 0.62 * Math.abs((nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]) / nm));
|
||||
quads.push({ a, b, d, e, hue, L, depth: (a.depth + b.depth + d.depth + e.depth) / 4 });
|
||||
}
|
||||
const lp = project(-0.5, 0.5, H(k, 0, N), k);
|
||||
labels.push({ x: lp.x, y: lp.y, hue, name: namesRef.current[k] || `p${k}` });
|
||||
}
|
||||
quads.sort((p, q) => q.depth - p.depth);
|
||||
for (const Q of quads) {
|
||||
ctx.fillStyle = `hsla(${Q.hue},80%,${(26 + Q.L * 44).toFixed(0)}%,0.66)`;
|
||||
ctx.strokeStyle = `hsla(${Q.hue},80%,82%,0.18)`; ctx.lineWidth = 0.6;
|
||||
ctx.beginPath(); ctx.moveTo(Q.a.x, Q.a.y); ctx.lineTo(Q.b.x, Q.b.y); ctx.lineTo(Q.d.x, Q.d.y); ctx.lineTo(Q.e.x, Q.e.y); ctx.closePath();
|
||||
ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.font = '11px var(--font-mono, monospace)'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||||
for (const Lb of labels) { ctx.fillStyle = `hsla(${Lb.hue},85%,74%,0.95)`; ctx.fillText(Lb.name, Lb.x - 6, Lb.y); }
|
||||
|
||||
// probe column at the current input, marker on each slab from the LIVE output
|
||||
const out = engine.getOutputs();
|
||||
const gx = posRef.current[0] - 0.5, gy = posRef.current[1] - 0.5;
|
||||
const top = project(gx, gy, 1.18, 0), bot = project(gx, gy, -0.18, K - 1);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(top.x, top.y); ctx.lineTo(bot.x, bot.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (let k = 0; k < K; k++) {
|
||||
const val = clamp(out[k] ?? 0.5);
|
||||
const p = project(gx, gy, val, k), fl = project(gx, gy, 0, k);
|
||||
ctx.strokeStyle = colCss(k, K, 0.5); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(fl.x, fl.y); ctx.stroke();
|
||||
ctx.fillStyle = colCss(k, K, 1); ctx.shadowColor = colCss(k, K, 1); ctx.shadowBlur = 12;
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = 'var(--bg, #0d0d0d)'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||
}, [engine, OUT]);
|
||||
|
||||
// ---- drag to orbit (yaw + pitch; pitch up to π/2 = exact top-down) ----
|
||||
const rotating = useRef(false);
|
||||
const last = useRef({ x: 0, y: 0 });
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'grab', touchAction: 'none' }}
|
||||
onPointerDown={(e) => {
|
||||
rotating.current = true;
|
||||
(e.currentTarget as HTMLCanvasElement).setPointerCapture?.(e.pointerId);
|
||||
last.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!rotating.current) return;
|
||||
yaw.current += (e.clientX - last.current.x) * 0.01;
|
||||
pitch.current = Math.max(0.06, Math.min(Math.PI / 2, pitch.current + (e.clientY - last.current.y) * 0.008));
|
||||
last.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onPointerUp={() => { rotating.current = false; }}
|
||||
onPointerCancel={() => { rotating.current = false; }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: 10, left: '50%', transform: 'translateX(-50%)', fontSize: 9, letterSpacing: '0.12em', color: 'var(--fg-dim)', pointerEvents: 'none', fontFamily: 'var(--font-mono)' }}>
|
||||
PARAMETER SANDWICH · drag to rotate · top-down = the flat 2-D map
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { FeedbackModeUI } from './types';
|
||||
import { DiceIcon } from './icons';
|
||||
|
||||
function ThumbIcon({ size = 24, down = false }: { size?: number; down?: boolean }) {
|
||||
return (
|
||||
|
|
@ -44,6 +45,10 @@ export interface VerdictClusterProps {
|
|||
onUndo: () => void;
|
||||
onCommit: () => void;
|
||||
onReroll: () => void;
|
||||
/** Small bounded weight perturbation of the current net (left half of the pill). */
|
||||
onNudge: () => void;
|
||||
/** Full re-roll of the current net (right half of the pill). */
|
||||
onRandomise: () => void;
|
||||
canUndo: boolean;
|
||||
ab: 'A' | 'B';
|
||||
onToggleAB: () => void;
|
||||
|
|
@ -62,6 +67,8 @@ export function VerdictCluster({
|
|||
onUndo,
|
||||
onCommit,
|
||||
onReroll,
|
||||
onNudge,
|
||||
onRandomise,
|
||||
canUndo,
|
||||
ab,
|
||||
onToggleAB,
|
||||
|
|
@ -116,6 +123,22 @@ export function VerdictCluster({
|
|||
...extra,
|
||||
});
|
||||
|
||||
// Two halves of the secondary pill (nudge | randomise).
|
||||
const pillHalf = (extra: CSSProperties): CSSProperties => ({
|
||||
flex: 1,
|
||||
height: 30,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-mute)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 'var(--fs-xs)',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'background var(--dur-fast), color var(--dur-fast)',
|
||||
...extra,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHover(true)}
|
||||
|
|
@ -125,6 +148,17 @@ export function VerdictCluster({
|
|||
bottom: 28,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--sp-2)',
|
||||
opacity: hover || firstSession ? 1 : 0.55,
|
||||
transition: 'opacity var(--dur-med) var(--ease-console)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--sp-3)',
|
||||
|
|
@ -135,9 +169,6 @@ export function VerdictCluster({
|
|||
border: '1px solid var(--glass-line)',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
opacity: hover || firstSession ? 1 : 0.55,
|
||||
transition: 'opacity var(--dur-med) var(--ease-console)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
|
@ -148,18 +179,15 @@ export function VerdictCluster({
|
|||
onPointerLeave={() => {
|
||||
if (lp.current) clearTimeout(lp.current);
|
||||
}}
|
||||
style={big(
|
||||
explore
|
||||
? exploring
|
||||
? { background: 'rgba(0,204,255,0.16)', color: 'var(--accent-2)' }
|
||||
: { background: 'rgba(0,204,255,0.10)', color: 'var(--accent-2)' }
|
||||
: { background: 'rgba(255,68,102,0.16)', color: 'var(--danger)' },
|
||||
)}
|
||||
style={big({
|
||||
background: explore && exploring ? 'rgba(255,106,0,0.22)' : 'rgba(255,106,0,0.16)',
|
||||
color: 'var(--accent)',
|
||||
})}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||
>
|
||||
{/* Explore-mode down is a re-roll/explore (↻), not a dislike thumb. */}
|
||||
{explore ? <span style={{ fontSize: 24, lineHeight: 1 }}>↻</span> : <ThumbIcon down />}
|
||||
{/* Explore mode = dice (re-roll into a scratchpad); dislike mode = thumb-down. */}
|
||||
{explore ? <DiceIcon size={24} /> : <ThumbIcon down />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -168,9 +196,9 @@ export function VerdictCluster({
|
|||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
style={big({
|
||||
width: 48,
|
||||
height: 48,
|
||||
fontSize: 20,
|
||||
width: 36,
|
||||
height: 36,
|
||||
fontSize: 16,
|
||||
background: 'var(--bg-2)',
|
||||
color: 'var(--fg-mute)',
|
||||
opacity: canUndo ? 1 : 0.4,
|
||||
|
|
@ -184,11 +212,19 @@ export function VerdictCluster({
|
|||
type="button"
|
||||
title={upTitle}
|
||||
onClick={onCommit}
|
||||
style={big({
|
||||
background: picking ? 'rgba(0,204,255,0.22)' : 'rgba(255,106,0,0.18)',
|
||||
color: picking ? 'var(--accent-2)' : 'var(--accent)',
|
||||
boxShadow: picking ? '0 0 16px var(--accent-2)' : '0 0 16px var(--glow-accent)',
|
||||
})}
|
||||
style={big(
|
||||
picking
|
||||
? {
|
||||
background: 'rgba(0,204,255,0.22)',
|
||||
color: 'var(--accent-2)',
|
||||
boxShadow: '0 0 16px var(--accent-2)',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(107,194,107,0.20)',
|
||||
color: 'var(--good)',
|
||||
boxShadow: '0 0 16px rgba(107,194,107,0.45)',
|
||||
},
|
||||
)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||
>
|
||||
|
|
@ -196,5 +232,55 @@ export function VerdictCluster({
|
|||
{explore && exploring ? <span style={{ fontSize: 22, lineHeight: 1 }}>⌖</span> : <ThumbIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary pill — two halves: nudge (small perturbation) | randomise (full re-roll). */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
width: 220,
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
border: '1px solid var(--glass-line)',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="Nudge — small bounded perturbation of the current net"
|
||||
onClick={onNudge}
|
||||
style={pillHalf({ borderRight: '1px solid var(--glass-line)' })}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,106,0,0.12)';
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--fg-mute)';
|
||||
}}
|
||||
>
|
||||
nudge
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Randomise — full re-roll of the current net"
|
||||
onClick={onRandomise}
|
||||
style={pillHalf({})}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,106,0,0.12)';
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--fg-mute)';
|
||||
}}
|
||||
>
|
||||
randomise
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,19 @@ export function ModeIcon({ size = 18, style }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Sandwich — stacked parameter-landscape slabs (the layer toggle). */
|
||||
export function SandwichIcon({ size = 18, style }: IconProps) {
|
||||
return svg(
|
||||
size,
|
||||
style,
|
||||
<>
|
||||
<path d="M3 6.5 12 3l9 3.5L12 10 3 6.5Z" />
|
||||
<path d="M3 12 12 15.5 21 12" />
|
||||
<path d="M3 17 12 20.5 21 17" />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Learning — a brain-ish node graph. */
|
||||
export function LearningIcon({ size = 18, style }: IconProps) {
|
||||
return svg(
|
||||
|
|
@ -117,6 +130,22 @@ export function HelpIcon({ size = 18, style }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Dice — explore / re-roll into a scratchpad (a die showing five pips). */
|
||||
export function DiceIcon({ size = 22, style }: IconProps) {
|
||||
return svg(
|
||||
size,
|
||||
style,
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
<circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="8.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Close (✕). */
|
||||
export function CloseIcon({ size = 14, style }: IconProps) {
|
||||
return svg(size, style, <path d="M6 6l12 12M18 6 6 18" />);
|
||||
|
|
|
|||
|
|
@ -163,7 +163,13 @@ export function CompactAxis({
|
|||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="mf-slider-input"
|
||||
style={{ width: 120, ['--mf-axis-accent' as string]: accent } as CSSProperties}
|
||||
style={
|
||||
{
|
||||
width: 120,
|
||||
['--mf-axis-accent' as string]: accent,
|
||||
['--mf-pct' as string]: `${Math.max(0, Math.min(1, value))}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export interface Snapshot {
|
|||
}
|
||||
|
||||
export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help';
|
||||
export type DrawerDepth = 'peek' | 'expand' | 'full';
|
||||
export type DrawerDepth = 'condensed' | 'expanded';
|
||||
export type Focus = 'in' | 'split' | 'out' | 'composite';
|
||||
|
||||
export interface Axes {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* (ConsoleApp owns it) — never a second data path (dock-spec §3.2, §8).
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { MFParam, ParamStatus } from '../console/model';
|
||||
import { CurvePad } from '../console/CurvePad';
|
||||
|
||||
|
|
@ -288,7 +288,7 @@ export function OutputControlRow({ param, value, onChange, showCurve = false }:
|
|||
value={param.val}
|
||||
onChange={(e) => onChange({ val: parseFloat(e.target.value) })}
|
||||
className="mf-slider-input"
|
||||
style={{ flex: 1 }}
|
||||
style={{ flex: 1, ['--mf-pct' as string]: `${Math.max(0, Math.min(1, param.val))}` } as CSSProperties}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,21 @@
|
|||
*/
|
||||
|
||||
/* ---- Slider (.mf-slider-input) ---- */
|
||||
/* Base reset so the custom track/thumb styling applies even when a consumer
|
||||
doesn't set these inline (e.g. OutputControlRow's held slider). The colored
|
||||
fill tracks `--mf-pct` (0..1); it falls back to 0 if a consumer forgets it. */
|
||||
.mf-slider-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
.mf-slider-input::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--accent) 0%,
|
||||
var(--accent) calc(var(--mf-pct) * 100%),
|
||||
var(--accent) calc(var(--mf-pct, 0) * 100%),
|
||||
var(--bg-3) 0%
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +36,11 @@
|
|||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.mf-slider-input::-moz-range-progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.mf-slider-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ test('engine loads, spine propagates, console renders', async ({ page }) => {
|
|||
// 4. The convertible Console rendered (assert on the dock drawer rail, which
|
||||
// is always present — the old "MEMLNaut" wordmark only shows outside
|
||||
// Particle mode now that the particle top bar is a heatmap strip).
|
||||
await expect(page.getByText('Learning')).toBeVisible();
|
||||
// Drawers are closed by default, so assert on the rail button's title
|
||||
// rather than the (hidden) drawer-header text.
|
||||
await expect(page.getByTitle('Learning')).toBeVisible();
|
||||
|
||||
// 5. No "C15" anywhere in the rendered UI.
|
||||
const body = await page.evaluate(() => document.body.innerText);
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class ModeBase {
|
|||
// Run an inference at default inputs so engine has params on first
|
||||
// process() call, even if no input has been touched.
|
||||
for (std::size_t i = 0u; i < NInputs; ++i) {
|
||||
ml_.set_input(i, input_channels_[i]);
|
||||
ml_.set_input(i, effective_input(i));
|
||||
}
|
||||
ml_.process();
|
||||
if constexpr (kRouteOutputsToEngine) {
|
||||
|
|
@ -165,6 +165,34 @@ class ModeBase {
|
|||
input_dirty_ = true;
|
||||
}
|
||||
|
||||
// ---- Input neutralization (single/double controller toggle) ----
|
||||
//
|
||||
// A pinned channel feeds `pin_value_` (neutral, default 0.5) to the MLP
|
||||
// instead of its live value, without rebuilding/resizing the network.
|
||||
// Glue toggles which channels are pinned (e.g. single-joystick mode pins
|
||||
// the second 2D controller's two channels). The stored live value is left
|
||||
// untouched, so unpinning resumes from the controller's current position.
|
||||
NISPS_FORCE_INLINE void set_input_pinned(std::size_t idx, bool pinned) noexcept {
|
||||
if (idx >= NInputs) return;
|
||||
input_pinned_[idx] = pinned;
|
||||
input_dirty_ = true;
|
||||
}
|
||||
NISPS_FORCE_INLINE bool is_input_pinned(std::size_t idx) const noexcept {
|
||||
return idx < NInputs && input_pinned_[idx];
|
||||
}
|
||||
NISPS_FORCE_INLINE void set_pin_value(float v) noexcept {
|
||||
if (v < 0.f) v = 0.f;
|
||||
else if (v > 1.f) v = 1.f;
|
||||
pin_value_ = v;
|
||||
input_dirty_ = true;
|
||||
}
|
||||
float pin_value() const noexcept { return pin_value_; }
|
||||
|
||||
// Effective value fed to the MLP for channel i (pin override applied).
|
||||
NISPS_FORCE_INLINE float effective_input(std::size_t i) const noexcept {
|
||||
return input_pinned_[i] ? pin_value_ : input_channels_[i];
|
||||
}
|
||||
|
||||
NISPS_HOT void tick_control() noexcept {
|
||||
if constexpr (requires(Derived& d) { d.on_pre_inference(); }) {
|
||||
static_cast<Derived&>(*this).on_pre_inference();
|
||||
|
|
@ -177,9 +205,10 @@ class ModeBase {
|
|||
if (jolt_.active()) apply_jolt_();
|
||||
jolt_.tick_lr_ramp();
|
||||
|
||||
// Forward (possibly Derived-mutated) channels into the MLP.
|
||||
// Forward (possibly Derived-mutated) channels into the MLP, applying
|
||||
// the per-channel pin override.
|
||||
for (std::size_t i = 0u; i < NInputs; ++i) {
|
||||
ml_.set_input(i, input_channels_[i]);
|
||||
ml_.set_input(i, effective_input(i));
|
||||
}
|
||||
ml_.process();
|
||||
if constexpr (kRouteOutputsToEngine) {
|
||||
|
|
@ -294,6 +323,8 @@ class ModeBase {
|
|||
EngineT engine_{};
|
||||
MLPType ml_;
|
||||
std::array<float, NInputs> input_channels_{};
|
||||
std::array<bool, NInputs> input_pinned_{}; // false => live
|
||||
float pin_value_ = 0.5f; // neutral
|
||||
bool input_dirty_ = false;
|
||||
std::size_t voice_space_idx_ = 0u;
|
||||
RingBuffer<ControlEvent, kModeEventBufferSize> events_{};
|
||||
|
|
|
|||
|
|
@ -88,6 +88,62 @@ NISPS_TEST(paf_synth_mode_input_clamping) {
|
|||
m.set_input(99u, 0.5f);
|
||||
}
|
||||
|
||||
NISPS_TEST(paf_synth_mode_pinned_inputs_neutralized) {
|
||||
// Single-joystick mode pins the second 2D controller's channels (2,3).
|
||||
// A pinned channel must feed the neutral pin value (default 0.5) to the
|
||||
// MLP regardless of its live value, and pinning must not resize the model.
|
||||
PAFSynthMode m;
|
||||
m.setup(48000.f);
|
||||
NISPS_EXPECT(m.pin_value() == 0.5f);
|
||||
|
||||
// Reference: channels 2,3 driven live to 0.5 (the neutral value).
|
||||
m.set_input(0, 0.2f);
|
||||
m.set_input(1, 0.8f);
|
||||
m.set_input(2, 0.5f);
|
||||
m.set_input(3, 0.5f);
|
||||
m.tick_control();
|
||||
std::array<float, 33> ref{};
|
||||
{
|
||||
const auto outs = m.ml().outputs();
|
||||
for (std::size_t i = 0u; i < outs.size(); ++i) ref[i] = outs[i];
|
||||
}
|
||||
|
||||
// Pin channels 2,3, then drive them to arbitrary values. Output must match
|
||||
// the reference (they are neutralized to 0.5).
|
||||
m.set_input_pinned(2, true);
|
||||
m.set_input_pinned(3, true);
|
||||
NISPS_EXPECT(m.is_input_pinned(2));
|
||||
m.set_input(2, 0.0f);
|
||||
m.set_input(3, 1.0f);
|
||||
m.tick_control();
|
||||
for (std::size_t i = 0u; i < 33u; ++i) {
|
||||
NISPS_EXPECT(std::fabs(m.ml().outputs()[i] - ref[i]) < 1e-6f);
|
||||
}
|
||||
|
||||
// The active controller (channels 0,1) still affects the output.
|
||||
m.set_input(0, 0.9f);
|
||||
m.tick_control();
|
||||
bool changed = false;
|
||||
for (std::size_t i = 0u; i < 33u; ++i) {
|
||||
if (std::fabs(m.ml().outputs()[i] - ref[i]) > 1e-6f) { changed = true; break; }
|
||||
}
|
||||
NISPS_EXPECT(changed);
|
||||
|
||||
// Unpinning restores sensitivity on channels 2,3.
|
||||
m.set_input_pinned(2, false);
|
||||
m.set_input_pinned(3, false);
|
||||
m.set_input(0, 0.2f); // restore active channels to the reference pose
|
||||
m.set_input(1, 0.8f);
|
||||
m.set_input(2, 0.0f);
|
||||
m.set_input(3, 1.0f);
|
||||
m.tick_control();
|
||||
bool differs = false;
|
||||
for (std::size_t i = 0u; i < 33u; ++i) {
|
||||
if (std::fabs(m.ml().outputs()[i] - ref[i]) > 1e-6f) { differs = true; break; }
|
||||
}
|
||||
NISPS_EXPECT(differs);
|
||||
}
|
||||
|
||||
NISPS_TEST(paf_synth_mode_engine_and_ml_accessors) {
|
||||
PAFSynthMode m;
|
||||
m.setup(48000.f);
|
||||
|
|
|
|||
Loading…
Reference in a new issue