Seedbox v0.1 + firmware, generic kit/synth, ROOT/SCALE pages
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
First commit of the repo. Contents beyond the v0.1 simulator and layout: - Full Pico 2 W firmware (firmware/app, drivers, engine port, host test). - BANK page and 404 mode A/B removed; learned pads play exactly what was learned. - 404/MiniFreak renamed to KIT/SYNTH throughout (jacks, code, sim, docs, layout labels; wiring rebuilt). - ROLL returns to page select after a re-roll; a freshly learned track turns on. - LCD contrast 66. - ROOT and SCALE pages: 21 scales, AUTO = the seed picks; global and sticky. Intended music change: the scale table grew from 8 to 21 and AUTO picks from all of them, so every seed's scale changed. Vectors regenerated with npm run vectors, plus new key-override cases. Flash record is now version 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
484
sim/app.js
Normal file
484
sim/app.js
Normal file
@@ -0,0 +1,484 @@
|
||||
/* ================= 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), 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.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','ROOT','SCALE','BPM','SLOT','ROLL','LEARN'];
|
||||
function params(fillNow){ return {gen:app.gen,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('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 LINE':'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==='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}; store('slots',app.slots); msg('SAVED S'+(app.slotSel+1)); return; }
|
||||
var sl=app.slots[app.slotSel]; if(sl){ app.gen=sl.gen|0; 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,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='GENERATOR'; value=E.GEN_NAMES[app.gen]; }
|
||||
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);
|
||||
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?'LINE':'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+'|'+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),params(false)), ds=E.describe(app.state,params(false));
|
||||
factsEl.innerHTML='<span>Seed <b>'+hex(app.state.seed)+'</b></span><span>Generator <b>'+E.GEN_NAMES[app.gen].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(); });
|
||||
})();
|
||||
553
sim/engine.js
Normal file
553
sim/engine.js
Normal file
@@ -0,0 +1,553 @@
|
||||
/* ================= ENGINE (portable) =================
|
||||
No DOM, no Math.random, no floats in decisions. 840 ticks per quarter, 210 per 16th
|
||||
(840 divides by 2,3,4,5,7 for tuplets). MIDI clock every 35 ticks.
|
||||
|
||||
The seed builds 11 looped tracks; the player brings them in and out by hand.
|
||||
Energy = pattern density (hats only lightly). Drift = how often a phrase swaps in a variation of the detail.
|
||||
Musical layer: a 4-chord progression per phrase shared by bass, pad and melody; a melodic motif that is
|
||||
restated, sequenced and answered across the phrase; swing and metric velocity on every lane; B/C sections
|
||||
are transformations of A instead of unrelated patterns; a phrase arc (flat, build, or break in bar 3).
|
||||
Lanes: 0 = kit (v = voice 0 kick, 1 snare, 2 hat, 3 perc), 1 = synth (v = MIDI pitch). */
|
||||
var Engine = (function(){
|
||||
var TPB=840, TPS=210, CLOCK_TICKS=35, PHRASE_BARS=4;
|
||||
function mix(a,b){ var h=(a ^ Math.imul(b|0,0x9E3779B1))>>>0; h^=h>>>16; h=Math.imul(h,0x85EBCA6B)>>>0; h^=h>>>13; h=Math.imul(h,0xC2B2AE35)>>>0; h^=h>>>16; return (h>>>0)||0x6D2B79F5; }
|
||||
function Rng(s){ this.s=(s>>>0)||0x6D2B79F5; }
|
||||
Rng.prototype.next=function(){ var x=this.s; x=(x^(x<<13))>>>0; x=(x^(x>>>17))>>>0; x=(x^(x<<5))>>>0; this.s=x; return x; };
|
||||
Rng.prototype.below=function(n){ return n<=0?0:(((this.next()>>>16)*n)>>>16); };
|
||||
Rng.prototype.chance=function(p){ return (this.next()>>>16) < p; };
|
||||
Rng.prototype.pickW=function(w){ var t=0,i; for(i=0;i<w.length;i++) t+=w[i]; var r=this.below(t); for(i=0;i<w.length;i++){ if(r<w[i]) return i; r-=w[i]; } return w.length-1; };
|
||||
function clamp(x,a,b){ return x<a?a:x>b?b:x; }
|
||||
function P(x){ return clamp(x|0,0,127)*516; } // 0..127 -> 0..65532
|
||||
function fdiv(a,b){ return Math.floor(a/b); } // floor division (C port: handle negatives explicitly)
|
||||
function mod(a,b){ return ((a%b)+b)%b; }
|
||||
|
||||
var TRACKS=[
|
||||
{name:'KICK', lane:0,voice:0},
|
||||
{name:'KICK 2',lane:0,voice:0},
|
||||
{name:'SNARE', lane:0,voice:1},
|
||||
{name:'GHOST', lane:0,voice:1},
|
||||
{name:'HAT', lane:0,voice:2},
|
||||
{name:'HAT 16',lane:0,voice:2},
|
||||
{name:'PERC', lane:0,voice:3},
|
||||
{name:'BASS', lane:1,voice:0},
|
||||
{name:'MEL A', lane:1,voice:0},
|
||||
{name:'MEL B', lane:1,voice:0},
|
||||
{name:'PAD', lane:1,voice:0}
|
||||
];
|
||||
var NT=TRACKS.length;
|
||||
var T_KICK=0,T_KICK2=1,T_SNARE=2,T_GHOST=3,T_HAT=4,T_HAT16=5,T_PERC=6,T_BASS=7,T_MELA=8,T_MELB=9,T_PAD=10;
|
||||
|
||||
var STEPS=[16,12,14,20], STEPS_W=[12,3,2,2];
|
||||
var FORMS=[[0,0,0,1],[0,0,1,0],[0,1,0,1],[0,0,0,0],[0,1,0,2],[0,1,0,0]], FORMS_W=[5,5,3,2,3,2];
|
||||
var FORM_NAMES=['AAAB','AABA','ABAB','AAAA','ABAC','ABAA'];
|
||||
/* Menu order = index. 5 to 8 notes; chords stack every other scale note, so pentatonics give open chords,
|
||||
whole tone augmented, octatonic diminished. Names fit the 10-character screen line. */
|
||||
var SCALES=[[0,2,4,5,7,9,11],[0,2,3,5,7,8,10],[0,2,3,5,7,9,10],[0,2,4,5,7,9,10],[0,2,4,6,7,9,11],[0,1,3,5,7,8,10],[0,2,3,5,7,8,11],
|
||||
[0,3,5,7,10],[0,2,4,7,9],
|
||||
[0,1,4,5,7,8,10],[0,1,4,5,7,8,11],[0,2,3,6,7,8,11],[0,2,3,6,7,9,10],[0,2,4,6,7,9,10],
|
||||
[0,2,3,7,8],[0,1,5,7,10],[0,1,5,6,10],[0,2,4,6,9,10],[0,2,4,6,8,10],[0,1,3,4,6,7,9,10],[0,1,3,5,6,8,10]];
|
||||
var SCALE_NAMES=['MAJOR','MINOR','DORIAN','MIXOLYDIAN','LYDIAN','PHRYGIAN','HARM MINOR','MINOR PENT','MAJOR PENT',
|
||||
'PHRYG DOM','DBL HARM','HUNG MINOR','UKR DORIAN','LYD DOM','HIRAJOSHI','IN SEN','IWATO','PROMETHEUS','WHOLE TONE','OCTATONIC','LOCRIAN'];
|
||||
var NOTE_NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||
var GEN_NAMES=['EUCLID','SKELETON','SUBDIV','AUTOMATA','AMENIZER'];
|
||||
var HOCKETS=[0xAA,0x55,0xB4,0x6C,0x96,0xD2,0x5A,0xA5];
|
||||
/* chord roots as scale-degree indices, one per bar of the phrase */
|
||||
var PROGS=[[0,0,5,4],[0,5,3,4],[0,3,0,4],[0,6,5,4],[0,2,5,4],[0,0,3,3],[0,5,0,5],[0,3,5,6],[0,1,0,6],[0,4,5,3]];
|
||||
var SWINGS=[0,21,35,50,70], SWING_W=[4,3,3,2,1]; // delay of every second 16th, in ticks (70 = triplet feel)
|
||||
var ARC_NAMES=['flat','build','break'];
|
||||
var SPLITS=[1,2,3,4,5,7];
|
||||
var SKEL=[
|
||||
{k:[0,10],s:[4,12],g:[7,15]}, // two-step
|
||||
{k:[0,2,10,11],s:[4,12],g:[7,9,15]}, // amen-ish
|
||||
{k:[0,11],s:[8],g:[14,3]}, // half-time
|
||||
{k:[0,3,10],s:[4,12],g:[7]}, // think-ish
|
||||
{k:[0,6,11],s:[4,13],g:[9,15]}, // broken
|
||||
{k:[0,7,10],s:[4,12],g:[2,14]} // steppy
|
||||
];
|
||||
/* Rhythmic frames: where the main kick(s) and snare(s) live. Positions in 16ths of a 16-step bar, scaled to the seed's bar.
|
||||
One frame per seed, shared by every generator, so the generators vary the detail and the frame varies the feel. */
|
||||
var FRAMES=[
|
||||
{n:'backbeat', k:[0], s:[4,12]},
|
||||
{n:'half-time', k:[0], s:[8]},
|
||||
{n:'four-floor', k:[0,4,8,12], s:[4,12], layer:1},
|
||||
{n:'3-3-2', k:[0,3,8,11], s:[6,14]},
|
||||
{n:'broken', k:[0,10], s:[6,12]},
|
||||
{n:'displaced', k:[3,10], s:[8], noOne:1},
|
||||
{n:'over the bar',k:null, s:null, cyc:12},
|
||||
{n:'free snare', k:[0], s:null, free:1}
|
||||
], FRAME_W=[3,3,2,2,2,1,1,2];
|
||||
function frameSets(g,steps,pos){
|
||||
var F=FRAMES[g.frame], K={}, S={}, i;
|
||||
function sc(x){ return Math.floor(x*steps/16); }
|
||||
if(F.cyc){ for(i=0;i<steps;i++){ var x=pos*steps+i; if(x%F.cyc===0) K[i]=1; else if(x%F.cyc===(F.cyc>>1)) S[i]=1; } }
|
||||
else {
|
||||
for(i=0;i<F.k.length;i++) K[sc(F.k[i])]=1;
|
||||
if(F.free){ for(i=1;i<steps;i++) if(eu(i,g.freeK,steps,g.freeRot) && !K[i]) S[i]=1; }
|
||||
else for(i=0;i<F.s.length;i++) S[sc(F.s[i])]=1;
|
||||
}
|
||||
if(!F.layer) for(i in S) if(K[i]) delete K[i]; // kick and snare share a step only in four-floor
|
||||
return {K:K,S:S,noOne:!!F.noOne};
|
||||
}
|
||||
var RULES=[30,45,54,57,60,73,90,105,110,122,126,150,182,225];
|
||||
var POLY_N=[5,7,9,11,12,6];
|
||||
|
||||
function genome(seed){
|
||||
var r=new Rng(mix(seed,0x47454E)), fi, g={};
|
||||
g.steps=STEPS[r.pickW(STEPS_W)];
|
||||
fi=r.pickW(FORMS_W); g.form=FORMS[fi]; g.formName=FORM_NAMES[fi];
|
||||
g.scale=r.below(SCALES.length); g.root=r.below(12);
|
||||
g.dens=[40+r.below(60),30+r.below(70)];
|
||||
g.fillP=40+r.below(80); g.monoLen=r.below(3); g.monoRange=1+r.below(2);
|
||||
g.rot=r.pickW([4,2,2,1]); // phase offset (16ths) of the ambiguous tracks: melody, perc
|
||||
g.hocket=HOCKETS[r.below(HOCKETS.length)];
|
||||
g.bassDeg=[4,2,3][r.below(3)];
|
||||
// musical layer
|
||||
g.prog=PROGS[r.below(PROGS.length)];
|
||||
g.swing=SWINGS[r.pickW(SWING_W)];
|
||||
g.arc=r.pickW([3,2,2]);
|
||||
g.motifLen=3+r.below(3);
|
||||
g.accentCycle=[0,3,5,3,7][r.pickW([3,3,2,2,1])];
|
||||
g.layback=r.below(2);
|
||||
g.frame=r.pickW(FRAME_W); g.freeK=2+r.below(3); g.freeRot=r.below(16);
|
||||
return g;
|
||||
}
|
||||
function roleSeed(st,r){ return mix(mix(st.seed,0x524F4C45+r),st.salts[r]); }
|
||||
function groups(steps){ var g=[],s; for(s=0;s<steps;s+=4) g.push([s,Math.min(4,steps-s)]); return g; }
|
||||
function isBack(gi){ return (gi%2)===1; }
|
||||
function eu(i,k,n,rot){ var j=(i+rot)%n; return ((j*k)%n)<k; } // Bresenham Euclidean
|
||||
|
||||
function divide(r,start,len,depth,maxD,W,prob,out,v,velFn){
|
||||
if(depth<maxD){
|
||||
var w=W.slice(); if(depth>0) w[0]+=depth*3;
|
||||
var f=SPLITS[r.pickW(w)];
|
||||
if(f>1){ for(var i=0;i<f;i++){ var a=start+Math.floor(i*len/f), b=start+Math.floor((i+1)*len/f); divide(r,a,b-a,depth+1,maxD,W,prob,out,v,velFn); } return; }
|
||||
}
|
||||
if(r.chance(prob)) out.push({t:start,v:v,vel:velFn(r,depth)});
|
||||
}
|
||||
|
||||
/* ---------- kit generators. Hat accents (vel >= 85) become HAT, the rest HAT 16. ---------- */
|
||||
function euclidParams(coreSeed,steps,d,hd){
|
||||
var c=new Rng(coreSeed), kOpts=[3,5,4,2,7], k=kOpts[c.pickW([4,3,2,1,1+(d>>5)])];
|
||||
k=clamp(k,1,steps>>1);
|
||||
var rots=[], r0; for(r0=0;r0<steps;r0++) if(((r0*k)%steps)<k) rots.push(r0); // rotations that keep a kick on step 0
|
||||
var kick={k:k,n:steps,rot:rots[c.below(rots.length)]};
|
||||
var ghost={k:1+c.below(3),n:steps,rot:c.below(steps)};
|
||||
var hn=c.pickW([3,3,1,1,1])<2?steps:POLY_N[c.below(3)];
|
||||
var hat={k:clamp((hn*(32+(hd>>2)))>>7,1,hn),n:hn,rot:c.below(hn)};
|
||||
var pn=POLY_N[c.below(POLY_N.length)];
|
||||
var perc={k:clamp(1+((pn*d)>>9)+c.below(2),1,pn-1),n:pn,rot:c.below(pn)};
|
||||
return {kick:kick,ghost:ghost,hat:hat,perc:perc,accent:c.below(hat.n)};
|
||||
}
|
||||
function kitEuclid(coreSeed,varSeed,steps,d,hd,pos,fs){
|
||||
var E=euclidParams(coreSeed,steps,d,hd), vr=new Rng(varSeed), hr=new Rng(mix(varSeed,0x484154+pos));
|
||||
var ev=[], back=fs.S, i;
|
||||
for(i=0;i<steps;i++){
|
||||
var t=i*TPS, x=pos*steps+i;
|
||||
if(back[i]) ev.push({t:t,v:1,vel:112});
|
||||
else if(eu(i,E.ghost.k,E.ghost.n,E.ghost.rot) && vr.chance(P(d>>1))) ev.push({t:t,v:1,vel:38+vr.below(20)});
|
||||
if(fs.K[i]) ev.push({t:t,v:0,vel:i===0?120:112,a:1});
|
||||
else if(eu(i,E.kick.k,E.kick.n,E.kick.rot) && !back[i] && !(i===0 && fs.noOne)) ev.push({t:t,v:0,vel:104});
|
||||
if(eu(x,E.hat.k,E.hat.n,E.hat.rot)){
|
||||
var acc=eu(x,Math.max(1,E.hat.k>>1),E.hat.n,E.accent);
|
||||
ev.push({t:t,v:2,vel:acc?92:66});
|
||||
if(hr.chance(P(hd>>3))) ev.push({t:t+(TPS>>1),v:2,vel:44});
|
||||
}
|
||||
if(eu(x,E.perc.k,E.perc.n,E.perc.rot)) ev.push({t:t,v:3,vel:70+vr.below(40)});
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
function kitSubdiv(coreSeed,varSeed,steps,d,hd,pos,fs){
|
||||
var core=new Rng(coreSeed), vr=new Rng(varSeed), hr=new Rng(mix(varSeed,0x484154+pos));
|
||||
var ev=[], gs=groups(steps), snT=[], gi, j;
|
||||
var burst = core.chance(P(d>>1)) ? core.below(gs.length) : -1;
|
||||
for(j=0;j<steps;j++){ if(fs.S[j]){ ev.push({t:j*TPS,v:1,vel:110+core.below(14)}); snT.push(j*TPS); } if(fs.K[j]) ev.push({t:j*TPS,v:0,vel:j===0?118:112,a:1}); }
|
||||
for(gi=0;gi<gs.length;gi++){
|
||||
var s=gs[gi][0]*TPS, L=gs[gi][1]*TPS, tmp=[];
|
||||
divide(core,s,L,0,1,[6,5,1,0,0,0],P(10+(d>>2)),tmp,0,function(r){ return 96+r.below(16); });
|
||||
for(j=0;j<tmp.length;j++) if(!(tmp[j].t===0 && fs.noOne) && snT.indexOf(tmp[j].t)<0) ev.push(tmp[j]);
|
||||
divide(vr,s,L,0,1,[4,4,1,0,0,0],P(d>>3),ev,1,function(r){ return 34+r.below(22); });
|
||||
if(gi===burst) divide(hr,s,L,0,2,[1,3,4,2,3,2],P(90),ev,2,function(r){ return 50+r.below(30); });
|
||||
else divide(hr,s,L,0,1,[3,6,0,2,0,0],P(36+(hd>>2)),ev,2,function(r,dp){ return dp===0?90:58+r.below(20); });
|
||||
divide(vr,s,L,0,1,[6,2,3,0,1,1],P(d>>2),ev,3,function(r){ return 60+r.below(45); });
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
function kitSkeleton(coreSeed,varSeed,steps,d,hd,pos,fs,frame){
|
||||
var core=new Rng(coreSeed), vr=new Rng(varSeed), hr=new Rng(mix(varSeed,0x484154+pos));
|
||||
var ev=[], i, sk=SKEL[core.below(SKEL.length)];
|
||||
function sc(x){ return Math.floor(x*steps/16); }
|
||||
var sn=[];
|
||||
if(frame===0) for(i=0;i<sk.s.length;i++) sn.push(sc(sk.s[i])); // backbeat frame: the template's own snares
|
||||
else for(i in fs.S) sn.push(i|0); // any other frame decides the snares
|
||||
for(i in fs.K) ev.push({t:(i|0)*TPS,v:0,vel:116,a:1});
|
||||
for(i=0;i<sk.k.length;i++){ var s=sc(sk.k[i]); if(sk.k[i]===0) continue; if(core.chance(P(d>>2))) s=clamp(s+(core.below(2)?1:-1),1,steps-1); if(sn.indexOf(s)<0) ev.push({t:s*TPS,v:0,vel:104+core.below(12)}); }
|
||||
for(i=0;i<sn.length;i++) ev.push({t:sn[i]*TPS,v:1,vel:110+core.below(14)});
|
||||
for(i=0;i<sk.g.length;i++) if(vr.chance(P(d))) ev.push({t:sc(sk.g[i])*TPS,v:1,vel:36+vr.below(24)});
|
||||
if(vr.chance(P(d>>1))){ var xs=1+vr.below(steps-1); if(sn.indexOf(xs)<0) ev.push({t:xs*TPS,v:0,vel:90+vr.below(20)}); }
|
||||
var div=[2,2,1][core.below(3)], hp=P(36+(hd>>2));
|
||||
for(i=0;i<steps;i+=div){
|
||||
if(i%4===0) ev.push({t:i*TPS,v:2,vel:92});
|
||||
else if(hr.chance(hp)){ var vel=60+hr.below(22);
|
||||
if(hr.chance(P(hd>>3))){ var L=TPS*div; ev.push({t:i*TPS,v:2,vel:vel}); ev.push({t:i*TPS+(L>>1),v:2,vel:vel-8}); }
|
||||
else ev.push({t:i*TPS,v:2,vel:vel}); }
|
||||
}
|
||||
var ek=clamp(1+((d*steps)>>8)+core.below(3),1,steps-1), rot=core.below(steps);
|
||||
for(i=0;i<steps;i++) if(eu(i,ek,steps,rot)) ev.push({t:i*TPS,v:3,vel:64+vr.below(40)});
|
||||
return ev;
|
||||
}
|
||||
function caRow(rule,init,steps,gens){
|
||||
var row=init.slice(), g, i;
|
||||
for(g=0;g<gens;g++){ var nx=[]; for(i=0;i<steps;i++){ var l=row[(i+steps-1)%steps], c=row[i], r=row[(i+1)%steps]; nx.push((rule>>((l<<2)|(c<<1)|r))&1); } row=nx; }
|
||||
return row;
|
||||
}
|
||||
function randRow(r,steps,p){ var a=[]; for(var i=0;i<steps;i++) a.push(r.chance(p)?1:0); return a; }
|
||||
function kitCA(coreSeed,varSeed,steps,d,hd,pos,sec,fs){
|
||||
var core=new Rng(coreSeed), vr=new Rng(varSeed), thin=new Rng(mix(varSeed,0x5448+pos));
|
||||
var ev=[], gen=sec*5+pos, i, nR=RULES.length;
|
||||
var rk=RULES[core.below(nR)], rs=RULES[core.below(nR)], rh=RULES[vr.below(nR)], rp=RULES[vr.below(nR)];
|
||||
var K=caRow(rk,randRow(core,steps,P(40)),steps,gen), S=caRow(rs,randRow(core,steps,P(40)),steps,gen),
|
||||
H=caRow(rh,randRow(vr,steps,P(64)),steps,gen+pos), C=caRow(rp,randRow(vr,steps,P(40)),steps,gen);
|
||||
for(i=0;i<steps;i++){
|
||||
var t=i*TPS, back=!!fs.S[i];
|
||||
if(back) ev.push({t:t,v:1,vel:112});
|
||||
else if(S[i] && thin.chance(P(d>>1))) ev.push({t:t,v:1,vel:40+thin.below(20)});
|
||||
if(fs.K[i]) ev.push({t:t,v:0,vel:118,a:1});
|
||||
else if(K[i] && !back && !(i===0 && fs.noOne) && thin.chance(P(40+(d>>1)))) ev.push({t:t,v:0,vel:96+thin.below(20)});
|
||||
if(H[i] && thin.chance(P(56+(hd>>2)))){ var nb=H[(i+steps-1)%steps]+H[(i+1)%steps]; ev.push({t:t,v:2,vel:58+nb*16}); }
|
||||
if(C[i] && !C[(i+1)%steps] && thin.chance(P(d))) ev.push({t:t,v:3,vel:70+thin.below(40)});
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
/* AMENIZER: the four bars of the Amen break, velocities included, played on the kit tracks.
|
||||
[step, voice (0 kick, 1 snare, 2 ride/hat, 3 crash/perc), velocity, main kick flag]. Ride: on-beat 8ths go to HAT, off-beat to HAT 16.
|
||||
Snares under 90 land on GHOST, kicks without the flag on KICK 2. This is a by-ear transcription: adjust the table to taste. */
|
||||
/* [step, voice, velocity, main flag]. Main flag = KICK / SNARE / HAT track; the rest land on KICK 2 / GHOST / HAT 16.
|
||||
Velocities leave headroom: backbeats ~110, offbeat kicks in the 80s, ghosts in the 50s, ride 82 / 60. */
|
||||
var AMEN=(function(){
|
||||
function ride(b,crashAt){ for(var s=0;s<16;s+=2) if(s!==crashAt) b.push(s%4===0?[s,2,82,1]:[s,2,60,0]); return b; }
|
||||
var A=ride([[0,0,112,1],[2,0,84,0],[10,0,104,1],[11,0,80,0],[4,1,112,1],[7,1,52,0],[9,1,86,1],[12,1,110,1],[15,1,58,0]],-1);
|
||||
var C=ride([[0,0,112,1],[2,0,84,0],[10,0,104,1],[4,1,112,1],[7,1,52,0],[9,1,86,1],[14,1,110,1]],-1);
|
||||
var D=ride([[2,0,104,1],[3,0,84,0],[10,0,108,1],[1,1,48,0],[4,1,112,1],[7,1,54,0],[9,1,88,1],[14,1,112,1],[10,3,110,0]],10);
|
||||
return [A,A,C,D];
|
||||
})();
|
||||
/* A drummer, not a grid: every hit moves a little in level, a hit that follows the same drum closely comes in softer,
|
||||
and the ride hand has a slightly different weight every bar. */
|
||||
function humanizeAmen(ev,seed,bar){
|
||||
ev.sort(function(a,b){ return a.t-b.t || a.v-b.v; });
|
||||
var last=[-1e6,-1e6,-1e6,-1e6], lastV=[0,0,0,0], br=new Rng(mix(mix(seed,0x48554D),bar)), rideBias=br.below(11)-5;
|
||||
for(var i=0;i<ev.length;i++){
|
||||
var x=ev[i], base=x.vel, h=mix(mix(mix(seed,0x564C),bar),x.t*4+x.v), v=base+(h%13)-6;
|
||||
if(x.v===2) v+=rideBias;
|
||||
if(x.v<2 && x.t-last[x.v]<=2*TPS && lastV[x.v]*10>=base*9) v=fdiv(v*80,100); // a quick second stroke on a kick or snare that was just hit hard: softer
|
||||
last[x.v]=x.t; lastV[x.v]=base; x.vel=clamp(v,1,127);
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
/* Timing feel per drum, in ticks (210 per 16th): snares sit back, ghosts float, the ride pushes slightly. */
|
||||
function amenFeel(seed,bar,t,v,a){
|
||||
var h=mix(mix(mix(seed,0x46454C),bar),t*4+v);
|
||||
function j(n){ return (h%(2*n+1))-n; }
|
||||
if(v===0) return j(4);
|
||||
if(v===1) return a ? 10+j(5) : j(10);
|
||||
if(v===2) return -4+j(6);
|
||||
return j(4);
|
||||
}
|
||||
function amenSlice(bar,beat){ var b=AMEN[bar&3], out=[], i; for(i=0;i<b.length;i++) if(b[i][0]>=beat*4 && b[i][0]<beat*4+4) out.push(b[i]); return out; }
|
||||
/* Drift chops the break per beat, like a jungle edit: swap in another beat of the break, repeat the last beat,
|
||||
stutter a half beat, or ratchet the snare. At 0 the break plays straight. */
|
||||
function kitAmen(seed,bar,pos,dr,fill){
|
||||
var src=fill?3:pos, ev=[], prev=null, j, k;
|
||||
for(j=0;j<4;j++){
|
||||
var sl=amenSlice(src,j), r=new Rng(mix(mix(seed,0x414D454E),bar*4+j)), op=-1;
|
||||
// edits grow steadily with the knob; later beats in the bar get edited more, the first beat stays put until ~70%
|
||||
var pc=fdiv(dr*80,127), pb=fdiv(pc*[2,3,5,6][j],4), keep=(pos===0 && j===0) || (j===0 && dr<88);
|
||||
if(!keep && r.chance(P(pb))) op=r.pickW([4,2,2,2,dr>80?1:0]);
|
||||
if(op===0){ sl=amenSlice(r.below(4),j); } // same beat from another bar: the classic edit
|
||||
else if(op===4){ sl=amenSlice(r.below(4),r.below(4)); op=0; } // any beat of the break
|
||||
else if(op===1 && prev){ sl=prev; }
|
||||
var base=j*4*TPS;
|
||||
for(k=0;k<sl.length;k++){
|
||||
var s=sl[k], rel=s[0]%4, t=base+rel*TPS;
|
||||
if(op===2){ if(rel<2){ ev.push({t:t,v:s[1],vel:s[2],a:s[3]}); ev.push({t:t+2*TPS,v:s[1],vel:Math.max(1,s[2]-10),a:s[3]}); } continue; }
|
||||
ev.push({t:t,v:s[1],vel:s[2],a:s[3]});
|
||||
if(op===3 && s[1]===1){ ev.push({t:t+(TPS>>1),v:1,vel:Math.max(1,s[2]-24),a:0}); }
|
||||
}
|
||||
prev=sl;
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
/* Sections B and C reuse A's core pattern and transform it, so the phrase hangs together. */
|
||||
function transformKit(ev,steps,sec,seed,fs){
|
||||
if(sec===0) return ev;
|
||||
var r=new Rng(mix(seed,0x5345+sec)), ops=[r.below(8)], i, out=[];
|
||||
if(r.chance(P(64))) ops.push(r.below(8));
|
||||
var half=(steps>>1)*TPS;
|
||||
for(i=0;i<ev.length;i++) out.push({t:ev[i].t,v:ev[i].v,vel:ev[i].vel,a:ev[i].a});
|
||||
function has(o){ return ops.indexOf(o)>=0; }
|
||||
if(has(0)){ // kick answer: push the last off-beat kick a 16th later, add one before the backbeat
|
||||
var lastK=-1; for(i=0;i<out.length;i++) if(out[i].v===0 && out[i].t>0) lastK=i;
|
||||
if(lastK>=0 && out[lastK].t+TPS<steps*TPS) out[lastK].t+=TPS;
|
||||
out.push({t:3*TPS,v:0,vel:98});
|
||||
}
|
||||
if(has(1)){ // second half doubles its hats into 16ths
|
||||
var hs=[]; for(i=0;i<out.length;i++) if(out[i].v===2 && out[i].t>=half && out[i].t%TPS===0) hs.push(out[i].t);
|
||||
for(i=0;i<hs.length;i++) if(hs[i]+TPS<steps*TPS) out.push({t:hs[i]+TPS,v:2,vel:60});
|
||||
}
|
||||
if(has(2)){ // first half thins out: no perc, no ghosts
|
||||
out=out.filter(function(e){ return !(e.t<half && (e.v===3 || (e.v===1 && e.vel<90))); });
|
||||
}
|
||||
if(has(3)){ // perc moves two 16ths later
|
||||
for(i=0;i<out.length;i++) if(out[i].v===3) out[i].t=(out[i].t+2*TPS)%(steps*TPS);
|
||||
}
|
||||
if(has(4)){ // ghost snare pickups into each main snare
|
||||
for(var sk in fs.S){ var sp=sk|0; if(sp>0) out.push({t:(sp-1)*TPS,v:1,vel:44}); }
|
||||
}
|
||||
if(has(5)){ // snare drag: the last main snare lands a 16th late
|
||||
var ls=-1; for(i=0;i<out.length;i++) if(out[i].v===1 && out[i].vel>=90) ls=i;
|
||||
if(ls>=0 && out[ls].t+TPS<steps*TPS) out[ls].t+=TPS;
|
||||
}
|
||||
if(has(6)){ // drop the one: no kick on the downbeat, the bass and pad keep the bar
|
||||
var kn=0; for(i=0;i<out.length;i++) if(out[i].v===0) kn++;
|
||||
if(kn>1) out=out.filter(function(e){ return !(e.v===0 && e.t===0); });
|
||||
}
|
||||
if(has(7)){ // half-time switch: main snares collapse to one in the middle of the bar
|
||||
out=out.filter(function(e){ return !(e.v===1 && e.vel>=90); });
|
||||
out.push({t:(steps>>1)*TPS,v:1,vel:116});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* Phrase arc. build: bars 1-2 drop their offbeat clutter. break: bar 3 strips to kick-on-one, backbeat, hats. */
|
||||
function arcKit(ev,g,pos){
|
||||
if(g.arc===1 && pos<2) return ev.filter(function(e){ return !((e.v===3 || (e.v===1 && e.vel<90)) && (pos===0 || e.t%(2*TPS)!==0)); });
|
||||
if(g.arc===2 && pos===2){ var k0=1e9; for(var i=0;i<ev.length;i++) if(ev[i].v===0 && ev[i].t<k0) k0=ev[i].t;
|
||||
return ev.filter(function(e){ return (e.v===0 && e.t===k0) || (e.v===1 && e.vel>=90) || (e.v===2 && e.t%(2*TPS)===0); }); }
|
||||
return ev;
|
||||
}
|
||||
/* Velocity by metric position (beat > 8th > 16th), plus an accent cycle of 3/5/7 sixteenths that runs across the bar line. */
|
||||
function shapeKit(ev,g,pos,aoff,jit,jr){
|
||||
for(var i=0;i<ev.length;i++){
|
||||
var e=ev[i]; if(e.v<2 || e.t%TPS!==0) continue;
|
||||
var st=e.t/TPS, sw=st+aoff, w=(sw%4===0)?100:(sw%2===0)?86:74, x=pos*g.steps+sw; // aoff slides where the 'strong' 16ths are
|
||||
var v=fdiv(e.vel*w,100);
|
||||
if(g.accentCycle && (x%g.accentCycle)===0) v+=24;
|
||||
if(jit) v+=jr.below(2*jit+1)-jit;
|
||||
e.vel=clamp(v,1,127);
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
function kitFill(ev,steps,e,fr){
|
||||
var gs=groups(steps), last=gs[gs.length-1], st=last[0]*TPS, L=last[1]*TPS, out=[], i, type=fr.below(4);
|
||||
for(i=0;i<ev.length;i++) if(ev[i].t<st) out.push(ev[i]);
|
||||
if(type===0){ out.push({t:st,v:0,vel:110}); var m=4+(e>>5); for(i=0;i<m;i++){ var q=m-i; out.push({t:st+L-Math.floor(L*q*q/(m*m)),v:1,vel:90+Math.floor(30*i/m)}); } }
|
||||
else if(type===1){ out.push({t:st,v:0,vel:110}); var k=0; for(var t=st;t<st+L;t+=(TPS>>1),k++) out.push({t:t,v:(k%2)?3:1,vel:(k%2)?70+k*4:92+k*3}); }
|
||||
else if(type===2){ out.push({t:st,v:0,vel:116}); out.push({t:st+L-TPS,v:1,vel:100}); out.push({t:st+L-(TPS>>1),v:1,vel:116}); } // drop-out, then a two-hit pickup
|
||||
else { var prev=gs.length>1?gs[gs.length-2]:last, ps=prev[0]*TPS; // previous beat, stuttered
|
||||
for(i=0;i<ev.length;i++) if(ev[i].t>=ps && ev[i].t<ps+(L>>1)){ out.push({t:st+(ev[i].t-ps),v:ev[i].v,vel:ev[i].vel}); out.push({t:st+(L>>1)+(ev[i].t-ps),v:ev[i].v,vel:clamp(ev[i].vel+10,1,127)}); } }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- synth tracks ---------- */
|
||||
function chordDeg(g,pos){ return g.prog[pos%PHRASE_BARS]; }
|
||||
function degPitch(g,base,idx){ var sc=SCALES[g.scale], n=sc.length; return base+g.root+12*fdiv(idx,n)+sc[mod(idx,n)]; }
|
||||
function melPitch(g,idx){ var p=degPitch(g,60,idx); while(p<60) p+=12; while(p>96) p-=12; return p; } // keep the melody above the pad
|
||||
function isChordTone(g,pos,idx){ var n=SCALES[g.scale].length, c=chordDeg(g,pos), x=mod(idx-c,n); return x===0 || x===2 || x===4; }
|
||||
function nearestChordTone(g,pos,idx){ for(var d=0;d<4;d++){ if(isChordTone(g,pos,idx-d)) return idx-d; if(isChordTone(g,pos,idx+d)) return idx+d; } return idx; }
|
||||
/* Motif: 3-5 notes inside an 8-step window, contour in scale steps. Same motif across the whole seed. */
|
||||
function makeMotif(rs,g){
|
||||
var r=new Rng(mix(rs,0x4D4F54)), n=g.motifLen, pos=[0], used={0:1}, cont=[0], i;
|
||||
if(r.chance(P(40))){ pos[0]=r.below(2)?1:2; used={}; used[pos[0]]=1; }
|
||||
while(pos.length<n){ var p=1+r.below(7); if(!used[p]){ used[p]=1; pos.push(p); } }
|
||||
pos.sort(function(a,b){ return a-b; });
|
||||
for(i=1;i<n;i++){ var stp=[-2,-1,1,2,-4,4][r.pickW([3,4,4,3,1,1])]; cont.push(cont[i-1]+stp); }
|
||||
return {pos:pos,cont:cont,resp:r.below(3)}; // resp: how the second half answers (0 sequence up, 1 inversion, 2 displaced)
|
||||
}
|
||||
function lineTracks(rs,variant,g,steps,d,sec,pos,kit,fill){
|
||||
var core=new Rng(mix(rs,0x4D4F+sec)), vr=new Rng(mix(mix(rs,0x5641+sec),variant));
|
||||
var n=SCALES[g.scale].length, barT=steps*TPS, mel=[], bass=[], pad=[], i, j;
|
||||
var gs=groups(steps), last=gs[gs.length-1], kickList=[], M=makeMotif(rs,g), c=chordDeg(g,pos);
|
||||
for(i=0;i<kit.length;i++) if(kit[i].v===0 && kit[i].t%TPS===0) kickList.push(kit[i].t);
|
||||
// melody: statement anchored on the bar's chord, then an answer in the second half
|
||||
function place(start,cont,anchor,velBase){
|
||||
for(var k=0;k<M.pos.length && k<cont.length;k++){
|
||||
var s=start+M.pos[k]; if(s>=steps) break;
|
||||
if(fill && s>=last[0]) break;
|
||||
var idx=anchor+cont[k];
|
||||
if(s%4===0) idx=nearestChordTone(g,pos,idx); // strong positions land on chord tones
|
||||
if(vr.chance(P(d>>3))) continue; // drift/energy drops the odd note
|
||||
mel.push({t:s*TPS,v:melPitch(g,idx),vel:velBase+(s%4===0?18:0)+vr.below(12)});
|
||||
if(k+1<M.pos.length && k+1<cont.length && vr.chance(P(d>>2))){ // passing note between motif notes
|
||||
var s2=start+M.pos[k+1]; if(s2-s>=2 && (s+1)<steps) mel.push({t:(s+1)*TPS,v:melPitch(g,idx+(cont[k+1]>cont[k]?1:-1)),vel:velBase-14});
|
||||
}
|
||||
}
|
||||
}
|
||||
var anchor=c+(core.chance(P(50))?2:0), half=steps>>1;
|
||||
place(0,M.cont,anchor,82);
|
||||
if(d>=40 || steps>=16){
|
||||
var c2=M.cont.slice(), a2=anchor, st2=half;
|
||||
if(M.resp===0) a2=anchor+2;
|
||||
else if(M.resp===1) for(j=0;j<c2.length;j++) c2[j]=-c2[j];
|
||||
else st2=half+1;
|
||||
if(pos===PHRASE_BARS-1){ c2=c2.slice(0,Math.max(1,c2.length-1)); } // last bar: shorter answer, then the cadence
|
||||
place(st2,c2,a2,74);
|
||||
}
|
||||
if(pos===PHRASE_BARS-1 && !fill){ var cs=Math.max(0,steps-4); mel=mel.filter(function(m){ return m.t<cs*TPS; }); mel.push({t:cs*TPS,v:melPitch(g,chordDeg(g,0)),vel:96}); }
|
||||
if(fill){ var nxt=chordDeg(g,0)+n, k2=0; for(var tt=last[0]*TPS;tt<(last[0]+last[1])*TPS;tt+=TPS,k2++) mel.push({t:tt,v:melPitch(g,nxt-(last[1]-1-k2)),vel:76+k2*8}); }
|
||||
var rot=g.rot*TPS;
|
||||
for(i=0;i<mel.length;i++) mel[i].t=(mel[i].t+rot)%barT;
|
||||
mel=oneAtATime(mel);
|
||||
for(i=0;i<mel.length;i++) mel[i].tr=((g.hocket>>(i&7))&1)?T_MELA:T_MELB;
|
||||
// bass: chord root on the kicks, the seed's colour tone on alternate kicks, an approach note into the next bar
|
||||
function bp(idx){ var p=degPitch(g,36,idx); while(p>47) p-=12; while(p<36) p+=12; return p; }
|
||||
var nextC=chordDeg(g,pos+1);
|
||||
for(i=0;i<kickList.length;i++){
|
||||
var t=kickList[i], idx=c;
|
||||
if(t>0 && (i%2)===1 && core.chance(P(70))) idx=c+g.bassDeg;
|
||||
bass.push({t:t,v:bp(idx),vel:t===0?112:98,tr:T_BASS});
|
||||
}
|
||||
if(steps>=8 && core.chance(P(60))){ var at=(steps-1)*TPS; if(kickList.indexOf(at)<0) bass.push({t:at,v:bp(nextC+(core.below(2)?1:-1)),vel:84,tr:T_BASS}); }
|
||||
// pad: the bar's triad (in scale), voiced inside C3-B3
|
||||
var tones=[c,c+2,c+4], seen={};
|
||||
for(i=0;i<tones.length;i++){ var pc=mod(degPitch(g,0,tones[i]),12), p=48+pc; if(!seen[p]){ seen[p]=1; pad.push({t:0,v:p,vel:i===0?58:50,tr:T_PAD}); } }
|
||||
return {mel:mel,bass:oneAtATime(bass),pad:pad};
|
||||
}
|
||||
|
||||
function sortDedupe(ev){
|
||||
ev.sort(function(a,b){ return a.t-b.t || a.v-b.v; });
|
||||
var out=[], i;
|
||||
for(i=0;i<ev.length;i++){ var p=out[out.length-1]; if(p && p.t===ev[i].t && p.v===ev[i].v){ if(ev[i].vel>p.vel) p.vel=ev[i].vel; } else out.push({t:ev[i].t,v:ev[i].v,vel:ev[i].vel,tr:ev[i].tr,a:ev[i].a}); }
|
||||
for(i=0;i<out.length;i++) out[i].vel=clamp(out[i].vel,1,127);
|
||||
return out;
|
||||
}
|
||||
function oneAtATime(ev){ var o=sortDedupe(ev), out=[]; for(var i=0;i<o.length;i++){ if(out.length && out[out.length-1].t===o[i].t) out[out.length-1]=o[i]; else out.push(o[i]); } return out; }
|
||||
|
||||
function kitTrack(e){
|
||||
if(e.v===0) return (e.a || e.t===0)?T_KICK:T_KICK2; // frame kicks are the main kick, wherever they fall
|
||||
if(e.v===1) return (e.a || e.vel>=90)?T_SNARE:T_GHOST;
|
||||
if(e.v===2) return (e.a || e.vel>=85)?T_HAT:T_HAT16;
|
||||
return T_PERC;
|
||||
}
|
||||
/* Groove: every second 16th is delayed by the seed's swing; main snares can sit slightly behind. */
|
||||
function groove(t,g,isSnare,barTicks,swing){
|
||||
if(t%TPS===0 && (t/TPS)%2===1) t+=swing;
|
||||
if(isSnare && g.layback) t+=12;
|
||||
return Math.min(t,barTicks-1);
|
||||
}
|
||||
|
||||
/* DRIFT, one knob with overlapping layers that each fade in smoothly (0 = locked loop):
|
||||
0-42 phrase variations go from never to every phrase
|
||||
20-127 microtiming: hits lean off the grid, hats drag, snares rush, kick and bass move together
|
||||
40-127 swing wobbles from bar to bar
|
||||
55-127 accents slip: the strong 16ths of the hats and perc move, velocities loosen
|
||||
80-127 whole parts slide a 16th early or late for a bar (hats, perc, ghosts, second kick, melody)
|
||||
105-127 a beat now and then is squeezed onto a triplet grid */
|
||||
function amt(d,a,b){ return clamp(fdiv((d-a)*127,b-a),0,127); }
|
||||
var NUDGE_GRP=[0,0,1,3,2,2,3,0,4,4,5]; // per track: 0 low end (kick, bass), 1 snare, 2 hats, 3 ghost/perc, 4 melody, 5 pad
|
||||
function nudge(barSeed,t,grp,micro){
|
||||
if(!micro || grp===5) return 0;
|
||||
var mx=fdiv(micro*(grp===4?30:48),127), h=mix(mix(barSeed,t),grp), n=(h%(2*mx+1))-mx;
|
||||
if(grp===2) n+=fdiv(micro*10,127); // hats drag
|
||||
if(grp===1) n-=fdiv(micro*8,127); // snares rush
|
||||
return n;
|
||||
}
|
||||
function shiftGroup(ev,test,by,barT){ for(var i=0;i<ev.length;i++) if(test(ev[i])) ev[i].t=mod(ev[i].t+by,barT); }
|
||||
function tripletWarp(ev,beat){ // the four 16ths of one beat land on three triplet 8ths
|
||||
var s=beat*TPB, out=[];
|
||||
for(var i=0;i<ev.length;i++){ var e=ev[i]; if(e.t>=s && e.t<s+TPB){ var k=Math.min(2,fdiv((e.t-s)*3+TPB/2,TPB)); e.t=s+k*280; } out.push(e); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The player's ROOT and SCALE pages: -1 (or absent) = AUTO, the seed's own pick. */
|
||||
function applyKey(g,p){
|
||||
if(typeof p.scale==='number' && p.scale>=0 && p.scale<SCALES.length) g.scale=p.scale;
|
||||
if(typeof p.root==='number' && p.root>=0 && p.root<12) g.root=p.root;
|
||||
return g;
|
||||
}
|
||||
|
||||
/* All tracks are rendered every bar; which ones sound is up to the player. */
|
||||
function renderBar(st,p,bar){
|
||||
var g=applyKey(genome(st.seed),p), amen=p.gen===4; if(amen){ g.steps=16; g.swing=0; g.layback=0; } // the break: 16 steps, straight; drift adds the swing
|
||||
var pos=bar%PHRASE_BARS, phrase=(bar/PHRASE_BARS)|0, sec=g.form[pos], barTicks=g.steps*TPS, i, j;
|
||||
var fr=new Rng(mix(mix(st.seed,0x46494C),phrase));
|
||||
var fill = !!p.fill || (!amen && pos===PHRASE_BARS-1 && fr.chance(P(g.fillP>>1)));
|
||||
var variants=[], r;
|
||||
var dr=clamp(p.drift|0,0,127), dVar=Math.min(127,dr*3), micro=amt(dr,20,127), wob=amt(dr,40,127), slip=amt(dr,55,127), disp=amt(dr,80,127), warp=amt(dr,105,127);
|
||||
if(amen){ micro=fdiv(micro,2); wob=fdiv(wob,2); slip=fdiv(slip,2); disp=fdiv(amt(dr,90,127),2); warp=amt(dr,115,127); } // the break already has its feel: the generic layers come in later and softer
|
||||
var dRng=new Rng(mix(mix(st.seed,0x445249),bar)), barSeed=mix(st.seed,0x4E55+bar);
|
||||
for(r=0;r<2;r++) variants.push(phrase>0 && new Rng(mix(mix(roleSeed(st,r),0xD1F7),phrase)).chance(P(dVar)) ? phrase : 0);
|
||||
var e=clamp(p.energy|0,0,127), krs=roleSeed(st,0), cS=mix(krs,0x434F), vS=mix(mix(krs,0x5641+sec),variants[0]);
|
||||
var arcD=(g.arc===1)?(pos*10-15):0;
|
||||
var kd=clamp(g.dens[0]+e-64+arcD,0,127), hd=clamp(g.dens[0]+((e-64)>>2)+arcD,0,127); // energy moves hats only a quarter as much
|
||||
var fs=frameSets(g,g.steps,pos);
|
||||
var kit;
|
||||
if(amen){
|
||||
kit=humanizeAmen(kitAmen(krs,bar,pos,dr,fill),krs,bar); // the break's own velocities, humanized, untouched by the shaping below
|
||||
if(slip){ var jt=fdiv(slip*24,127); for(i=0;i<kit.length;i++) kit[i].vel=clamp(kit[i].vel+dRng.below(2*jt+1)-jt,1,127); }
|
||||
} else {
|
||||
kit = p.gen===0 ? kitEuclid(cS,vS,g.steps,kd,hd,pos,fs)
|
||||
: p.gen===1 ? kitSkeleton(cS,vS,g.steps,kd,hd,pos,fs,g.frame)
|
||||
: p.gen===2 ? kitSubdiv(cS,vS,g.steps,kd,hd,pos,fs)
|
||||
: kitCA(cS,vS,g.steps,kd,hd,pos,sec,fs);
|
||||
kit=transformKit(kit,g.steps,sec,krs,fs);
|
||||
kit=arcKit(kit,g,pos);
|
||||
if(fill) kit=kitFill(kit,g.steps,p.energy,fr);
|
||||
var aoff = dRng.chance(P(slip)) ? 1+dRng.below(3) : 0;
|
||||
kit=shapeKit(kit,g,pos,aoff,fdiv(slip*24,127),dRng);
|
||||
}
|
||||
if(disp){ // whole parts slide a 16th for this bar
|
||||
var dirs=[TPS,-TPS];
|
||||
if(dRng.chance(P(disp))) shiftGroup(kit,function(x){ return x.v===2; },dirs[dRng.below(2)],barTicks);
|
||||
if(dRng.chance(P(disp))) shiftGroup(kit,function(x){ return x.v===3; },dirs[dRng.below(2)],barTicks);
|
||||
if(dRng.chance(P(disp>>1))) shiftGroup(kit,function(x){ return x.v===1 && x.vel<90; },dirs[dRng.below(2)],barTicks);
|
||||
if(dRng.chance(P(disp>>1))) shiftGroup(kit,function(x){ return x.v===0 && !x.a && x.t>0; },dirs[dRng.below(2)],barTicks);
|
||||
if(dRng.chance(P(disp>>2))) shiftGroup(kit,function(x){ return x.v===1 && x.vel>=90; },TPS,barTicks);
|
||||
}
|
||||
var warpBeat = warp && dRng.chance(P(warp)) ? dRng.below(Math.max(1,fdiv(g.steps,4))) : -1;
|
||||
if(warpBeat>=0) kit=tripletWarp(kit,warpBeat);
|
||||
kit=sortDedupe(kit);
|
||||
var melShift = disp && dRng.chance(P(disp>>1)) ? (dRng.below(2)?TPS:-TPS) : 0;
|
||||
var swingBar = wob ? clamp(g.swing+dRng.below(2*fdiv(wob*40,127)+1)-fdiv(wob*40,127),0,90) : g.swing;
|
||||
var lt=lineTracks(roleSeed(st,1),variants[1],g,g.steps,clamp(g.dens[1]+e-64,0,127),sec,pos,kit,fill), out=[];
|
||||
for(i=0;i<kit.length;i++){
|
||||
var tr=kitTrack(kit[i]), t=kit[i].t;
|
||||
if(tr===T_PERC) t=(t+g.rot*TPS)%barTicks; // perc shares the melody's phase offset
|
||||
var nt=barTicks; for(j=i+1;j<kit.length;j++) if(kit[j].v===kit[i].v){ nt=kit[j].t; break; }
|
||||
var len=Math.max(10,Math.min(105,nt>t?nt-t:105));
|
||||
out.push({t:clamp(groove(t,g,tr===T_SNARE,barTicks,swingBar)+nudge(barSeed,t,NUDGE_GRP[tr],micro)+(amen?amenFeel(krs,bar,t,kit[i].v,kit[i].a):0),0,barTicks-1),lane:0,v:kit[i].v,vel:kit[i].vel,len:len,tr:tr});
|
||||
}
|
||||
function mono(list,lenRule){
|
||||
for(var k=0;k<list.length;k++){ var nx=list[k+1], gap=(nx?nx.t:barTicks)-list[k].t, len=lenRule(gap,nx,list[k]);
|
||||
var t0=list[k].t; if(melShift && (list[k].tr===T_MELA || list[k].tr===T_MELB)) t0=mod(t0+melShift,barTicks);
|
||||
out.push({t:clamp(groove(t0,g,false,barTicks,swingBar)+nudge(barSeed,list[k].t,NUDGE_GRP[list[k].tr],micro),0,barTicks-1),lane:1,v:list[k].v,vel:list[k].vel,len:Math.max(10,len-swingBar),tr:list[k].tr}); }
|
||||
}
|
||||
var melA=[], melB=[]; for(i=0;i<lt.mel.length;i++) (lt.mel[i].tr===T_MELA?melA:melB).push(lt.mel[i]);
|
||||
function melLen(gap,nx,cur){ return g.monoLen===0?Math.min(90,gap-5):g.monoLen===1?Math.max(30,gap>>1):((nx && nx.v!==cur.v)?gap+20:gap-5); }
|
||||
mono(melA,melLen); mono(melB,melLen);
|
||||
mono(lt.bass,function(gap){ return gap-10; });
|
||||
for(i=0;i<lt.pad.length;i++) out.push({t:0,lane:1,v:lt.pad[i].v,vel:lt.pad[i].vel,len:barTicks-30,tr:T_PAD});
|
||||
out.sort(function(a,b){ return a.t-b.t || a.tr-b.tr || a.v-b.v; });
|
||||
return {bar:bar,pos:pos,phrase:phrase,sec:sec,fill:fill,variants:variants,barTicks:barTicks,steps:g.steps,events:out};
|
||||
}
|
||||
var ROMAN=['i','ii','iii','iv','v','vi','vii'];
|
||||
function describe(st,p){
|
||||
var g=applyKey(genome(st.seed),p), s=[];
|
||||
s.push(p.gen===4?'amen break':FRAMES[g.frame].n);
|
||||
s.push('chords '+g.prog.map(function(x){ return ROMAN[x%7]; }).join('–'));
|
||||
if(p.gen!==4){
|
||||
s.push(g.swing?'swing '+Math.round(g.swing*100/TPS)+'%':'straight');
|
||||
if(g.arc) s.push(ARC_NAMES[g.arc]);
|
||||
if(g.accentCycle) s.push('accents every '+g.accentCycle);
|
||||
}
|
||||
if(p.gen===0){ var dd=clamp(g.dens[0]+(p.energy|0)-64,0,127), E=euclidParams(mix(roleSeed(st,0),0x434F),g.steps,dd,clamp(g.dens[0]+(((p.energy|0)-64)>>2),0,127));
|
||||
s.push('kick E('+E.kick.k+','+E.kick.n+') hat E('+E.hat.k+','+E.hat.n+') perc E('+E.perc.k+','+E.perc.n+')'); }
|
||||
if(g.rot) s.push('melody + perc shifted '+g.rot+'/16');
|
||||
return s.join(' · ');
|
||||
}
|
||||
function checksum(rows){ var h=0x811C9DC5>>>0; for(var i=0;i<rows.length;i++) for(var j=0;j<rows[i].length;j++){ h=Math.imul(h^(rows[i][j]&0xFFFF),0x01000193)>>>0; } return h; }
|
||||
|
||||
return {TPB:TPB,TPS:TPS,CLOCK_TICKS:CLOCK_TICKS,PHRASE_BARS:PHRASE_BARS,TRACKS:TRACKS,NT:NT,
|
||||
genome:genome,applyKey:applyKey,renderBar:renderBar,
|
||||
describe:describe,checksum:checksum,mix:mix,Rng:Rng,SCALE_NAMES:SCALE_NAMES,NOTE_NAMES:NOTE_NAMES,GEN_NAMES:GEN_NAMES};
|
||||
})();
|
||||
if(typeof module!=='undefined') module.exports=Engine;
|
||||
237
sim/index.html
Normal file
237
sim/index.html
Normal file
@@ -0,0 +1,237 @@
|
||||
<title>Seedbox</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=Martian+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Layout: left = the box as it will be built; right = phrase view, voice switches, bench settings. Stacks on phones. */
|
||||
:root{
|
||||
--bg:#E3E7EB; --surface:#F1F3F5; --panel-a:#D8DDE2; --panel-b:#BFC7CF; --ink:#19202A; --muted:#566271;
|
||||
--line:#BAC2CB; --grid:#D3D9DF; --accent:#1E4C9E; --warn:#B4441F;
|
||||
--l-kit:#13737A; --l-mono:#6243AB;
|
||||
--oled-bg:#03060A; --cap:#262C33; --cap-hi:#3A424B; --cap-ink:#E6EBEF; --bezel:#11161C;
|
||||
--f-ui:'Hanken Grotesk', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--f-mono:'Martian Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
|
||||
--bg:#12161B; --surface:#1A1F25; --panel-a:#2A3038; --panel-b:#1E242A; --ink:#E2E7EC; --muted:#8C98A5;
|
||||
--line:#333B44; --grid:#262D35; --accent:#8EB0F2; --warn:#F07B52;
|
||||
--l-kit:#4CC4C9; --l-mono:#A88EF2;
|
||||
--cap:#0E1216; --cap-hi:#272E36; --cap-ink:#D9E0E6; --bezel:#07090C; color-scheme:dark } }
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#12161B; --surface:#1A1F25; --panel-a:#2A3038; --panel-b:#1E242A; --ink:#E2E7EC; --muted:#8C98A5;
|
||||
--line:#333B44; --grid:#262D35; --accent:#8EB0F2; --warn:#F07B52;
|
||||
--l-kit:#4CC4C9; --l-mono:#A88EF2;
|
||||
--cap:#0E1216; --cap-hi:#272E36; --cap-ink:#D9E0E6; --bezel:#07090C; color-scheme:dark }
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--ink);font:15px/1.5 var(--f-ui);padding:20px 16px 40px}
|
||||
.wrap{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr);gap:28px;align-items:start}
|
||||
|
||||
h1{font:700 22px/1.1 var(--f-mono);letter-spacing:-.01em;margin:0}
|
||||
.sub{color:var(--muted);font-size:14px;margin:6px 0 0;max-width:62ch}
|
||||
.label{font:500 10px/1.2 var(--f-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}
|
||||
|
||||
/* the box */
|
||||
.device{position:relative;border-radius:14px;padding:26px 22px 22px;background:linear-gradient(165deg,var(--panel-a),var(--panel-b));
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.35),inset 0 -2px 0 rgba(0,0,0,.12),0 10px 30px -12px rgba(0,0,0,.35);display:grid;gap:18px}
|
||||
.screw{position:absolute;width:9px;height:9px;border-radius:50%;background:radial-gradient(circle at 35% 35%,var(--cap-hi),var(--cap))}
|
||||
.screw.tl{top:8px;left:8px}.screw.tr{top:8px;right:8px}.screw.bl{bottom:8px;left:8px}.screw.br{bottom:8px;right:8px}
|
||||
.dev-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||||
.dev-name{font:700 13px var(--f-mono);letter-spacing:.24em;color:var(--accent)}
|
||||
.oledrow{display:flex;align-items:flex-end;gap:10px}
|
||||
.device{max-width:820px}
|
||||
.backwall{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.10);box-shadow:inset 0 1px 2px rgba(0,0,0,.2)}
|
||||
.backwall .label{margin-right:8px}
|
||||
.bw-spacer{flex:1}
|
||||
.usb{width:22px;height:10px;border-radius:3px;background:var(--bezel)}
|
||||
.zones{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:22px;align-items:center}
|
||||
@media (max-width:700px){.zones{grid-template-columns:minmax(0,1fr)}}
|
||||
.zone{display:grid;gap:14px;justify-items:center;min-width:0}
|
||||
.zl{justify-items:start}
|
||||
.zlrow{display:flex;gap:22px;align-items:flex-end}
|
||||
.zm{gap:12px}
|
||||
.zm .big{width:72px;height:72px}
|
||||
.oled{background:var(--bezel);border-radius:3px;padding:2mm;line-height:0}
|
||||
.oled.lcd{background:#3A4440;padding:2.5mm}
|
||||
.oled canvas{display:block;image-rendering:pixelated;max-width:100%}
|
||||
.zoomwrap{background:var(--bezel);border-radius:6px;padding:10px;line-height:0}
|
||||
#oledZoom{display:block;width:100%;max-width:520px;image-rendering:pixelated}
|
||||
.trk.noled .tled{visibility:hidden}
|
||||
.runcell{display:grid;place-items:center}
|
||||
.row{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;flex-wrap:wrap}
|
||||
.ctl{display:grid;justify-items:center;gap:6px}
|
||||
.knob{width:62px;height:62px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
|
||||
background:radial-gradient(circle at 40% 30%,var(--cap-hi),var(--cap) 70%);box-shadow:0 3px 6px rgba(0,0,0,.3),0 0 0 4px rgba(0,0,0,.08)}
|
||||
.knob::after{content:"";position:absolute;left:50%;top:6px;width:3px;height:18px;margin-left:-1.5px;border-radius:2px;background:var(--cap-ink);transform-origin:50% 25px;transform:rotate(var(--rot,0deg))}
|
||||
.knob:focus-visible,.enc:focus-visible,.cap:focus-visible,.big:focus-visible,.toggle:focus-visible,.vs:focus-visible,.btn:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
.val{font:500 11px var(--f-mono);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.enc{width:52px;height:52px;border-radius:50%;cursor:ew-resize;touch-action:none;position:relative;background:repeating-conic-gradient(var(--cap) 0 6deg,var(--cap-hi) 6deg 12deg);box-shadow:0 3px 6px rgba(0,0,0,.3)}
|
||||
.enc::after{content:"";position:absolute;inset:10px;border-radius:50%;background:radial-gradient(circle at 40% 30%,var(--cap-hi),var(--cap) 75%)}
|
||||
.enc i{position:absolute;z-index:1;left:50%;top:14px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:var(--cap-ink);transform-origin:2px 12px;transform:rotate(var(--rot,0deg))}
|
||||
.big{width:96px;height:96px;border-radius:50%;border:0;cursor:pointer;font:700 11px var(--f-mono);letter-spacing:.14em;color:var(--cap-ink);touch-action:none;
|
||||
background:radial-gradient(circle at 40% 30%,var(--cap-hi),var(--cap) 72%);box-shadow:0 5px 0 rgba(0,0,0,.35),0 0 0 6px rgba(0,0,0,.1)}
|
||||
.big.down{transform:translateY(3px);box-shadow:0 2px 0 rgba(0,0,0,.35),0 0 0 6px rgba(0,0,0,.1)}
|
||||
.caps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;flex:1;min-width:0}
|
||||
.capw{display:grid;justify-items:center;gap:6px}
|
||||
.led{width:8px;height:8px;border-radius:50%;background:rgba(0,0,0,.35);box-shadow:inset 0 1px 1px rgba(0,0,0,.4)}
|
||||
.led.on{background:var(--c);box-shadow:0 0 8px var(--c)}
|
||||
.cap{width:100%;max-width:70px;height:38px;border:0;border-radius:6px;cursor:pointer;color:var(--cap-ink);font:600 10px var(--f-mono);letter-spacing:.08em;touch-action:none;
|
||||
background:linear-gradient(var(--cap-hi),var(--cap));box-shadow:0 3px 0 rgba(0,0,0,.35)}
|
||||
.cap.down{transform:translateY(2px);box-shadow:0 1px 0 rgba(0,0,0,.35)}
|
||||
.cap.off{opacity:.45}
|
||||
.toggle{display:grid;justify-items:center;gap:6px;background:none;border:0;cursor:pointer;padding:0;color:inherit}
|
||||
.tg{width:22px;height:40px;border-radius:11px;background:var(--bezel);position:relative;box-shadow:inset 0 2px 3px rgba(0,0,0,.6)}
|
||||
.tg::after{content:"";position:absolute;left:4px;width:14px;height:18px;border-radius:7px;background:var(--cap-ink);top:18px;transition:top .12s}
|
||||
.toggle[aria-pressed="true"] .tg::after{top:4px}
|
||||
.hint{font:400 11px/1.6 var(--f-mono);color:var(--muted);margin:12px 0 0}
|
||||
.hint kbd{font:inherit;border:1px solid var(--line);border-radius:3px;padding:0 4px}
|
||||
|
||||
|
||||
/* track buttons under a WS2812 strip */
|
||||
.trk{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 10px;padding:12px 10px 8px;border-radius:8px;background:rgba(0,0,0,.06);box-shadow:inset 0 1px 2px rgba(0,0,0,.15)}
|
||||
.tcell{display:grid;justify-items:center;gap:6px;min-width:0}
|
||||
.tled{width:11px;height:11px;border-radius:50%;background:var(--bezel);box-shadow:inset 0 1px 1px rgba(0,0,0,.6)}
|
||||
.tbtn{width:34px;height:34px;border-radius:50%;border:0;cursor:pointer;touch-action:none;background:radial-gradient(circle at 40% 30%,var(--cap-hi),var(--cap) 70%);box-shadow:0 3px 0 rgba(0,0,0,.35),0 0 0 3px rgba(0,0,0,.12)}
|
||||
.tbtn.down{transform:translateY(2px);box-shadow:0 1px 0 rgba(0,0,0,.35),0 0 0 3px rgba(0,0,0,.12)}
|
||||
.tbtn:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
.tname{font:600 9px/1.1 var(--f-mono);letter-spacing:.06em;color:var(--accent);text-align:center;white-space:nowrap}
|
||||
.jacks{display:flex;align-items:center;gap:6px}
|
||||
.jack{width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,var(--bezel) 40%,var(--cap-hi) 44%,var(--cap) 70%)}
|
||||
.jacks .label{margin-right:8px}
|
||||
/* right column */
|
||||
.main{display:grid;gap:18px;min-width:0}
|
||||
.statusbar{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:space-between}
|
||||
.pill{font:500 11px var(--f-mono);padding:4px 10px;border-radius:999px;border:1px solid var(--line);color:var(--muted);background:var(--surface)}
|
||||
.pill.ok{color:var(--l-kit);border-color:currentColor}
|
||||
.pill.bad{color:var(--warn);border-color:currentColor}
|
||||
.view{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px;display:grid;gap:12px;min-width:0}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:4px 18px;font:500 12px var(--f-mono);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.facts b{color:var(--ink);font-weight:500}
|
||||
#grid{display:block;width:100%;height:340px}
|
||||
.voices{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.tracks{display:grid;gap:8px;flex:1;min-width:0}
|
||||
.trow{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
||||
.trow .label{width:100%}
|
||||
.vs{display:inline-flex;align-items:center;gap:8px;font:600 12px var(--f-mono);padding:7px 12px;border-radius:6px;cursor:pointer;
|
||||
border:1px solid var(--c);background:var(--c);color:var(--surface)}
|
||||
.vs .st{font-weight:500;opacity:.8;font-size:10px;text-transform:uppercase;letter-spacing:.06em}
|
||||
.vs[aria-pressed="false"]{background:transparent;color:var(--muted);border-color:var(--line)}
|
||||
.vs.pending{outline:2px dashed var(--accent);outline-offset:2px}
|
||||
.check{display:flex;gap:8px;align-items:center;font:500 13px var(--f-ui)}
|
||||
.voices .check{font-size:12px;color:var(--muted)}
|
||||
.voices{align-items:flex-end}
|
||||
details{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
|
||||
summary{cursor:pointer;font:600 14px var(--f-ui)}
|
||||
.form{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px 16px;margin-top:12px}
|
||||
.field{display:grid;gap:4px;min-width:0}
|
||||
.field label{font:500 11px var(--f-mono);color:var(--muted);letter-spacing:.04em}
|
||||
select,input[type=number],input[type=text]{font:500 13px var(--f-mono);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;min-width:0}
|
||||
.btn{font:600 12px var(--f-mono);letter-spacing:.04em;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:6px;padding:7px 12px;cursor:pointer}
|
||||
.btn:hover{border-color:var(--accent)}
|
||||
textarea{width:100%;min-height:140px;font:400 11px/1.4 var(--f-mono);background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px;resize:vertical}
|
||||
.note{color:var(--muted);font-size:13px;margin:10px 0 0;max-width:70ch}
|
||||
|
||||
.padgrid .lbl{font:500 11px var(--f-mono);color:var(--muted)}
|
||||
.padgrid .val.miss{color:var(--warn)}
|
||||
.padgrid{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px 12px}
|
||||
.sect{font:600 12px var(--f-mono);color:var(--accent);letter-spacing:.06em;text-transform:uppercase;grid-column:1/-1;margin-top:6px}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<section aria-label="Device">
|
||||
<div class="device">
|
||||
<span class="screw tl"></span><span class="screw tr"></span><span class="screw bl"></span><span class="screw br"></span>
|
||||
<div class="backwall" aria-label="Back wall">
|
||||
<span class="label">back wall</span>
|
||||
<span class="usb"></span><span class="label">USB</span>
|
||||
<span class="bw-spacer"></span>
|
||||
<span class="jack"></span><span class="label">MIDI in</span><span class="jack"></span><span class="label">Kit</span><span class="jack"></span><span class="label">Synth</span>
|
||||
</div>
|
||||
<div class="zones">
|
||||
<div class="zone zl">
|
||||
<div class="dev-head"><span class="dev-name">SEEDBOX</span></div>
|
||||
<div class="oledrow"><div class="oled"><canvas id="oled" width="84" height="48" aria-label="Screen, approximately true size"></canvas></div></div>
|
||||
<div class="zlrow">
|
||||
<div class="ctl"><div class="enc" id="enc" tabindex="0" role="slider" aria-label="Encoder: turn to change page or value, press to edit"><i></i></div><span class="label">Enc · push</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="zone zm">
|
||||
<div class="ctl"><div class="knob" id="kEnergy" tabindex="0" role="slider" aria-label="Energy" aria-valuemin="0" aria-valuemax="127"></div><span class="label">Energy <span class="val" id="vEnergy"></span></span></div>
|
||||
<div class="ctl"><div class="knob" id="kDrift" tabindex="0" role="slider" aria-label="Drift" aria-valuemin="0" aria-valuemax="127"></div><span class="label">Drift <span class="val" id="vDrift"></span></span></div>
|
||||
<div class="ctl"><button class="big" id="big" aria-label="New seed">SEED</button></div>
|
||||
</div>
|
||||
<div class="zone zr"><div class="trk" id="trk" aria-label="Track buttons"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin:8px 0 0">Landscape, laid out like the panel drawing: you sit at the bottom edge, cables leave from the back. The screen is drawn at roughly real size.</p>
|
||||
<p class="hint"><b>Track buttons:</b> tap = on ↔ off. Hold = flip it while held (drop a track out, or punch one in).<br>
|
||||
<b>Screen map / LEDs:</b> filled = on, hollow center = hit, empty = off, blinking = change waiting for the bar line. Map rows match the button rows.<br>
|
||||
<b>Encoder pages:</b> SEED history · GEN · ROOT · SCALE (AUTO = the seed picks) · BPM · SLOT (push loads, hold push saves) · ROLL (re-roll kit or line) · LEARN (push, press a kit track, hit the pad).<br>
|
||||
Keys: <kbd>Space</kbd> seed · <kbd>1</kbd>…<kbd>0</kbd> <kbd>-</kbd> tracks · <kbd>←</kbd><kbd>→</kbd> encoder · <kbd>Enter</kbd> push · <kbd>R</kbd> run</p>
|
||||
</section>
|
||||
|
||||
<section class="main">
|
||||
<div>
|
||||
<h1>Seedbox</h1>
|
||||
<p class="sub">A generative rhythm and melody machine built on tracking: one 32-bit seed makes eleven looped tracks for a sampler or drum machine and a synth, and you shape the piece by switching tracks on and off. Each seed carries a chord progression, a melodic motif, a swing amount and a phrase arc, shared by every track. Energy sets how dense the patterns are; drift sets how often a phrase brings a variation.</p>
|
||||
</div>
|
||||
<div class="statusbar">
|
||||
<span class="pill" id="midiStatus">MIDI: not connected</span>
|
||||
<span class="facts" id="pos"></span>
|
||||
</div>
|
||||
<details open>
|
||||
<summary>Hardware</summary>
|
||||
<div class="form">
|
||||
<div class="field"><label for="hwDisplay">Screen</label><select id="hwDisplay">
|
||||
<option value="5110">Nokia 3310/5110 · 84×48 LCD (≈33×19 mm)</option>
|
||||
<option value="091">0.91" OLED · 128×32 (22×6 mm)</option>
|
||||
<option value="096">0.96" OLED · 128×64 (22×11 mm)</option>
|
||||
<option value="130">1.3" OLED · 128×64 (29×15 mm)</option>
|
||||
<option value="242">2.42" OLED · 128×64 (55×27 mm)</option></select></div>
|
||||
<div class="field"><label class="check"><input type="checkbox" id="hwLeds"> LED above each button</label></div>
|
||||
</div>
|
||||
<p class="note" id="hwNote"></p>
|
||||
<div class="zoomwrap"><canvas id="oledZoom" width="84" height="48" aria-label="Screen, magnified"></canvas></div>
|
||||
<p class="note">Magnified, pixel for pixel. The panel on the left shows it at roughly real size (CSS millimetres, so it depends on your monitor).</p>
|
||||
</details>
|
||||
|
||||
<div class="view">
|
||||
<div class="facts" id="facts"></div>
|
||||
<canvas id="grid" aria-label="Current phrase, four bars"></canvas>
|
||||
<label class="check" style="font-size:12px;color:var(--muted)"><input type="checkbox" id="muteQ"> Track changes wait for the next bar line</label>
|
||||
</div>
|
||||
|
||||
<details open>
|
||||
<summary>Routing</summary>
|
||||
<div class="form">
|
||||
<div class="field"><label for="portDrums">Kit output (sampler / drum machine)</label><select id="portDrums"></select></div>
|
||||
<div class="field"><label for="portMono">Synth output (line)</label><select id="portMono"></select></div>
|
||||
<div class="field"><label for="portIn">Kit input (for pad learn)</label><select id="portIn"></select></div>
|
||||
<div class="field"><label class="check"><input type="checkbox" id="sendClock"> Send MIDI clock + start/stop</label></div>
|
||||
<div class="sect">Learned pads <button class="btn" id="clearLearn" type="button" style="margin-left:8px;padding:3px 8px;font-size:10px">Clear learned</button></div>
|
||||
<div id="padFields" class="padgrid"></div>
|
||||
<div class="sect">Synth line</div>
|
||||
<div class="field"><label for="monoCh">Channel</label><input type="number" id="monoCh" min="1" max="16"></div>
|
||||
<div class="field"><label for="monoOct">Octave shift</label><input type="number" id="monoOct" min="-3" max="3"></div>
|
||||
</div>
|
||||
<p class="note">Bass, melody and pad all go to the synth on one channel, so put it in Poly mode. They sit in separate octaves (bass below C3, pad C3–B3, melody from C4) so their notes never collide.</p>
|
||||
<p class="note">Kit tracks play only after they are learned: encoder to LEARN, push, press a track button, hit the pad on the sampler. Unlearned tracks show a dot on the screen.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Port check (golden vectors)</summary>
|
||||
<p class="note">Exports the exact event list for the current seed and settings. The C port on the Pico must reproduce the same list and checksum.</p>
|
||||
<div class="form" style="align-items:end">
|
||||
<div class="field"><label for="vecBars">Bars</label><input type="number" id="vecBars" min="1" max="64" value="8"></div>
|
||||
<div class="field"><button class="btn" id="vecGo">Generate</button></div>
|
||||
<div class="field"><button class="btn" id="vecCopy">Copy</button></div>
|
||||
</div>
|
||||
<textarea id="vecOut" readonly aria-label="Golden vectors JSON"></textarea>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="engine.js"></script>
|
||||
|
||||
<script src="app.js"></script>
|
||||
Reference in New Issue
Block a user