Files
seedbox/sim/app.js
Gee 346c820b10
Some checks failed
check / check (push) Has been cancelled
Split kit and keys; KEYS GEN with MOTIF, 303, TURING, ARP
- genome(seed, salts) now has three parts with their own RNG streams:
  shared (steps, form, swing, arc, layback, fill odds, phase offset),
  kit (density, frame, accent cycle, free snare) and keys (scale/root,
  progression, motif, hocket, bass tone, note lengths, range). ROLL KEYS
  now renews the harmony too; ROLL KIT leaves the keys alone.
- Keys generators share one signature and can fix note lengths:
  MOTIF (the old line), 303 (acid bass, answers in the rests, drone or
  silent pad), TURING (8/16-bit shift register, DRIFT flips bits, 16-bar
  window), ARP (triad/7th arpeggio, roots on kicks).
- Pages: KIT GEN, KEYS GEN; ROLL shows KIT / KEYS; slots keep both gens.
- Flash record v3 (old settings ignored). Bring-up golden checksums updated.
- C port matches; host test and ASan/UBSan pass.

Intended music change: every seed sounds different (genome split, new
generators). Vectors regenerated with npm run vectors; the case grid now
crosses all kit and keys generators, with TURING runs past its window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:50:41 +03:00

488 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================= APP (browser only: UI, Web MIDI, scheduler) ================= */
(function(){
var E=Engine, TPB=E.TPB, NT=E.NT, HOLD_MS=250;
function store(k,v){ try{ localStorage.setItem('seedbox3.'+k,JSON.stringify(v)); }catch(e){} }
function load(k,d){ try{ var v=localStorage.getItem('seedbox3.'+k); return v?JSON.parse(v):d; }catch(e){ return d; } }
function rnd32(){ try{ return crypto.getRandomValues(new Uint32Array(1))[0]>>>0; }catch(e){ return (Date.now()*2654435761)>>>0; } }
function hex(n){ return ('00000000'+(n>>>0).toString(16).toUpperCase()).slice(-8); }
function clamp(x,a,b){ return x<a?a:x>b?b:x; }
function mod(a,n){ return ((a%n)+n)%n; }
function laneTracks(l){ var a=[]; for(var t=0;t<NT;t++) if(E.TRACKS[t].lane===l) a.push(t); return a; }
var KIT=laneTracks(0);
var DEF={portDrums:'',portMono:'',portIn:'',clock:true,
kit:{learn:[null,null,null,null,null,null,null]}, mono:{ch:1,oct:0}, hw:{display:'5110',leds:false}};
var cfg=(function(saved){ var c=JSON.parse(JSON.stringify(DEF)); Object.keys(c).forEach(function(k){
if(saved[k]===undefined) return; if(typeof c[k]==='object'){ Object.keys(c[k]).forEach(function(f){ if(saved[k][f]!==undefined) c[k][f]=saved[k][f]; }); } else c[k]=saved[k]; });
return c; })(load('cfg',{}));
if(!Array.isArray(cfg.kit.learn) || cfg.kit.learn.length<7) cfg.kit.learn=[null,null,null,null,null,null,null];
function saveCfg(){ store('cfg',cfg); }
var app={ state:load('state',null)||{seed:0x5EEDB0C5,salts:[0,0]}, gen:load('gen',0), keys:load('keys',0), scale:load('scale',-1), root:load('root',-1), energy:load('energy',64), drift:load('drift',32), bpm:load('bpm',165),
on:load('on',null), inv:[], fill:false, history:[], slots:load('slots',[null,null,null,null,null,null,null,null]),
page:0, editing:false, histSel:0, slotSel:0, rollSel:0, learn:0, learnTr:-1 };
if(!(app.keys>=0 && app.keys<E.KEYS_NAMES.length)) app.keys=0;
if(!(app.scale>=-1 && app.scale<E.SCALE_NAMES.length)) app.scale=-1;
if(!(app.root>=-1 && app.root<12)) app.root=-1;
if(!Array.isArray(app.on) || app.on.length!==NT){ app.on=[]; for(var i9=0;i9<NT;i9++) app.on.push(true); }
for(var i0=0;i0<NT;i0++) app.inv.push(false); // inv: flipped while a button is held
var PAGES=['SEED','GEN','KEYS','ROOT','SCALE','BPM','SLOT','ROLL','LEARN'];
function params(fillNow){ return {gen:app.gen,keys:app.keys,energy:app.energy,drift:app.drift,fill:!!fillNow,scale:app.scale,root:app.root}; }
function save(){ store('state',app.state); store('gen',app.gen); store('keys',app.keys); store('scale',app.scale); store('root',app.root); store('energy',app.energy); store('drift',app.drift); store('bpm',app.bpm); }
function setState(s){
app.history.unshift({seed:app.state.seed,salts:app.state.salts.slice()}); if(app.history.length>16) app.history.pop();
app.state={seed:s.seed>>>0,salts:[s.salts[0]|0,s.salts[1]|0]}; app.histSel=0; save(); dirty=true;
}
function baseOn(tr){ return !!app.on[tr]; }
function sounds(tr,mask){ var b=baseOn(tr,mask); return app.inv[tr]?!b:b; }
var flashMsg='', flashMsgUntil=0;
function msg(t,ms){ flashMsg=t; flashMsgUntil=performance.now()+(ms||900); }
/* ---------- MIDI ---------- */
var midi=null, outs={}, ins={}, inPort=null, statusEl=document.getElementById('midiStatus');
function setStatus(t,cls){ statusEl.textContent=t; statusEl.className='pill'+(cls?' '+cls:''); }
function fillSelect(id,list,re){
var sel=document.getElementById(id), want=cfg[id], map={}; sel.innerHTML='';
var none=document.createElement('option'); none.value=''; none.textContent='(none)'; sel.appendChild(none);
list.forEach(function(o){ map[o.id]=o; var op=document.createElement('option'); op.value=o.id; op.textContent=o.name; sel.appendChild(op); });
if(!want || !map[want]){ var guess=list.filter(function(o){ return re.test(o.name); })[0]; if(guess){ want=guess.id; cfg[id]=want; } }
sel.value=map[want]?want:'';
}
function fillPorts(){
outs={}; ins={}; var lo=[], li=[];
if(midi){ midi.outputs.forEach(function(o){ outs[o.id]=o; lo.push(o); }); midi.inputs.forEach(function(o){ ins[o.id]=o; li.push(o); }); }
fillSelect('portDrums',lo,/404/i); fillSelect('portMono',lo,/minifreak|freak/i); fillSelect('portIn',li,/404/i);
hookInput();
if(midi) setStatus('MIDI: '+lo.length+' out, '+li.length+' in', lo.length?'ok':'');
}
function hookInput(){
if(inPort) inPort.onmidimessage=null;
inPort=ins[cfg.portIn]||null;
if(inPort) inPort.onmidimessage=function(e){ onMidiIn(e.data); };
}
if(navigator.requestMIDIAccess){
navigator.requestMIDIAccess({sysex:false}).then(function(m){ midi=m; m.onstatechange=fillPorts; fillPorts(); },
function(){ setStatus('MIDI blocked in this view. Open the standalone file in Chrome','bad'); fillPorts(); });
} else { setStatus('No Web MIDI here. Use Chrome or Edge on desktop','bad'); fillPorts(); }
/* Pad learn: LEARN page, push, press a kit track, then hit the pad on the sampler. Stores the exact channel + note received. */
function onMidiIn(d){
if(!d || d.length<3) return;
if((d[0]&0xF0)!==0x90 || d[2]===0) return;
if(app.learn===2 && app.learnTr>=0){
cfg.kit.learn[app.learnTr]={ch:d[0]&0x0F,note:d[1]}; saveCfg(); refreshPadFields();
applyOn(KIT[app.learnTr],true); dirty=true; // a freshly learned track starts on
msg('N'+d[1]+' CH'+((d[0]&15)+1)+' OK',1400);
app.learn=0; app.learnTr=-1; app.editing=false;
}
}
function portFor(lane){ return outs[lane===1?cfg.portMono:cfg.portDrums]||null; }
/* A learned pad plays on exactly the channel and note it was learned with. */
function learned(tr){ var L=cfg.kit.learn[tr]; return L?{ch:L.ch&15,note:L.note&127}:null; }
/* Kit tracks play only once learned; an unlearned track stays silent. */
function chFor(ev){ if(ev.lane===1) return clamp(cfg.mono.ch|0,1,16)-1; var L=learned(ev.tr); return L?L.ch:0; }
function noteFor(ev){ if(ev.lane===1) return clamp(ev.v+12*(cfg.mono.oct|0),0,127); var L=learned(ev.tr); return L?L.note:null; }
function kitChannels(){ var c={}; KIT.forEach(function(t){ var L=learned(t); if(L) c[L.ch]=1; }); return Object.keys(c).map(Number); }
/* ---------- scheduler (look-ahead, timestamped sends) ---------- */
var LOOK=110, S={running:false,anchorTime:0,anchorTick:0,nextBarTick:0,bar:0,nextClock:0,queue:[],sounding:{},timer:null,log:{},mask:0}, flashes=[];
function msPerTick(){ return 60000/(app.bpm*TPB); }
function timeOf(t){ return S.anchorTime+(t-S.anchorTick)*msPerTick(); }
function tickAt(ms){ return S.anchorTick+(ms-S.anchorTime)/msPerTick(); }
function clockPorts(){ var p=[], a=outs[cfg.portDrums], b=outs[cfg.portMono]; if(a) p.push(a); if(b && b!==a) p.push(b); return p; }
function send(port,bytes,ts){ if(!port) return; try{ port.send(bytes,ts); }catch(e){} }
function start(){
if(S.running) return;
S.running=true; S.anchorTime=performance.now()+60; S.anchorTick=0; S.nextBarTick=0; S.bar=0; S.nextClock=0; S.queue=[]; S.sounding={}; S.log={}; S.mask=0;
if(cfg.clock) clockPorts().forEach(function(p){ send(p,[0xFA],S.anchorTime); });
S.timer=setInterval(pump,15); pump(); runBtn.setAttribute('aria-pressed','true');
}
function stop(){
if(!S.running) return;
clearInterval(S.timer); S.running=false;
Object.keys(outs).forEach(function(id){ try{ outs[id].clear(); }catch(e){} });
Object.keys(S.sounding).forEach(function(k){ var s=S.sounding[k]; send(s.port,[0x80|s.ch,s.note,0]); });
var pd=portFor(0), pm=portFor(1);
if(pd) kitChannels().forEach(function(c){ send(pd,[0xB0|c,120,0]); }); // samplers that ignore All Notes Off still obey All Sound Off (120)
if(pm){ var mc=clamp(cfg.mono.ch|0,1,16)-1; send(pm,[0xB0|mc,120,0]); send(pm,[0xB0|mc,123,0]); }
if(cfg.clock) clockPorts().forEach(function(p){ send(p,[0xFC]); });
S.queue=[]; S.sounding={};
for(var t=0;t<NT;t++) if(pending[t]) applyOn(t,pending[t].v);
runBtn.setAttribute('aria-pressed','false'); dirty=true;
}
function pump(){
var now=performance.now(), horizon=now+LOOK, i;
applyPending(tickAt(now));
while(timeOf(S.nextBarTick)<horizon){
var p=params(app.fill), r=E.renderBar(app.state,p,S.bar);
S.log[S.bar]={start:S.nextBarTick,r:r,mask:S.mask}; delete S.log[S.bar-12];
for(i=0;i<r.events.length;i++){ var ev=r.events[i], at=S.nextBarTick+ev.t; S.queue.push({tick:at,on:1,ev:ev,mask:S.mask}); S.queue.push({tick:at+ev.len,on:0,ev:ev}); }
S.queue.sort(function(a,b){ return a.tick-b.tick || a.on-b.on; });
S.nextBarTick+=r.barTicks; S.bar++;
}
if(cfg.clock){ var cp=clockPorts(); while(timeOf(S.nextClock)<horizon){ var ct=timeOf(S.nextClock); cp.forEach(function(p){ send(p,[0xF8],ct); }); S.nextClock+=E.CLOCK_TICKS; } }
else S.nextClock=Math.ceil(tickAt(horizon)/E.CLOCK_TICKS)*E.CLOCK_TICKS;
while(S.queue.length && timeOf(S.queue[0].tick)<horizon){
var q=S.queue.shift(), e=q.ev, ts=Math.max(now,timeOf(q.tick));
applyPending(q.tick);
var port=portFor(e.lane), ch=chFor(e), note=noteFor(e), key=(port?port.id:'-')+':'+ch+':'+note;
if(q.on){ if(!sounds(e.tr,q.mask) || note===null) continue; send(port,[0x90|ch,note,e.vel],ts); S.sounding[key]={port:port,ch:ch,note:note,tr:e.tr,until:q.tick+e.len}; flashes.push({time:ts,tr:e.tr}); }
else { var s=S.sounding[key]; if(s){ send(port,[0x80|ch,note,0],ts); if(s.until<=q.tick) delete S.sounding[key]; } }
}
}
function setBpm(b){ b=clamp(b|0,60,240); if(b===app.bpm) return; if(S.running){ var now=performance.now(), tk=tickAt(now); S.anchorTime=now; S.anchorTick=tk; } app.bpm=b; save(); }
/* ---------- tracking by hand: every track is simply on or off ---------- */
var pending=[]; for(var i1=0;i1<NT;i1++) pending.push(null);
function releaseTrack(tr){ Object.keys(S.sounding).forEach(function(k){ var s=S.sounding[k]; if(s.tr===tr){ send(s.port,[0x80|s.ch,s.note,0]); delete S.sounding[k]; } }); }
function curMask(){ return 0; }
function applyOn(tr,v){ app.on[tr]=v; pending[tr]=null; store('on',app.on); if(!sounds(tr,0)) releaseTrack(tr); }
function applyPending(tick){ for(var t=0;t<NT;t++) if(pending[t] && pending[t].at<=tick) applyOn(t,pending[t].v); }
function targetOn(tr){ return pending[tr]?pending[tr].v:app.on[tr]; }
function setOn(tr,v){
if(S.running && document.getElementById('muteQ').checked){
var now=tickAt(performance.now()), at=S.nextBarTick;
for(var b in S.log){ var L=S.log[b]; if(L.start>now && L.start<at) at=L.start; }
pending[tr] = (v===app.on[tr]) ? null : {v:v,at:at};
} else applyOn(tr,v);
}
function setInv(tr,v){ app.inv[tr]=v; if(!sounds(tr,0)) releaseTrack(tr); }
/* ---------- actions ---------- */
function newSeed(){ setState({seed:rnd32(),salts:[0,0]}); }
function reroll(l){ var s={seed:app.state.seed,salts:app.state.salts.slice()}; s.salts[l]=(s.salts[l]+1)&0xFFFF; setState(s); msg(l?'NEW KEYS':'NEW KIT',700); }
function encTurn(d){
if(!app.editing){ app.page=(app.page+d+PAGES.length)%PAGES.length; app.learn=0; app.learnTr=-1; return; }
var pg=PAGES[app.page];
if(pg==='SEED') app.histSel=clamp(app.histSel+d,0,app.history.length);
else if(pg==='GEN'){ app.gen=(app.gen+d+5)%5; save(); dirty=true; }
else if(pg==='KEYS'){ app.keys=mod(app.keys+d,E.KEYS_NAMES.length); save(); dirty=true; }
else if(pg==='ROOT'){ app.root=mod(app.root+1+d,13)-1; save(); dirty=true; } // AUTO, C .. B
else if(pg==='SCALE'){ var ns=E.SCALE_NAMES.length+1; app.scale=mod(app.scale+1+d,ns)-1; save(); dirty=true; } // AUTO, MAJOR .. LOCRIAN
else if(pg==='BPM') setBpm(app.bpm+d);
else if(pg==='SLOT') app.slotSel=(app.slotSel+d+8)%8;
else if(pg==='ROLL') app.rollSel=(app.rollSel+d+2)%2;
}
function encPush(hold){
var pg=PAGES[app.page];
if(pg==='LEARN'){
if(app.learn===0){ app.learn=1; app.editing=true; } // now press a kit track button
else { app.learn=0; app.learnTr=-1; app.editing=false; msg('CANCELLED',700); }
return;
}
if(!app.editing){ app.editing=true; return; }
if(pg==='SEED' && app.histSel>0) setState(app.history[app.histSel-1]);
if(pg==='SLOT'){
if(hold){ app.slots[app.slotSel]={seed:app.state.seed,salts:app.state.salts.slice(),gen:app.gen,keys:app.keys}; store('slots',app.slots); msg('SAVED S'+(app.slotSel+1)); return; }
var sl=app.slots[app.slotSel]; if(sl){ app.gen=sl.gen|0; app.keys=(sl.keys|0)%E.KEYS_NAMES.length; save(); setState(sl); }
}
if(pg==='ROLL') reroll(app.rollSel); // then back to page select
app.editing=false;
}
/* ---------- device controls ---------- */
var runBtn=(function(){ var b=document.createElement('button'); b.className='toggle'; b.id='run'; b.type='button'; b.setAttribute('aria-pressed','false'); b.setAttribute('aria-label','Run / stop');
b.innerHTML='<span class="label">Run</span><span class="tg"></span><span class="label">Stop</span>'; return b; })(), big=document.getElementById('big');
runBtn.addEventListener('click',function(){ S.running?stop():start(); });
var seedHeld=false, comboUsed=false;
big.addEventListener('pointerdown',function(e){ if(big.setPointerCapture) big.setPointerCapture(e.pointerId); seedHeld=true; comboUsed=false; big.classList.add('down'); });
big.addEventListener('pointerup',function(){ big.classList.remove('down'); if(seedHeld && !comboUsed) newSeed(); seedHeld=false; });
big.addEventListener('pointercancel',function(){ big.classList.remove('down'); seedHeld=false; });
big.addEventListener('keydown',function(e){ if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); newSeed(); } });
/* Track buttons. Tap: on <-> off. Hold past 250 ms: flip while held.
In LEARN a kit button selects the track to learn instead. */
/* Panel grid, same order on the screen map: row 1 KICK KICK2 SNARE [RUN toggle], row 2 HAT HAT16 PERC GHOST, row 3 BASS MEL A MEL B PAD.
-1 = the RUN toggle. In the wiring matrix that slot is empty; GHOST is wired at row 2 / column 4. */
var PANEL=[0,1,2,-1, 4,5,6,3, 7,8,9,10];
var trk=document.getElementById('trk'), tleds=[], tbtns=[], held={};
function addCell(name,onDown,onUp){
var c=document.createElement('div'); c.className='tcell';
var led=document.createElement('span'); led.className='tled';
var b=document.createElement('button'); b.className='tbtn'; b.type='button'; b.setAttribute('aria-label',name);
var n=document.createElement('span'); n.className='tname'; n.textContent=name;
c.appendChild(led); c.appendChild(b); c.appendChild(n); trk.appendChild(c);
b.addEventListener('pointerdown',function(e){ if(b.setPointerCapture) b.setPointerCapture(e.pointerId); b.classList.add('down'); onDown(e); });
b.addEventListener('pointerup',function(){ b.classList.remove('down'); onUp(); });
b.addEventListener('pointercancel',function(){ b.classList.remove('down'); onUp(); });
b.addEventListener('keydown',function(e){ if((e.key==='Enter'||e.key===' ') && !e.repeat){ e.preventDefault(); e.stopPropagation(); b.classList.add('down'); onDown(e); } });
b.addEventListener('keyup',function(e){ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); b.classList.remove('down'); onUp(); } });
return {led:led,btn:b};
}
function trackDown(t,e){
var pg=PAGES[app.page];
if(app.learn===1 || app.learn===2){ if(E.TRACKS[t].lane!==0){ msg('KIT ONLY',700); return; } app.learnTr=KIT.indexOf(t); app.learn=2; return; }
held[t]={t0:performance.now(),timer:setTimeout(function(){ if(held[t]){ held[t].long=true; setInv(t,true); } },HOLD_MS)};
}
function trackUp(t){
var h=held[t]; if(!h) return; clearTimeout(h.timer); delete held[t];
if(h.long) setInv(t,false);
else setOn(t,!targetOn(t));
}
PANEL.forEach(function(t){
if(t<0){ var rc=document.createElement('div'); rc.className='tcell runcell'; rc.appendChild(runBtn); trk.appendChild(rc); return; } // the RUN toggle sits in the grid, outside the matrix
var c=addCell(E.TRACKS[t].name,function(e){ trackDown(t,e); },function(){ trackUp(t); });
tleds[t]=c.led; tbtns[t]=c.btn;
});
function knob(el,valEl,get,set){
function draw(){ var v=get(); el.style.setProperty('--rot',(-135+270*v/127)+'deg'); el.setAttribute('aria-valuenow',v); valEl.textContent=v; }
var y0=0,v0=0;
el.addEventListener('pointerdown',function(e){ el.setPointerCapture(e.pointerId); y0=e.clientY; v0=get(); });
el.addEventListener('pointermove',function(e){ if(!el.hasPointerCapture(e.pointerId)) return; set(clamp(Math.round(v0+(y0-e.clientY)/1.5),0,127)); draw(); });
el.addEventListener('wheel',function(e){ e.preventDefault(); set(clamp(get()+(e.deltaY<0?2:-2),0,127)); draw(); },{passive:false});
el.addEventListener('keydown',function(e){ var d=(e.key==='ArrowUp'||e.key==='ArrowRight')?1:(e.key==='ArrowDown'||e.key==='ArrowLeft')?-1:0; if(d){ e.preventDefault(); e.stopPropagation(); set(clamp(get()+d*(e.shiftKey?8:2),0,127)); draw(); } });
draw();
}
knob(document.getElementById('kEnergy'),document.getElementById('vEnergy'),function(){return app.energy;},function(v){ app.energy=v; save(); dirty=true; });
knob(document.getElementById('kDrift'),document.getElementById('vDrift'),function(){return app.drift;},function(v){ app.drift=v; save(); dirty=true; });
var enc=document.getElementById('enc'), encDot=enc.querySelector('i'), encRot=0;
function encStep(d){ encRot+=d*18; encDot.style.setProperty('--rot',encRot+'deg'); encTurn(d); }
(function(){
var x0=0, acc=0, moved=false, t0=0;
enc.addEventListener('pointerdown',function(e){ enc.setPointerCapture(e.pointerId); x0=e.clientX; acc=0; moved=false; t0=performance.now(); });
enc.addEventListener('pointermove',function(e){ if(!enc.hasPointerCapture(e.pointerId)) return; acc+=e.clientX-x0; x0=e.clientX; while(Math.abs(acc)>=14){ moved=true; var d=acc>0?1:-1; acc-=d*14; encStep(d); } });
enc.addEventListener('pointerup',function(){ if(!moved) encPush(performance.now()-t0>600); });
enc.addEventListener('wheel',function(e){ e.preventDefault(); encStep(e.deltaY>0?1:-1); },{passive:false});
})();
var KEYMAP=['Digit1','Digit2','Digit3','Digit4','Digit5','Digit6','Digit7','Digit8','Digit9','Digit0','Minus'];
document.addEventListener('keydown',function(e){
var tg=e.target.tagName; if(tg==='INPUT'||tg==='SELECT'||tg==='TEXTAREA'||tg==='BUTTON' && (e.key==='Enter'||e.key===' ')) return;
if(e.repeat && e.key!=='ArrowLeft' && e.key!=='ArrowRight') return;
var t=KEYMAP.indexOf(e.code);
if(t>=0){ tbtns[t].classList.add('down'); trackDown(t,e); }
else if(e.key===' '){ e.preventDefault(); big.classList.add('down'); newSeed(); setTimeout(function(){ big.classList.remove('down'); },90); }
else if(e.key==='ArrowLeft'){ e.preventDefault(); encStep(-1); }
else if(e.key==='ArrowRight'){ e.preventDefault(); encStep(1); }
else if(e.key==='Enter'){ e.preventDefault(); encPush(e.shiftKey); }
else if(e.key==='r'||e.key==='R'){ S.running?stop():start(); }
});
document.addEventListener('keyup',function(e){
var t=KEYMAP.indexOf(e.code);
if(t>=0){ tbtns[t].classList.remove('down'); trackUp(t); }
});
/* ---------- bench settings ---------- */
function bind(id,get,set){ var el=document.getElementById(id); el.value=get(); el.addEventListener('change',function(){ set(el.value); saveCfg(); dirty=true; }); return el; }
bind('monoCh',function(){return cfg.mono.ch;},function(v){cfg.mono.ch=clamp(v|0,1,16);});
bind('monoOct',function(){return cfg.mono.oct;},function(v){cfg.mono.oct=clamp(v|0,-3,3);});
['portDrums','portMono'].forEach(function(id){ document.getElementById(id).addEventListener('change',function(e){ cfg[id]=e.target.value; saveCfg(); }); });
document.getElementById('portIn').addEventListener('change',function(e){ cfg.portIn=e.target.value; saveCfg(); hookInput(); });
function applyHw(){
trk.classList.toggle('noled',!cfg.hw.leds);
var D=DISPLAYS[cfg.hw.display]||DISPLAYS['5110'], w=document.getElementById('hwNote');
w.textContent = D.h===32 ? (cfg.hw.leds ? 'This screen only fits the menu (two big lines). Track status is on the LEDs.' : 'This screen only fits the menu. Without LEDs there is no way to see which tracks are on.')
: 'Menu on top, a 4 × 3 track map underneath laid out like the buttons, so the LEDs are optional.';
}
bind('hwDisplay',function(){ return cfg.hw.display; },function(v){ cfg.hw.display=DISPLAYS[v]?v:'5110'; applyHw(); });
(function(){ var el=document.getElementById('hwLeds'); el.checked=!!cfg.hw.leds; el.addEventListener('change',function(){ cfg.hw.leds=el.checked; saveCfg(); applyHw(); }); })();
var clk=document.getElementById('sendClock'); clk.checked=!!cfg.clock; clk.addEventListener('change',function(){ cfg.clock=clk.checked; saveCfg(); });
var padNotes=[];
(function(){ var box=document.getElementById('padFields');
KIT.forEach(function(t,k){
var f=document.createElement('div'); f.className='field';
var lab=document.createElement('span'); lab.className='lbl'; lab.textContent=E.TRACKS[t].name;
var nt=document.createElement('span'); nt.className='val';
f.appendChild(lab); f.appendChild(nt); box.appendChild(f); padNotes[k]=nt;
}); })();
function refreshPadFields(){
KIT.forEach(function(t,k){ var L=learned(t); padNotes[k].textContent = L ? 'ch '+(L.ch+1)+' · note '+L.note : 'not learned'; padNotes[k].classList.toggle('miss',!L); });
}
document.getElementById('clearLearn').addEventListener('click',function(){ cfg.kit.learn=[null,null,null,null,null,null,null]; saveCfg(); refreshPadFields(); });
refreshPadFields();
document.getElementById('vecGo').addEventListener('click',function(){
var n=clamp(document.getElementById('vecBars').value|0,1,64), rows=[], bars=[], p=params(false), mask=0;
for(var b=0;b<n;b++){
var r=E.renderBar(app.state,p,b);
var ev=r.events.map(function(e){ return [e.t,e.lane,e.v,e.vel,e.len,e.tr]; });
bars.push({bar:b,fill:r.fill,events:ev}); rows=rows.concat(ev);
}
document.getElementById('vecOut').value=JSON.stringify({seed:'0x'+hex(app.state.seed),salts:app.state.salts,params:{gen:app.gen,keys:app.keys,energy:app.energy,drift:app.drift},
checksum:'0x'+hex(E.checksum(rows)),format:'[tick,lane,voice,vel,len,track]; all tracks are listed, on/off is applied at output',bars:bars});
});
document.getElementById('vecCopy').addEventListener('click',function(){
var ta=document.getElementById('vecOut'), b=this;
function ok(){ b.textContent='Copied'; setTimeout(function(){ b.textContent='Copy'; },1200); }
try{ navigator.clipboard.writeText(ta.value).then(ok,function(){ ta.select(); }); }catch(e){ ta.select(); }
});
/* ---------- Screen: 1-bit framebuffer, 5x7 font, laid out for the real panel ----------
0.91" 128x32: two big lines (label, value). No room for track status: that needs LEDs.
128x64 and Nokia 84x48: small label + value on top, a track map below that mirrors the button rows. */
var DISPLAYS={
'5110':{w:84,h:48,wmm:33.1,hmm:18.9,lcd:true,name:'Nokia 3310/5110 84×48 PCD8544'},
'091':{w:128,h:32,wmm:22.38,hmm:5.58,name:'0.91" 128×32 SSD1306'},
'096':{w:128,h:64,wmm:21.74,hmm:10.86,name:'0.96" 128×64 SSD1306'},
'130':{w:128,h:64,wmm:29.42,hmm:14.70,name:'1.3" 128×64 SH1106'},
'242':{w:128,h:64,wmm:55.01,hmm:27.49,name:'2.42" 128×64 SSD1309'}
};
var FONT={' ':[0,0,0,0,0],'+':[8,8,62,8,8],'-':[8,8,8,8,8],'.':[0,96,96,0,0],'/':[32,16,8,4,2],':':[0,54,54,0,0],'#':[20,127,20,127,20],'*':[20,8,62,8,20],'>':[0,65,34,20,8],'?':[2,1,81,9,6],'!':[0,0,95,0,0],
'0':[62,81,73,69,62],'1':[0,66,127,64,0],'2':[66,97,81,73,70],'3':[33,65,69,75,49],'4':[24,20,18,127,16],'5':[39,69,69,69,57],'6':[60,74,73,73,48],'7':[1,113,9,5,3],'8':[54,73,73,73,54],'9':[6,73,73,41,30],
'A':[126,17,17,17,126],'B':[127,73,73,73,54],'C':[62,65,65,65,34],'D':[127,65,65,34,28],'E':[127,73,73,73,65],'F':[127,9,9,9,1],'G':[62,65,73,73,122],'H':[127,8,8,8,127],'I':[0,65,127,65,0],'J':[32,64,65,63,1],
'K':[127,8,20,34,65],'L':[127,64,64,64,64],'M':[127,2,12,2,127],'N':[127,4,8,16,127],'O':[62,65,65,65,62],'P':[127,9,9,9,6],'Q':[62,65,81,33,94],'R':[127,9,25,41,70],'S':[70,73,73,73,49],'T':[1,1,127,1,1],
'U':[63,64,64,64,63],'V':[31,32,64,32,31],'W':[63,64,56,64,63],'X':[99,20,8,20,99],'Y':[7,8,112,8,7],'Z':[97,81,73,69,67]};
var oled=document.getElementById('oled'), octx=oled.getContext('2d'), zoom=document.getElementById('oledZoom'), zctx=zoom.getContext('2d');
var FB_W=128, fb=new Uint8Array(128*64), fbH=32;
function fitText(s,x,y,maxW,v){ if(textW(s,2)<=maxW) text(s,x,y,2,v); else text(s,x,y+4,1,v); }
function px(x,y,v){ if(x>=0&&x<FB_W&&y>=0&&y<fbH) fb[y*FB_W+x]=v; }
function fillR(x,y,w,h,v){ for(var j=y;j<y+h;j++) for(var i=x;i<x+w;i++) px(i,j,v); }
function boxR(x,y,w,h){ fillR(x,y,w,1,1); fillR(x,y+h-1,w,1,1); fillR(x,y,1,h,1); fillR(x+w-1,y,1,h,1); }
function text(s,x,y,sc,v){
s=String(s).toUpperCase();
for(var c=0;c<s.length;c++){ var g=FONT[s[c]]||FONT['?'];
for(var col=0;col<5;col++) for(var row=0;row<7;row++) if((g[col]>>row)&1) fillR(x+col*sc,y+row*sc,sc,sc,v);
x+=(sc===1?6:12); }
}
function textW(s,sc){ return String(s).length*(sc===1?6:12)-(sc===1?1:2); }
function screenText(){
var pg=PAGES[app.page], label='', value='';
if(pg==='SEED'){ var sh=app.histSel>0?app.history[app.histSel-1]:app.state; label='SEED'+(app.histSel>0?' -'+app.histSel:'')+((sh.salts[0]||sh.salts[1])?' *':''); value=hex(sh.seed); }
else if(pg==='GEN'){ label='KIT GEN'; value=E.GEN_NAMES[app.gen]; }
else if(pg==='KEYS'){ label='KEYS GEN'; value=E.KEYS_NAMES[app.keys]; }
else if(pg==='ROOT' || pg==='SCALE'){ // on AUTO, show the seed's own pick
var sc=pg==='SCALE', x=sc?app.scale:app.root, gk=E.genome(app.state.seed,app.state.salts);
label=(sc?'SCALE':'ROOT')+(x<0?' AUTO':''); if(x<0) x=sc?gk.scale:gk.root; value=sc?E.SCALE_NAMES[x]:E.NOTE_NAMES[x]; }
else if(pg==='BPM'){ label='TEMPO'; value=app.bpm+' BPM'; }
else if(pg==='SLOT'){ label='SLOT '+(app.slotSel+1); value=app.slots[app.slotSel]?hex(app.slots[app.slotSel].seed):'EMPTY'; }
else if(pg==='ROLL'){ label='RE-ROLL'; value=app.rollSel?'KEYS':'KIT'; }
else if(pg==='LEARN'){ label= app.learn===2 ? 'LEARN '+E.TRACKS[KIT[app.learnTr]].name.replace(' ','') : 'LEARN';
value= app.learn===0 ? 'PUSH' : app.learn===1 ? 'PICK TRACK' : (inPort?'HIT PAD':'NO INPUT'); }
if(performance.now()<flashMsgUntil) value=flashMsg;
return {label:label.slice(0,10),value:String(value).slice(0,10)};
}
function drawCell(x,y,w,h,state){ // state: 0 off, 1 on, 2 on+hit, 3 selectable, 4 selected
if(state===1||state===2||state===4) fillR(x,y,w,h,1); else boxR(x,y,w,h);
if(state===2) fillR(x+3,y+3,w-6,h-6,0);
if(state===3) fillR(x+(w>>1)-1,y+(h>>1)-1,2,2,1);
}
function cellState(t,now,mask,blink){
if(app.learn>0) return E.TRACKS[t].lane!==0 ? 0 : (app.learn===2 && KIT[app.learnTr]===t) ? (blink?4:3) : 3;
if(E.TRACKS[t].lane===0 && !cfg.kit.learn[t]) return 3; // a dot: this kit track still needs learning
var on=sounds(t,mask); if(pending[t] && blink) on=!on;
return on ? (now<hitUntil[t]?2:1) : 0;
}
var lastDisp='';
function drawOled(curBar,mask){
var D=DISPLAYS[cfg.hw.display]||DISPLAYS['5110'], now=performance.now(), blink=Math.floor(now/250)%2===0, s=screenText(), i, t;
if(lastDisp!==cfg.hw.display){ lastDisp=cfg.hw.display; FB_W=D.w; fbH=D.h; oled.width=D.w; oled.height=D.h; zoom.width=D.w; zoom.height=D.h;
oled.style.width=D.wmm+'mm'; oled.style.height=D.hmm+'mm'; zoom.style.aspectRatio=D.w+' / '+D.h;
oled.parentNode.classList.toggle('lcd',!!D.lcd); zoom.classList.toggle('lcd',!!D.lcd); }
fb.fill(0);
function drawMap(x0,y0,cw,ch,gx,gy){ // 4 x 3, same as the buttons and the wiring matrix
for(var k=0;k<12;k++){ var t=PANEL[k], x=x0+(k%4)*(cw+gx), y=y0+((k/4)|0)*(ch+gy);
drawCell(x,y,cw,ch, t<0 ? (S.running?1:0) : cellState(t,now,mask,blink)); } }
if(D.lcd){
// Nokia 84x48: 14 chars per line at 1x. Label + bar dots, value (2x when it fits), then the 7+4 track map with 10 px cells.
text(s.label,0,0,1,1);
if(S.running && curBar>=0) for(i=0;i<4;i++){ var nx=64+i*5; if(i===curBar%4) fillR(nx,1,4,5,1); else boxR(nx,1,4,5); }
if(app.editing){ fillR(0,8,84,10,1); text(s.value,1,9,1,0); } else text(s.value,1,9,1,1);
drawMap(1,20,19,8,2,2);
} else if(D.h===32){
text(s.label,2,0,2,1);
if(app.editing){ fillR(0,16,128,16,1); text(s.value,2,17,2,0); } else text(s.value,2,17,2,1);
} else {
text(s.label,1,0,1,1);
if(S.running && curBar>=0) for(i=0;i<4;i++){ var bx=104+i*6; if(i===curBar%4) fillR(bx,1,5,5,1); else boxR(bx,1,5,5); }
if(app.editing){ fillR(0,9,128,18,1); text(s.value,2,11,2,0); } else text(s.value,2,11,2,1);
drawMap(1,29,30,10,2,2);
}
var img=octx.createImageData(FB_W,fbH), d=img.data, ON=D.lcd?[28,40,38]:[214,236,255], OFF=D.lcd?[150,176,160]:[3,6,10];
for(i=0;i<FB_W*fbH;i++){ var c=fb[i]?ON:OFF; d[i*4]=c[0]; d[i*4+1]=c[1]; d[i*4+2]=c[2]; d[i*4+3]=255; }
octx.putImageData(img,0,0); zctx.putImageData(img,0,0);
}
/* ---------- LED strip ---------- */
var hitUntil=[]; for(var i2=0;i2<NT;i2++) hitUntil.push(0);
function rgba(c,a){ return 'color-mix(in srgb, '+c+' '+Math.round(a*100)+'%, transparent)'; }
function drawLeds(now,mask,T){
var blink=Math.floor(now/250)%2===0, slow=Math.floor(now/450)%2===0;
for(var t=0;t<NT;t++){
var el=tleds[t], on=sounds(t,mask), lane=E.TRACKS[t].lane?T.mono:T.kit, col, glow=false;
if(app.learn>0){
if(E.TRACKS[t].lane!==0) col='var(--bezel)';
else if(app.learn===2 && KIT[app.learnTr]===t){ col=blink?'#FFB547':'var(--bezel)'; glow=blink; }
else { col=slow?'#FFB547':'var(--bezel)'; }
} else if(E.TRACKS[t].lane===0 && !cfg.kit.learn[t]){
col=rgba(lane,0.15);
} else {
if(on){ col=now<hitUntil[t]?lane:rgba(lane,0.7); glow=now<hitUntil[t]; }
else col='var(--bezel)';
if(pending[t] && !blink) col='var(--bezel)';
}
el.style.background=col; el.style.boxShadow=glow?'0 0 10px '+col:'inset 0 1px 1px rgba(0,0,0,.4)';
}
}
/* ---------- phrase view: one row per track ---------- */
var grid=document.getElementById('grid'), gctx=grid.getContext('2d'), dirty=true, view=null, viewKey='';
function tokens(){ var cs=getComputedStyle(document.documentElement); function v(n){ return cs.getPropertyValue(n).trim(); }
return {ink:v('--ink'),muted:v('--muted'),line:v('--line'),grid:v('--grid'),kit:v('--l-kit'),mono:v('--l-mono'),accent:v('--accent'),font:v('--f-mono')}; }
function phraseBars(phrase){ var out=[]; for(var i=0;i<4;i++){ var b=phrase*4+i; out.push(S.running && S.log[b] ? S.log[b].r : E.renderBar(app.state,params(false),b)); } return out; }
function drawGrid(curTick,mask,T){
var dpr=window.devicePixelRatio||1, W=grid.clientWidth, H=grid.clientHeight;
if(grid.width!==Math.round(W*dpr)||grid.height!==Math.round(H*dpr)){ grid.width=Math.round(W*dpr); grid.height=Math.round(H*dpr); }
gctx.setTransform(dpr,0,0,dpr,0,0); gctx.clearRect(0,0,W,H);
var bars=view.bars, total=0; bars.forEach(function(b){ total+=b.barTicks; });
var X0=58, GW=W-X0-4, rows=[], y=4, i;
for(i=0;i<NT;i++) rows.push({h:(i>=7?1.4:1)});
var unit=(H-24)/rows.reduce(function(a,r){ return a+r.h; },0);
rows.forEach(function(r){ r.y=y; r.hh=r.h*unit; y+=r.hh; });
var gb=y;
gctx.font='500 10px '+T.font; gctx.textBaseline='middle';
for(i=0;i<NT;i++){ var r=rows[i], on=sounds(i,mask);
gctx.fillStyle=on?T.ink:T.line; gctx.fillText(E.TRACKS[i].name,2,r.y+r.hh/2);
gctx.strokeStyle=(i===6)?T.line:T.grid; gctx.lineWidth=1; gctx.beginPath(); gctx.moveTo(X0,Math.round(r.y+r.hh)+.5); gctx.lineTo(X0+GW,Math.round(r.y+r.hh)+.5); gctx.stroke(); }
function X(t){ return X0+t/total*GW; }
var acc=0;
bars.forEach(function(b){
for(var t=0;t<b.barTicks;t+=E.TPS){ gctx.strokeStyle=(t===0)?T.muted:(t%TPB===0)?T.line:T.grid; gctx.lineWidth=(t===0)?1.5:1;
var xx=Math.round(X(acc+t))+.5; gctx.beginPath(); gctx.moveTo(xx,4); gctx.lineTo(xx,gb); gctx.stroke(); }
gctx.fillStyle=T.muted; gctx.textBaseline='top'; gctx.fillText(String.fromCharCode(65+b.sec)+(b.fill?' fill':''),X(acc)+4,gb+6); gctx.textBaseline='middle';
acc+=b.barTicks;
});
var rng={}; bars.forEach(function(b){ b.events.forEach(function(e){ if(e.lane===1){ var q=rng[e.tr]||(rng[e.tr]={lo:127,hi:0}); q.lo=Math.min(q.lo,e.v); q.hi=Math.max(q.hi,e.v); } }); });
acc=0;
bars.forEach(function(b){
b.events.forEach(function(e){
var r=rows[e.tr], x=X(acc+e.t), yy, hh, w, on=sounds(e.tr,mask);
if(e.lane===0){ hh=r.hh-6; yy=r.y+3; w=3; }
else { var q=rng[e.tr], span=Math.max(7,q.hi-q.lo); hh=3; yy=r.y+r.hh-5-((e.v-q.lo)/span)*(r.hh-8); w=Math.max(3,Math.min((e.len/total)*GW,X0+GW-x)); }
gctx.globalAlpha=on?0.3+0.7*e.vel/127:0.12; gctx.fillStyle=e.lane?T.mono:T.kit; gctx.fillRect(x,yy,w,hh);
});
acc+=b.barTicks;
});
gctx.globalAlpha=1;
if(curTick>=0){ var px=Math.round(X(curTick))+.5; gctx.strokeStyle=T.accent; gctx.lineWidth=2; gctx.beginPath(); gctx.moveTo(px,2); gctx.lineTo(px,gb); gctx.stroke(); }
}
var factsEl=document.getElementById('facts'), posEl=document.getElementById('pos');
function frame(){
var now=performance.now(), curBar=-1, curTick=-1, phrase=0, mask, T=tokens();
if(S.running){
var tk=tickAt(now);
for(var b in S.log){ var L=S.log[b]; if(tk>=L.start && tk<L.start+L.r.barTicks){ curBar=b|0; phrase=L.r.phrase; break; } }
if(curBar>=0){ var first=S.log[phrase*4]; if(first) curTick=tk-first.start; }
}
mask=curMask();
var key=phrase+'|'+app.state.seed+'|'+app.state.salts.join('.')+'|'+app.gen+'|'+app.keys+'|'+app.scale+'|'+app.root+'|'+S.running;
if(dirty || key!==viewKey || (S.running && view && view.bars.some(function(b,i){ return S.log[phrase*4+i] && S.log[phrase*4+i].r!==b; }))){
view={bars:phraseBars(phrase)}; viewKey=key; dirty=false;
var g=E.applyKey(E.genome(app.state.seed,app.state.salts),params(false)), ds=E.describe(app.state,params(false));
factsEl.innerHTML='<span>Seed <b>'+hex(app.state.seed)+'</b></span><span>Kit <b>'+E.GEN_NAMES[app.gen].toLowerCase()+'</b></span><span>Keys <b>'+E.KEYS_NAMES[app.keys].toLowerCase()+'</b></span><span>Bar <b>'+(app.gen===4?16:g.steps)+' steps</b></span><span>Key <b>'+E.NOTE_NAMES[g.root]+' '+E.SCALE_NAMES[g.scale].toLowerCase()+(app.scale<0||app.root<0?' (auto)':'')+'</b></span>'+(ds?'<span><b>'+ds+'</b></span>':'');
}
var on=0; for(var t=0;t<NT;t++) if(sounds(t,mask)) on++;
posEl.innerHTML=(S.running && curBar>=0 ? '<span>Phrase <b>'+(phrase+1)+'</b></span><span>Bar <b>'+(curBar%4+1)+'/4</b></span>' : '<span>Stopped</span>')+
'<span>Tracks on <b>'+on+'/'+NT+'</b></span><span><b>'+app.bpm+'</b> BPM</span>';
while(flashes.length && flashes[0].time<=now){ hitUntil[flashes[0].tr]=now+80; flashes.shift(); }
drawLeds(now,mask,T); drawGrid(curTick,mask,T); drawOled(curBar,mask);
requestAnimationFrame(frame);
}
applyHw();
requestAnimationFrame(frame);
window.addEventListener('beforeunload',function(){ stop(); });
})();