diff --git a/fedac/native/pieces/spatial-rehearsal.mjs b/fedac/native/pieces/spatial-rehearsal.mjs index 509491abf4..bb2a2f317d 100644 --- a/fedac/native/pieces/spatial-rehearsal.mjs +++ b/fedac/native/pieces/spatial-rehearsal.mjs @@ -9,6 +9,7 @@ let identifyAt = null, glow = 0, flyCursors = []; let beepCount = 0, outputPeak = 0, maxFrameGap = 0, previousTime = null; let networkHalfRttMs = null, runId = null; let presence = null, presenceRaw = '', presenceSeen = -Infinity, presencePoll = -Infinity; +const FRAME_E = new Array(48), FRAME_U = new Float64Array(48); // paint's bounded frame list, allocated once function seatConnection(i, now) { if (!presence || now - presenceSeen > 5) return 'unknown'; @@ -213,46 +214,69 @@ export function paint({ wipe, ink, box, line, circle, write, screen, sound, syst // The view into the space: this laptop's notes come from far away as // frames in the screen's own aspect, growing as they approach, filling // the screen exactly when they sound, then fading with the note. - const LOOK = 3, cx = w * .5, cy = h * .5; - const frames = []; + // + // On a budget. A dense second (Rush E: 726 events, 37 lanes) used to cost + // a seat-gain evaluation per event in the look-ahead, a sort of all of + // them, and boxes, hatch lines and a label for every frame that passed — + // 2,600 line fills in one paint on a software raster. Now: one seat gain + // per LANE per frame (the ring turns slowly; an event's gain a second + // ahead is its lane's gain now), lanes this seat does not carry are never + // scanned, at most NEAR per lane and MAX_FRAMES in all (the nearest win), + // repeated ticks in a lane merge, hatching has a per-paint budget, labels + // only on frames big enough to read and at most LABELS of them. + const LOOK = 3, cx = w * .5, cy = h * .5, MAX_FRAMES = 48, NEAR = 6, LABELS = 16, HATCH_BUDGET = 48, HATCH = 4, MERGE = .08; + let count = 0; for (let i = 0; i < score.lanes.length; i++) { const lane = score.lanes[i], evs = lane.events; let j = flyCursors[i] || 0; while (j < evs.length && evs[j].t + evs[j].dur + .6 < t) j++; flyCursors[i] = j; - for (let k = j; k < evs.length && evs[k].t <= t + LOOK; k++) { + if (j >= evs.length || evs[j].t > t + LOOK) continue; + const gain = sourceGain(score, voicePosition(score, i, t), config.seat, config.seats); + if (gain * gain < .5) continue; + let kept = 0, lastT = -Infinity; + for (let k = j; k < evs.length && kept < NEAR; k++) { const e = evs[k]; - const gain = sourceGain(score, voicePosition(score, i, e.t), config.seat, config.seats); - if (gain * gain < .5) continue; - frames.push({ e, until: e.t - t }); + if (e.t > t + LOOK) break; + if (e.t - lastT < MERGE && e.dur < .12) continue; // a run of ticks reads as one + lastT = e.t; + const until = e.t - t; + if (count < MAX_FRAMES) { FRAME_E[count] = e; FRAME_U[count] = until; count++; } + else { // keep the nearest MAX_FRAMES: replace the farthest if this one is nearer + let far = 0; for (let m = 1; m < MAX_FRAMES; m++) if (FRAME_U[m] > FRAME_U[far]) far = m; + if (until < FRAME_U[far]) { FRAME_E[far] = e; FRAME_U[far] = until; } else break; // this lane only gets farther + } + kept++; } } - frames.sort((p, q) => q.until - p.until); // far first, so near frames draw on top - for (const { e, until } of frames) { - const base = noteColor(e.note) || own, sharp = e.note && e.note.includes('#'); + // far first, so near frames draw on top: insertion sort on the bounded arrays + for (let a = 1; a < count; a++) { const e = FRAME_E[a], u = FRAME_U[a]; let b = a - 1; while (b >= 0 && FRAME_U[b] < u) { FRAME_E[b + 1] = FRAME_E[b]; FRAME_U[b + 1] = FRAME_U[b]; b--; } FRAME_E[b + 1] = e; FRAME_U[b + 1] = u; } + let hatchLeft = HATCH_BUDGET, labels = 0; + for (let n = 0; n < count; n++) { + const e = FRAME_E[n], until = FRAME_U[n]; + const base = noteColor(e.note) || own, sharp = !!(e.note && e.note.includes('#')); const sounding = until <= 0, held = Math.max(.3, e.dur), left = sounding ? Math.max(0, 1 - (-until) / held) : 1; // approaching: grows on a square law; hit: one short blink; then it reverses and recedes over the note const near = sounding ? left : 1 - Math.max(0, Math.min(1, until / LOOK)); - const sc = sounding && -until < .08 ? 1 : .05 + .95 * Math.pow(near, sounding ? 1.6 : 2.2); + const blink = sounding && -until < .08; + const sc = blink ? 1 : .05 + .95 * Math.pow(near, sounding ? 1.6 : 2.2); const fw = Math.round(w * sc), fh = Math.round(h * sc), x0 = Math.round(cx - fw / 2), y0 = Math.round(cy - fh / 2); const bright = sounding ? .35 + .65 * left : .3 + .7 * near; - const col = base.map(v => Math.round(v * bright)), dark = base.map(v => Math.round(v * bright * .45)); - // Hatch on a budget: at most HATCH lines per frame, and only on frames big - // enough to read (the Lift once asked for 2,600 line fills in one frame; a - // laptop's software raster could not keep the rate). The look is the same - // at arm's length; the lines are simply spaced to the frame. - const HATCH = 10; - if (sounding && -until < .08) { // the blink: the whole screen is the note for a moment - ink(...col); box(x0, y0, fw, fh, 'fill'); - ink(...dark); const gap = Math.max(8, Math.ceil(fh / 16)); for (let y = y0 + 4; y < y0 + fh; y += gap) line(x0, y, x0 + fw, y); + const cr = Math.round(base[0] * bright), cg = Math.round(base[1] * bright), cb = Math.round(base[2] * bright); + const dr = Math.round(cr * .45), dg = Math.round(cg * .45), db = Math.round(cb * .45); + if (blink) { // the whole screen is the note for a moment + ink(cr, cg, cb); box(x0, y0, fw, fh, 'fill'); + ink(dr, dg, db); const gap = Math.max(8, Math.ceil(fh / 16)); for (let y = y0 + 4; y < y0 + fh && hatchLeft > 0; y += gap, hatchLeft--) line(x0, y, x0 + fw, y); } else { - if (sc > .3) { ink(...dark); const gap = Math.max(sounding ? 6 : 4, Math.ceil(fh / HATCH)); for (let y = y0 + 3; y < y0 + fh - 1; y += gap) line(x0 + 2, y, x0 + fw - 3, y); } - ink(...(sharp ? [225, 225, 235] : col)); box(x0, y0, fw, fh, 'outline'); - if (sc > .25) { ink(...dark); box(x0 + 2, y0 + 2, fw - 4, fh - 4, 'outline'); } + if (sc > .3 && hatchLeft > 0) { ink(dr, dg, db); const gap = Math.max(sounding ? 6 : 4, Math.ceil(fh / HATCH)); for (let y = y0 + 3; y < y0 + fh - 1 && hatchLeft > 0; y += gap, hatchLeft--) line(x0 + 2, y, x0 + fw - 3, y); } + if (sharp) ink(225, 225, 235); else ink(cr, cg, cb); + box(x0, y0, fw, fh, 'outline'); + if (sc > .25) { ink(dr, dg, db); box(x0 + 2, y0 + 2, fw - 4, fh - 4, 'outline'); } } - if (e.note && sc > .18) { - ink(...(sharp ? [225, 225, 235] : col)); - write(e.note, { x: x0 + 4, y: y0 + 3, font: '6x10', size: sc > .6 ? 3 : sc > .35 ? 2 : 1 }); + if (e.note && sc > .35 && labels < LABELS) { + labels++; + if (sharp) ink(225, 225, 235); else ink(cr, cg, cb); + write(e.note, { x: x0 + 4, y: y0 + 3, font: '6x10', size: sc > .6 ? 3 : 2 }); } } ink(...own); diff --git a/fedac/native/tests/paint-density-bench.mjs b/fedac/native/tests/paint-density-bench.mjs new file mode 100644 index 0000000000..2de8cb8780 --- /dev/null +++ b/fedac/native/tests/paint-density-bench.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// paint-density-bench.mjs — how many draw calls a piece's paint makes per +// frame at a score's densest second, with a counting fake of the native API. +// +// node fedac/native/tests/paint-density-bench.mjs [seat=0] [seconds=t1,t2,…] +// +// Drives boot → prepare → play → sim at the given score seconds and calls +// paint once per second sampled, printing write/box/line/ink/circle counts +// and paint wall time. No audio, no display: sound.synth returns a stub. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +const [pieceArg, scoreArg, seatArg = '0', secondsArg] = process.argv.slice(2); +if (!pieceArg || !scoreArg) { console.error('usage: paint-density-bench.mjs [seat] [t1,t2,…]'); process.exit(2); } +const piece = await import(resolve(pieceArg)); +const scoreBytes = readFileSync(resolve(scoreArg)); +const score = JSON.parse(scoreBytes); +const seat = Number(seatArg), seats = score.seats || 6; +let densest = 0, best = 0; const per = new Map(); +for (const lane of score.lanes || []) for (const e of lane.events || []) { const s = Math.floor(e.t); per.set(s, (per.get(s) || 0) + 1); } +for (const [s, n] of per) if (n > best) { best = n; densest = s; } +const seconds = secondsArg ? secondsArg.split(',').map(Number) : [densest + .3, densest + .5, densest + .8]; +const files = { + '/pieces/spatial-rehearsal-config.json': JSON.stringify({ seat, seats, machineName: 'bench', maxSeconds: score.dur }), + '/pieces/spatial-rehearsal.nsscore': scoreBytes, + '/pieces/trio-fleet-config.json': readFileSync(resolve(scoreArg), 'utf8'), +}; +let now = 100, cmd = null, status = null; +const counts = { write: 0, box: 0, line: 0, ink: 0, circle: 0 }; +const api = { + system: { readFile: (p) => (p.endsWith('command.json') ? JSON.stringify(cmd || {}) : files[p] ?? ''), readFileBytes: (p) => { const v = files[p]; return v instanceof Buffer ? v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength) : Buffer.from(String(v)).buffer; }, writeFile: (p, v) => { if (String(p).includes('status')) { try { status = JSON.parse(v); } catch {} } }, battery: { percent: 80, charging: false, minutesLeft: 200 }, startSSH: () => {}, brightness: 100, brightnessAdjust: () => {}, dmxSend: () => true, pty: { spawn: () => {} } }, + sound: { get time() { return now; }, microphone: { close() {}, hot: false, recording: false }, speaker: { amplitudes: { left: .1, right: .1 } }, synth: () => ({ kill() {}, update() {} }), volume: { setMono() {}, setMonoOutput() {}, setMix() {}, mono: true, monoOutput: 'left', mix: .25 }, deck: { load: () => true, pause() {}, play() {}, seek() {}, setSpeed() {}, setCrossfader() {}, setVolume() {}, setMasterVolume() {}, decks: [{ loaded: true, duration: score.dur, position: 0, playing: false, error: '' }] }, room: { setMix() {} }, drive: { setMix() {} }, wobble: { setMix() {} }, glitch: { setMix() {} } }, + screen: { width: 1366, height: 768 }, wifi: { ip: '192.168.1.0' }, + wipe: () => {}, ink: () => counts.ink++, box: () => counts.box++, line: () => counts.line++, circle: () => counts.circle++, write: () => counts.write++, +}; +piece.boot?.(api); +cmd = { id: 'p1', action: 'prepare', startAt: now + 3, arrangementHash: score.arrangementHash, runId: 'bench' }; now += .02; piece.sim?.(api); +cmd = { id: 'p2', action: 'play', runId: 'bench' }; now += .02; piece.sim?.(api); +const origin = now + 3 - .04; +// through the downbeat on time (the Trio piece calls >100 ms late a missed downbeat), then on +cmd = { id: 'ka-arm', action: 'keepalive', runId: 'bench' }; now = origin - 1.5; piece.sim?.(api); +cmd = { id: 'ka-down', action: 'keepalive', runId: 'bench' }; now = origin - .05; piece.sim?.(api); now = origin + .02; piece.sim?.(api); +let walked = .02, ka = 0; +for (const t of seconds) { + // walk sim to t in 50 ms steps so cursors and voices are where they would be; keep the heartbeat alive + for (let s = walked; s <= t; s += .05) { now = origin + s; if (s - ka > 1) { ka = s; cmd = { id: 'ka' + s.toFixed(2), action: 'keepalive', runId: 'bench' }; } piece.sim?.(api); } + walked = t; now = origin + t; piece.sim?.(api); + for (const k in counts) counts[k] = 0; + const t0 = performance.now(); piece.paint?.(api); const ms = performance.now() - t0; + console.log(`t=${t.toFixed(1)}s phase ${status?.phase ?? '?'}${status?.error ? ' ERROR ' + status.error : ''} write ${counts.write} box ${counts.box} line ${counts.line} ink ${counts.ink} circle ${counts.circle} paint ${ms.toFixed(2)} ms (node, no raster)`); +} diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs b/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs index 68c609dd7d..704d714246 100644 --- a/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs @@ -110,62 +110,86 @@ const NOTE_RGB={c:[255,50,50],d:[255,160,0],e:[255,230,0],f:[50,200,50],g:[50,12 const NAMES=['c','c#','d','d#','e','f','f#','g','g#','a','a#','b']; function noteRgb(midi){const n=NAMES[((midi%12)+12)%12];return n.includes('#')?[235,235,235]:NOTE_RGB[n];} const KIND={kick:{lane:0,rgb:[255,90,60]},boom:{lane:0,rgb:[200,40,40]},snare:{lane:1,rgb:[245,245,245]},donk:{lane:1,rgb:[255,170,40]},hat:{lane:2,rgb:[170,170,170]},perc:{lane:1,rgb:[220,220,220]},riser:{lane:3,rgb:[120,200,255]},voice:{lane:3,rgb:[255,120,200]}}; -let noteCursor=0; +let noteCursor=0,lyricCursor=0,routeCursor=0,lastPaintT=-1; +// Per-frame budgets: the roll draws at most NOTES marks (this seat's own +// always, the room's until OTHERS), LABELS note names, and merges a run of +// ticks in one lane closer than MERGE seconds. No arrays are made per frame. +const NOTES=72,OTHERS=40,LABELS=16,MERGE=.06; +const laneLastT=[-9,-9,-9,-9]; +function inkRgb(ink,r,g,b,k){ink(Math.round(r*k),Math.round(g*k),Math.round(b*k));} export function paint({sound,wipe,ink,box,line,write,screen}){ const w=screen.width,h=screen.height,t=origin===null?-1:sound.time-origin,active=phase==='playing'; - const color=cfg?.color||[143,209,63];wipe(10,12,20); - if(error){ink(255,140,130);write(error,{x:8,y:h-24,font:FONT,size:1});} + // The screen IS the seat's colour: full strength while a piece is prepared + // or playing, a darker shade at rest so the room still reads as six colours. + // Everything else is drawn in an ink chosen against that colour's luminance. + const color=cfg?.color||[143,209,63],lit=['prepared','countdown','playing'].includes(phase)?1:.35; + wipe(Math.round(color[0]*lit),Math.round(color[1]*lit),Math.round(color[2]*lit)); + const lum=(.2126*color[0]+.7152*color[1]+.0722*color[2])*lit,dark=lum>110; + const ir=dark?16:248,ig=dark?16:248,ib=dark?24:250; // the ink + const tint=k=>dark?[Math.round(color[0]*lit/k),Math.round(color[1]*lit/k),Math.round(color[2]*lit/k)]:[Math.round(255-(255-color[0]*lit)/k),Math.round(255-(255-color[1]*lit)/k),Math.round(255-(255-color[2]*lit)/k)]; // darker on a bright ground, lighter on a deep one + if(error){ink(ir,ig,ib);write(error,{x:8,y:h-24,font:FONT,size:1});} if(!cfg)return; - ink(...color);write((cfg.label||cfg.receiverId||'').toLowerCase(),{x:10,y:10,font:FONT,size:2}); - if(phase==='countdown'){const left=Math.max(0,origin-sound.time);ink(240,240,240);centered(write,String(Math.ceil(left)),Math.round(h/2-40),8,w);ink(150,150,150);centered(write,(cfg.title||'').toLowerCase(),Math.round(h/2+50),2,w);return;} - if(phase==='prepared'){ink(150,150,150);centered(write,'ready',Math.round(h/2-10),3,w);return;} - if(!active&&phase!=='finished'){ink(110,110,110);centered(write,phase,Math.round(h/2-10),2,w);return;} + // seat name, top left + ink(ir,ig,ib);write((cfg.label||cfg.receiverId||'').toLowerCase(),{x:10,y:10,font:FONT,size:2}); + if(phase==='countdown'){const left=Math.max(0,origin-sound.time);centered(write,String(Math.ceil(left)),Math.round(h/2-40),8,w);inkRgb(ink,ir,ig,ib,.75);centered(write,(cfg.title||'').toLowerCase(),Math.round(h/2+50),2,w);return;} + if(phase==='prepared'){inkRgb(ink,ir,ig,ib,.75);centered(write,'ready',Math.round(h/2-10),3,w);return;} + if(!active&&phase!=='finished'){inkRgb(ink,ir,ig,ib,.6);centered(write,phase,Math.round(h/2-10),2,w);return;} const beat=60/cfg.bpm,bpb=cfg.beatsPerBar||4,bar=Math.floor(t/beat/bpb)+1,beatIn=Math.floor(t/beat)%bpb+1; - // the beat lamp and the bar count, top right - const pulse=Math.max(0,1-((t/beat)%1)*1.6);ink(...color.map(v=>Math.round(v*(.25+.75*pulse))));box(w-34,10,22,22); - ink(160,160,160);const bc=`${bar}.${beatIn}`;write(bc,{x:w-44-textWidth(bc,2),y:14,font:FONT,size:2}); - // sections along the top: a thin strip, the current one lit, its name + // the beat lamp (a flash of ink) and the bar count, top right + const pulse=Math.max(0,1-((t/beat)%1)*1.6);const tp=tint(1.35);ink(Math.round(tp[0]+(ir-tp[0])*pulse),Math.round(tp[1]+(ig-tp[1])*pulse),Math.round(tp[2]+(ib-tp[2])*pulse));box(w-34,10,22,22); + inkRgb(ink,ir,ig,ib,.8);const bc=`${bar}.${beatIn}`;write(bc,{x:w-44-textWidth(bc,2),y:14,font:FONT,size:2}); + // sections along the top: a thin strip, the current one in ink, its name const secs=cfg.sections||[]; - if(secs.length){const total=cfg.duration;let cur=null; + if(secs.length){const total=cfg.duration;let cur=null;const td=tint(1.35); for(const s of secs){const x0=Math.round(s.startSec/total*(w-20))+10,x1=Math.round(s.endSec/total*(w-20))+10,on=t>=s.startSec&&tMath.round(v*(on?.9:.25))));if(on)box(x0,40,Math.max(2,x1-x0-2),6);else box(x0,40,Math.max(2,x1-x0-2),6,'outline');} - if(cur){ink(...color);write(cur.name,{x:10,y:52,font:FONT,size:2});const nxt=secs[secs.indexOf(cur)+1];if(nxt){const left=Math.ceil((cur.endSec-t)/beat/bpb);ink(90,95,110);write(`${nxt.name} in ${left}`,{x:10+textWidth(cur.name,2)+14,y:56,font:FONT,size:1});}} + if(on){ink(ir,ig,ib);box(x0,40,Math.max(2,x1-x0-2),6);}else{ink(td[0],td[1],td[2]);box(x0,40,Math.max(2,x1-x0-2),6,'outline');}} + if(cur){ink(ir,ig,ib);write(cur.name,{x:10,y:52,font:FONT,size:2});const nxt=secs[secs.indexOf(cur)+1];if(nxt){const left=Math.ceil((cur.endSec-t)/beat/bpb);inkRgb(ink,ir,ig,ib,.6);write(`${nxt.name} in ${left}`,{x:10+textWidth(cur.name,2)+14,y:56,font:FONT,size:1});}} } - // Trio: the lyric being sung and the phrase this seat carries - const lyrics=cfg.lyrics||[];const curL=lyrics.filter(l=>t>=l.t-.3&&tt>=r.t&&tb.gain-a.gain)[0],size=fitSize(r.text,w*.6,4),tw=textWidth(r.text,size),bw=tw+30,bh=size*10+30,bx=Math.round((w-bw)/2),by=Math.round(h*.24); - const fade=Math.min(1,(t-r.t)/.15,(r.t+r.dur-t)/.4);ink(...r.rgb.map(v=>Math.round(v*(r.role==='lead'?.35:.18)*fade)));box(bx,by,bw,bh);ink(...r.rgb.map(v=>Math.round(v*fade)));box(bx,by,bw,bh,'outline');write(r.text,{x:bx+15,y:by+20,font:FONT,size}); - const tag=r.role==='lead'?'LEAD':'ECHO';write(tag,{x:bx+15,y:by+5,font:FONT,size:1});} + // Trio: the lyric being sung and the phrase this seat carries. Cursors, + // not scans: lyrics and routes are sorted by time; a new run (t went back) + // rewinds them. + if(t=lyrics[lyricCursor].t+lyrics[lyricCursor].dur+.8)lyricCursor++; + let curL=null;for(let i=lyricCursor;i=routes[routeCursor].t+routes[routeCursor].dur)routeCursor++; + let r=null;for(let i=routeCursor;ir.gain))r=x;} + if(r){ + const size=fitSize(r.text,w*.6,4),tw=textWidth(r.text,size),bw=tw+30,bh=size*10+30,bx=Math.round((w-bw)/2),by=Math.round(h*.24); + const fade=Math.min(1,(t-r.t)/.15,(r.t+r.dur-t)/.4),tb=tint(r.role==='lead'?1.5:1.25);ink(tb[0],tb[1],tb[2]);box(bx,by,bw,bh);ink(ir,ig,ib);box(bx,by,bw,bh,'outline');inkRgb(ink,ir,ig,ib,.4+.6*fade);write(r.text,{x:bx+15,y:by+20,font:FONT,size}); + ink(r.rgb[0],r.rgb[1],r.rgb[2]);box(bx,by+bh-5,bw,5);ink(ir,ig,ib);write(r.role==='lead'?'LEAD':'ECHO',{x:bx+15,y:by+5,font:FONT,size:1});} // the roll const notes=cfg.notes||[];if(!notes.length)return; const ahead=6,behind=1.5,head=Math.round(w/3),px=w/(ahead+behind),top=Math.round(h*.36),bottom=Math.round(h*.78),laneY=[bottom+14,bottom+32,bottom+46,bottom+60]; const lo=cfg.midiLow||36,hi=cfg.midiHigh||108,yOf=m=>Math.round(bottom-(Math.max(lo,Math.min(hi,m))-lo)/(hi-lo)*(bottom-top)); - ink(34,38,52);line(0,bottom,w,bottom);for(const y of laneY)line(0,y,w,y); - ink(...color.map(v=>Math.round(v*(.5+.5*pulse))));line(head,top-10,head,laneY[3]+8); - while(noteCursor0&¬es[noteCursor-1].t>t-behind-2)noteCursor=0; // a seek backwards (a new run) resets the cursor - for(let k=noteCursor;kahead)break;if(dt<-behind)continue; + inkRgb(ink,ir,ig,ib,.35);line(0,bottom,w,bottom);for(const y of laneY)line(0,y,w,y); + inkRgb(ink,ir,ig,ib,.5+.5*pulse);line(head,top-10,head,laneY[3]+8); + while(noteCursorahead)break; + const kind=KIND[n.i]; + if(!n.mine&&others>=OTHERS)continue; + if(kind&&n.i!=='riser'&&n.i!=='voice'){if(n.t-laneLastT[kind.lane]=n.t&&t=0&&just<.18?1-just/.18:0; - const kind=KIND[n.i],dimF=n.mine?1:.32,rgb=n.rgb||(kind?kind.rgb:noteRgb(n.midi||60)); + const dimF=n.mine?1:.32,rgb=n.rgb||(kind?kind.rgb:noteRgb(n.midi||60)); if(kind&&n.i!=='riser'&&n.i!=='voice'){ // percussion: a mark in its lane, bigger for louder - const y=laneY[kind.lane],sz=Math.max(3,Math.round(4+(n.gain||.1)*24)),c=rgb.map(v=>Math.round(v*dimF*(on?1:.6))); - ink(...c);if(n.i==='hat')box(x-1,y-2,3,3);else if(n.i==='donk'){box(x-sz/2,y-sz/2,sz,sz,'outline');}else box(x-Math.round(sz/2),y-Math.round(sz/2),sz,sz); - if(hit&&n.mine){ink(...rgb);box(x-sz,y-sz,sz*2,sz*2,'outline');} + const y=laneY[kind.lane],sz=Math.max(3,Math.round(4+(n.gain||.1)*24)); + inkRgb(ink,rgb[0],rgb[1],rgb[2],dimF*(on?1:.6));if(n.i==='hat'){ink(ir,ig,ib);box(x-1,y-2,3,3);}else if(n.i==='donk'){box(x-sz/2,y-sz/2,sz,sz,'outline');}else{box(x-Math.round(sz/2),y-Math.round(sz/2),sz,sz);if(n.mine){ink(ir,ig,ib);box(x-Math.round(sz/2),y-Math.round(sz/2),sz,sz,'outline');}} + if(hit&&n.mine){ink(rgb[0],rgb[1],rgb[2]);box(x-sz,y-sz,sz*2,sz*2,'outline');} continue; } - if(n.i==='riser'||n.i==='voice'){const y=laneY[3],c=rgb.map(v=>Math.round(v*dimF));ink(...c);box(x,y-4,len,8,'outline');if(n.text&&xMath.round(v*dimF*(dt<0&&!on?.5:1))); - ink(...c); - if(n.i==='rbell'){const steps=6;for(let i=0;iMath.round(v*dimF)));box(x,y-3,len,6);} - else box(x,y-Math.round(hh/2),len,hh); - if(hit&&n.mine){ink(...rgb);box(x-4,y-Math.round(hh/2)-4,len+8,hh+8,'outline');} - if(n.mine&&n.label&&dt>0&&dt<1.2&&n.i!=='sub'&&n.i!=='throat'){ink(...rgb.map(v=>Math.round(v*.8)));write(n.label,{x,y:y-Math.round(hh/2)-11,font:FONT,size:1});} + const y=yOf(n.midi||60),hh=Math.max(4,Math.round(4+(n.gain||.05)*90)); + inkRgb(ink,rgb[0],rgb[1],rgb[2],dimF*(dt<0&&!on?.5:1)); + if(n.i==='rbell'){const steps=4;for(let i=0;i0&&dt<1.2&&labels