From 1f6d73760f40beceab84df9edfcf579162b31e39 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 16:48:37 -0700 Subject: [PATCH] Native painting on a budget: the ring piece and the Trio roll under dense scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spatial-rehearsal.mjs used to evaluate a seat gain for every event in its three-second look-ahead each frame, sort them all, and draw boxes, hatch lines and a label for each frame that passed — at Rush E's densest second, 2,616 line fills and 768 boxes in one paint. Now one seat gain per lane per frame, lanes this seat does not carry are never scanned, at most six frames per lane and 48 in all (the nearest), runs of ticks merge, hatching has a per-paint budget of 48 lines, labels only on readable frames and at most 16: 59 boxes, 48 lines, 25 labels at that same second. The Trio piece's roll keeps cursors into lyrics, routes and notes instead of filtering and sorting per frame, caps drawn marks at 72 (this seat's own always, the room's up to 40), merges ticks in a lane, labels at most 16, and allocates nothing per frame: Femrag's feed drops from 279 to 81 boxes at its densest second. The laptop screens are now filled with the seat's colour (full while prepared or playing, a darker shade at rest), everything drawn in an ink chosen against that colour's luminance, notes in their own colours with an ink edge, the beat lamp a flash of ink; the outline rings are gone. fedac/native/tests/paint-density-bench.mjs measures draw calls per frame at a score's densest second with a counting fake of the native API. Co-Authored-By: Claude Fable 5.1 --- fedac/native/pieces/spatial-rehearsal.mjs | 74 ++++++++----- fedac/native/tests/paint-density-bench.mjs | 50 +++++++++ .../macneopolitan/fleet/native-trio.mjs | 102 +++++++++++------- 3 files changed, 162 insertions(+), 64 deletions(-) create mode 100644 fedac/native/tests/paint-density-bench.mjs 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