Monorepo for Aesthetic.Computer aesthetic.computer
at main 1041 lines 44 kB view raw
1#!/usr/bin/env node 2/** 3 * Ableton Timeline Simulation (ncurses-style) 4 * 5 * Uses pre-extracted notes + timeline from analyze-ableton.mjs output to run a 6 * silent clock and display progress, remaining notes, and simple track meters. 7 * 8 * Inputs (flags): 9 * --project-xml <path> (optional, for future: direct parse) NOT USED yet 10 * --notes <notes.json> (required unless --auto) 11 * --report <report.json> (required unless --auto) 12 * --auto (infer notes.json & report.json in current dir) 13 * --rate <multiplier> playback speed (default 1.0) 14 * --fps <number> UI refresh frames per second (default 20) 15 * --length <beats> override total beats (else infer from last note or timeline) 16 * --filter-pitch <num> only count/render notes of this pitch (others ignored in meters) 17 * --filter-pitches a,b comma list alternative to select multiple pitches 18 * --aggregate-window <b> rolling window (beats) for density bars (default 4) 19 * --show-stream display a scrolling recent note stream (pitch-coded) 20 * --stream-width <n> width of note stream (default 60) 21 * --hat-pitches a,b define a set of "hi-hat" (or target) pitches to aggregate 22 * --density-full <n> notes/beat for full (red) bar (default 8) 23 * --adaptive-density dynamically rescale density-full when exceeded 24 * --snapshot-csv <file> write periodic aggregate snapshots to CSV 25 * --snapshot-interval-beats <b> beats interval between CSV rows (default 4) 26 * --sparklines append ASCII mini density sparkline per track 27 * --spark-width <n> width (frames) of sparkline history (default 30) 28 * --bpm <n> override/global tempo (ignores parsed tempo map) 29 * --sandcastle accumulate per-column note bursts over song forming a bottom 'sand' visualization 30 * --sand-height <rows> vertical rows for sandcastle (default 16) 31 * --burst-line append a scrolling bottom line mark for every track burst (each asterisk) 32 * --minimal-line ultra-minimal mode: print legend once then a single continuous line of glyphs as notes fire 33 * --minimal-pitch (with --minimal-line) emit pitch-class letters instead of track dots when not matched by group/hat 34 * --groups-grid show one scrolling lane per pitch group (or hat / top tracks if no groups) with time on X 35 * --start <beat> start simulation from this beat (default 0) 36 * --end <beat> end simulation early (default inferred length) 37 * --help 38 * 39 * Display: 40 * Top line: Beat mm:ss.beat | Progress bar | Notes played / total (pct) | ETA 41 * Next lines: Per-track meter (# symbols for notes this frame) limited to width. 42 * Locator line updates when passing locator times. 43 * 44 * Press 'q' to quit early. 45 * 46 * Run example (after analyze step): 47 * node simulate-ableton.mjs --auto --rate 2 48 */ 49 50import fs from 'fs'; 51import readline from 'readline'; 52import process from 'process'; 53import { SaxesParser } from 'saxes'; 54 55/* Minimal color helpers (avoid extra deps) */ 56const color = { 57 dim: s => `\x1b[2m${s}\x1b[0m`, 58 cyan: s => `\x1b[36m${s}\x1b[0m`, 59 green: s => `\x1b[32m${s}\x1b[0m`, 60 yellow: s => `\x1b[33m${s}\x1b[0m`, 61 magenta: s => `\x1b[35m${s}\x1b[0m`, 62 bold: s => `\x1b[1m${s}\x1b[0m`, 63 red: s => `\x1b[31m${s}\x1b[0m` 64}; 65 66const args = process.argv.slice(2); 67function getFlag(name, def = undefined) { 68 const i = args.indexOf(`--${name}`); 69 if (i !== -1) return args[i+1]; 70 return def; 71} 72const has = name => args.includes(`--${name}`); 73 74if (has('help')) { 75 console.log(`Ableton Timeline Simulation\n\nFlags:\n --auto\n --notes notes.json\n --report report.json\n --project-xml <file> (parse raw Ableton extracted XML directly)\n --rate <multiplier>\n --fps <n>\n --length <beats>\n --filter-pitch <num>\n --filter-pitches a,b\n --aggregate-window <beats>\n --show-stream --stream-width <n>\n --hat-pitches a,b\n --density-full <n> --adaptive-density\n --snapshot-csv <file> --snapshot-interval-beats <b>\n --sparklines --spark-width <n>\n --bpm <n>\n --start <beat> --end <beat>\n --help\n`); 76 process.exit(0); 77} 78 79const auto = has('auto'); 80const projectXmlPath = getFlag('project-xml'); 81const notesPath = getFlag('notes', auto ? './notes.json' : null); 82const reportPath = getFlag('report', auto ? './report.json' : null); 83if (!projectXmlPath && (!notesPath || !reportPath)) { 84 console.error('Need either --project-xml <file> or both --notes and --report (or --auto).'); 85 process.exit(1); 86} 87 88function loadJSON(path) { 89 return JSON.parse(fs.readFileSync(path, 'utf8')); 90} 91 92let notes = []; 93let report = {}; 94 95async function parseProjectXML(file) { 96 const parser = new SaxesParser({ xmlns:false }); 97 const stack = []; 98 const trackSummaries = []; 99 const trackStack = []; 100 const clips = []; 101 let currentClip = null; 102 const locators = []; 103 const tempoChanges = []; 104 let currentTrack = null; 105 let currentText = ''; 106 const timeLike = new Set(['CurrentStart','CurrentEnd','LoopStart','LoopEnd','Start','End','StartRelative','StartMarker','EndMarker']); 107 108 parser.on('opentag', node => { 109 const name = node.name; 110 const attrs = Object.fromEntries(Object.entries(node.attributes).map(([k,v])=>[k, v.value ?? v])); 111 stack.push(name); 112 // Track detection 113 if (/(MidiTrack|AudioTrack|GroupTrack|ReturnTrack|MainTrack|PreHearTrack)$/.test(name)) { 114 const id = attrs.Id || attrs.ID || attrs.id; 115 currentTrack = { id, type: name, name: null, clipCount:0, clips:[], devices:[] }; 116 trackSummaries.push(currentTrack); 117 trackStack.push(currentTrack); 118 } 119 // Clip detection 120 if (/Clip$/.test(name) && name !== 'ClipSlot') { 121 currentClip = { type:name, trackId: currentTrack?.id, times:{}, notes:[], name: attrs.Name || '' }; 122 clips.push(currentClip); 123 if (currentTrack) currentTrack.clipCount++; 124 } 125 // Note events 126 if (name === 'MidiNoteEvent' && currentClip) { 127 const rel = parseFloat(attrs.Time || attrs.time || '0') || 0; 128 const dur = parseFloat(attrs.Duration || attrs.duration || '0') || 0; 129 const vel = parseFloat(attrs.Velocity || attrs.velocity || '0') || 0; 130 const pitch = attrs.Pitch || attrs.Note || attrs.NoteNumber || attrs.pitch; 131 currentClip.notes.push({ relTime: rel, duration: dur, velocity: vel, pitch: pitch!=null?Number(pitch):undefined }); 132 } 133 // Locators 134 if (name === 'Locator') { 135 const time = parseFloat(attrs.Time || attrs.time || attrs.Start || '0') || 0; 136 locators.push({ id: attrs.Id || attrs.id, time, name: attrs.Name || attrs.name || '' }); 137 } 138 // Tempo (rough heuristic) 139 if (/Tempo/i.test(name) && attrs.Value) { 140 const val = parseFloat(attrs.Value); 141 if (!Number.isNaN(val)) tempoChanges.push({ beat: 0, value: val }); 142 } 143 // Time fields inside clips 144 if (currentClip && timeLike.has(name) && (attrs.Value!=null)) { 145 const v = parseFloat(attrs.Value); 146 if (!Number.isNaN(v)) currentClip.times[name] = v; 147 } 148 }); 149 parser.on('closetag', name => { 150 // capture accumulated text for names 151 const text = currentText.trim(); 152 currentText = ''; 153 if (text) { 154 const parent = stack[stack.length-1]; 155 if (currentTrack && /^(EffectiveName|UserName)$/.test(name) && !currentTrack.name) currentTrack.name = text; 156 if (currentClip && /^(EffectiveName|UserName)$/.test(name) && !currentClip.name) currentClip.name = text; 157 } 158 stack.pop(); 159 if (/Clip$/.test(name) && currentClip && currentClip.type === name) { 160 currentClip = null; 161 } 162 if (/(MidiTrack|AudioTrack|GroupTrack|ReturnTrack|MainTrack|PreHearTrack)$/.test(name)) { 163 trackStack.pop(); 164 currentTrack = trackStack[trackStack.length-1] || null; 165 } 166 }); 167 parser.on('text', t => { currentText += t; }); 168 await new Promise((res,rej)=>{ 169 parser.on('error', rej); 170 parser.on('end', res); 171 const stream = fs.createReadStream(file, { encoding:'utf8' }); 172 stream.on('data', chunk => parser.write(chunk)); 173 stream.on('end', ()=>parser.close()); 174 stream.on('error', rej); 175 }); 176 // Build notes absolute times 177 const flatNotes = []; 178 for (const clip of clips) { 179 const start = clip.times.CurrentStart ?? clip.times.Start ?? 0; 180 for (const n of clip.notes) { 181 flatNotes.push({ beat: start + n.relTime, relBeat: n.relTime, duration: n.duration, velocity: n.velocity, pitch: n.pitch, trackId: clip.trackId, clipName: clip.name }); 182 } 183 } 184 flatNotes.sort((a,b)=>a.beat-b.beat); 185 // Timeline (simple) 186 const timeline = clips.map((c,i)=>({ index:i, trackId:c.trackId, name:c.name, times:c.times, noteCount:c.notes.length })); 187 return { notes: flatNotes, report: { trackSummaries, timeline, tempoChanges, locators } }; 188} 189 190if (projectXmlPath) { 191 const t0 = Date.now(); 192 const parsed = await parseProjectXML(projectXmlPath); 193 notes = parsed.notes; 194 report = parsed.report; 195 console.error(`[simulate] Parsed XML ${projectXmlPath} -> notes=${notes.length} tracks=${report.trackSummaries.length} clips=${report.timeline.length} in ${Date.now()-t0}ms`); 196} else { 197 notes = loadJSON(notesPath); 198 report = loadJSON(reportPath); 199} 200 201// Pitch filtering (single or multi) 202const pitchFilterSingle = getFlag('filter-pitch'); 203const pitchFilterMulti = getFlag('filter-pitches'); 204let pitchFilterSet = null; 205if (pitchFilterMulti) { 206 pitchFilterSet = new Set(pitchFilterMulti.split(',').map(s=>s.trim()).filter(Boolean)); 207} else if (pitchFilterSingle) { 208 pitchFilterSet = new Set([String(pitchFilterSingle)]); 209} 210// Preserve original unfiltered note list for omni-line output (so filters don't hide events) 211const allNotesUnfiltered = [...notes]; 212if (pitchFilterSet && !has('omni-line')) { 213 notes = notes.filter(n => pitchFilterSet.has(String(n.pitch))); 214} 215 216// Hat / target pitch set (for aggregation stats only) 217const hatPitchFlag = getFlag('hat-pitches',''); 218const hatPitchSet = new Set(hatPitchFlag.split(',').map(s=>s.trim()).filter(Boolean)); 219 220// Determine length (beats) 221let inferredLength = 0; 222if (notes.length) inferredLength = Math.max(inferredLength, notes[notes.length - 1].beat); 223if (report.timeline && report.timeline.length) { 224 for (const c of report.timeline) { 225 if (c.times && c.times.CurrentEnd != null) inferredLength = Math.max(inferredLength, c.times.CurrentEnd); 226 } 227} 228const overrideLength = getFlag('length'); 229const totalBeats = overrideLength ? parseFloat(overrideLength) : inferredLength; 230 231const startBeat = parseFloat(getFlag('start', '0')); 232const endBeat = parseFloat(getFlag('end', totalBeats)); 233 234// Playback config 235const rate = parseFloat(getFlag('rate', '1')); 236const fps = parseFloat(getFlag('fps', '20')); 237const aggWindow = parseFloat(getFlag('aggregate-window','4')); // beats 238const showStream = has('show-stream'); 239const streamWidth = parseInt(getFlag('stream-width','60')); 240const useSparklines = has('sparklines'); 241const sparkWidth = parseInt(getFlag('spark-width','30')); 242const useSandcastle = has('sandcastle'); 243const sandHeight = parseInt(getFlag('sand-height','16')); 244const useBurstLine = has('burst-line'); 245const useMinimalLine = has('minimal-line'); 246const useMinimalPitch = has('minimal-pitch'); 247const noMinimalLegend = has('no-minimal-legend'); // suppress legend header in minimal mode 248const useOmniLine = has('omni-line'); // new: stream ALL timeline events (notes, locators, clip boundaries, tempo changes) 249const noBeatMarks = has('no-beat-marks'); // suppress beat markers in minimal/omni modes 250if (useMinimalLine && useOmniLine) { 251 console.error('[simulate] Warning: --omni-line overrides --minimal-line'); 252} 253const useGroupsGrid = has('groups-grid'); 254const groupsSpecRaw = getFlag('groups'); // format: Name:p1,p2,p3:Glyph;Name2:p4:Glyph2 255const gridCsvPath = getFlag('grid-csv'); // optional: export per-beat group counts 256const topPitchesFlag = getFlag('top-pitches'); // optional: list top N pitches then continue 257const showAllTracks = has('show-all-tracks'); // new: do not hide inactive tracks during run 258const trackLimitFlag = parseInt(getFlag('track-limit', '20')); // configurable max before clipping (ignored if show-all) 259const fullView = has('full-view'); // enable most visual layers at once 260const condenseTracks = has('condense-tracks'); // multi-column compact track display 261const windowPitchStatsN = parseInt(getFlag('window-pitch-stats','0')); // top N pitch counts in current aggregate window (0=off) 262 263// Full view implies enabling key layers unless user already supplied specific flags 264if (fullView) { 265 if (!useGroupsGrid) args.push('--groups-grid'); 266 if (!useSandcastle) args.push('--sandcastle'); 267 if (!useBurstLine) args.push('--burst-line'); 268 if (!showStream) args.push('--show-stream'); 269} 270 271// Parse groups 272let pitchGroups = []; 273if (groupsSpecRaw) { 274 const palette = ['\x1b[32m','\x1b[33m','\x1b[31m','\x1b[36m','\x1b[35m','\x1b[34m','\x1b[92m','\x1b[93m']; 275 let colorIdx = 0; 276 for (const seg of groupsSpecRaw.split(/;+/)) { 277 const trimmed = seg.trim(); if (!trimmed) continue; 278 const parts = trimmed.split(':'); 279 if (parts.length < 3) continue; 280 const name = parts[0]; 281 const pitches = parts[1].split(',').map(s=>s.trim()).filter(Boolean); 282 const glyph = parts.slice(2).join(':').trim()[0]; // first char of remaining as glyph 283 if (!glyph) continue; 284 const set = new Set(pitches); 285 const colorCode = palette[colorIdx++ % palette.length]; 286 pitchGroups.push({ name, set, glyph, colorCode }); 287 } 288} 289 290// Warn if defined pitch groups don't match any present pitches (common cause of empty grid lanes) 291if (pitchGroups.length) { 292 const presentPitches = new Set(notes.map(n => String(n.pitch))); 293 const inactive = pitchGroups.filter(g => ![...g.set].some(p => presentPitches.has(p))); 294 if (inactive.length === pitchGroups.length) { 295 console.error('[groups] Warning: none of the defined pitch groups match any note pitches.'); 296 if (presentPitches.size) { 297 console.error('[groups] Example available pitches:', [...presentPitches].slice(0,12).join(',')); 298 } 299 } else if (inactive.length) { 300 console.error('[groups] Inactive groups (no matching pitches):', inactive.map(g=>g.name).join(', ')); 301 } 302} 303 304// Optional: report top pitches 305if (topPitchesFlag) { 306 const N = parseInt(topPitchesFlag)||10; 307 const freq = new Map(); 308 for (const n of notes) freq.set(n.pitch, (freq.get(n.pitch)||0)+1); 309 const sorted = [...freq.entries()].sort((a,b)=>b[1]-a[1]).slice(0,N); 310 console.error(`[top-pitches] Top ${sorted.length} pitches:`); 311 console.error(sorted.map(([p,c])=>`${p}:${c}`).join(' ')); 312} 313 314// Assume base tempo constant for mm:ss (use first tempo change if present) 315let bpm = 120; // default fallback 316const bpmOverride = getFlag('bpm'); 317if (bpmOverride) { 318 const parsed = parseFloat(bpmOverride); 319 if (!Number.isNaN(parsed)) bpm = parsed; 320} else if (report.tempoChanges && report.tempoChanges.length) { 321 const first = report.tempoChanges[0]; 322 if (first && (first.value || first.Value)) bpm = parseFloat(first.value || first.Value) || bpm; 323} 324// Density scaling flags 325let densityFull = parseFloat(getFlag('density-full','8')); 326const adaptiveDensity = has('adaptive-density'); 327 328// Snapshot CSV 329const snapshotCsvPath = getFlag('snapshot-csv'); 330const snapshotIntervalBeats = parseFloat(getFlag('snapshot-interval-beats','4')); 331let nextSnapshotBeat = snapshotIntervalBeats; 332let csvStream = null; 333if (snapshotCsvPath) { 334 csvStream = fs.createWriteStream(snapshotCsvPath, { flags: 'w' }); 335} 336 337// Tempo map (piecewise bpm) 338let tempoMap = []; 339if (report.tempoChanges && report.tempoChanges.length) { 340 for (const tc of report.tempoChanges) { 341 const beat = tc.beatTime ?? tc.beat ?? tc.time; 342 const bpmVal = tc.value ?? tc.Value; 343 if (beat != null && bpmVal != null) tempoMap.push({ beat: Number(beat), bpm: Number(bpmVal) }); 344 } 345 tempoMap.sort((a,b)=>a.beat-b.beat); 346} 347if (tempoMap.length === 0 || bpmOverride) tempoMap = [{ beat: 0, bpm }]; 348function bpmAtBeat(b) { 349 let last = tempoMap[0]; 350 for (const seg of tempoMap) { if (seg.beat <= b) last = seg; else break; } 351 return last.bpm; 352} 353function beatToSeconds(b) { 354 let seconds = 0; 355 for (let i=0;i<tempoMap.length;i++) { 356 const cur = tempoMap[i]; 357 const next = tempoMap[i+1]; 358 const segStart = cur.beat; 359 const segEnd = next ? Math.min(next.beat, b) : b; 360 if (b <= segStart) break; 361 const span = Math.max(0, segEnd - segStart); 362 seconds += span * (60 / cur.bpm); 363 if (!next || next.beat >= b) break; 364 } 365 return seconds; 366} 367 368// Index notes by beat for faster simulation scanning 369let noteIndex = 0; // global moving pointer (sorted by beat already) 370 371// Track mapping 372const trackMap = new Map(); 373if (report.trackSummaries) { 374 for (const t of report.trackSummaries) trackMap.set(String(t.id), t); 375} 376 377// Locator awareness 378const locators = (report.locators || []).slice().sort((a,b)=>a.time-b.time); 379let nextLocatorIdx = locators.findIndex(l => l.time >= startBeat); 380if (nextLocatorIdx === -1) nextLocatorIdx = locators.length; // none ahead 381 382// Stats 383const totalNotes = notes.length; 384let playedNotes = 0; 385let playedHats = 0; 386const trackTotals = new Map(); 387const trackHatTotals = new Map(); 388const trackHistory = new Map(); // trackId -> array of densityRate snapshots (notes/beat) 389 390// Rolling window store (simple queue) 391const windowEvents = []; // each {beat, trackId, pitch} 392 393// Recent stream characters 394const recentEvents = []; 395 396// Sandcastle accumulation: per song-progress column heights (capped at sandHeight) 397let sandCols = []; 398let lastSandWidth = 0; 399const burstMarks = []; // sequence of marks per track burst 400 401// Minimal line mode state 402let minimalLegendPrinted = false; 403let lastBeatFloor = 0; 404let omniEvents = []; 405let omniIndex = 0; 406let lastOmniBeatFloor = 0; 407if (useOmniLine) { 408 // Notes (all, unfiltered) 409 for (const n of allNotesUnfiltered) omniEvents.push({ beat: n.beat, kind: 'note', data: n }); 410 // Locators 411 for (const l of (report.locators||[])) if (l.time!=null) omniEvents.push({ beat: l.time, kind: 'locator', data: l }); 412 // Clip boundaries 413 for (const c of (report.timeline||[])) { 414 const start = c.times?.CurrentStart ?? c.times?.Start; 415 const end = c.times?.CurrentEnd ?? c.times?.End; 416 if (start!=null) omniEvents.push({ beat: start, kind: 'clipStart', data: c }); 417 if (end!=null) omniEvents.push({ beat: end, kind: 'clipEnd', data: c }); 418 } 419 // Tempo changes 420 for (const tc of (report.tempoChanges||[])) { 421 const beat = tc.beatTime ?? tc.beat ?? tc.time; 422 if (beat!=null) omniEvents.push({ beat: Number(beat), kind: 'tempo', data: tc }); 423 } 424 omniEvents.sort((a,b)=>a.beat-b.beat || (a.kind==='note'? -1:1)); 425} 426 427// Groups grid state 428let gridInitialized = false; 429let gridRows = []; // each {label, glyph, colorCode, cols:[]} 430let gridWidth = 0; 431let lastTermCols = 0; 432let groupBeatHits = []; 433let groupBeatCounts = []; 434let lastBeatBucket = null; // beat index currently accumulating 435let beatTickCols = []; // beat marker row (| every beat, bold every 4/16) aligned with committed cols 436let gridCsvStream = null; 437if (gridCsvPath) { 438 gridCsvStream = fs.createWriteStream(gridCsvPath, { flags: 'w' }); 439} 440 441// Prepare readline for keypress 442readline.emitKeypressEvents(process.stdin); 443if (process.stdin.isTTY) process.stdin.setRawMode(true); 444process.stdin.on('keypress', (str, key) => { 445 if (key.name === 'q' || (key.ctrl && key.name === 'c')) { 446 cleanupAndExit(); 447 } 448}); 449 450function cleanupAndExit(code=0) { 451 process.stdout.write('\x1b[0m\n'); 452 process.exit(code); 453} 454 455function formatTime(beat) { 456 const seconds = beatToSeconds(beat); 457 const m = Math.floor(seconds / 60); 458 const s = Math.floor(seconds % 60); 459 const fracBeat = (beat % 1).toFixed(2).padStart(5,' '); 460 return `${m}:${String(s).padStart(2,'0')}@${fracBeat}`; 461} 462 463function renderBar(pct, width) { 464 const filled = Math.round(pct * width); 465 return '[' + '#'.repeat(filled).padEnd(width,' ') + ']'; 466} 467 468// Per-frame we gather notes in (currentBeat, nextBeatFrame] 469const frameBeatIncrement = (rate / fps); // beats progressed per frame given base 1 beat per second at bpm=60? Wait: we base on beat unit. 470// Clarify: we want real time reflect beats according to BPM; easier: advance beats by rate * (bpm/60)/fps? Actually 1 beat duration in seconds = 60/bpm. 471// Each frame is 1/fps seconds -> beats advanced = (1/fps) / (60/bpm) = (bpm/60)/fps 472const beatsPerFrameBase = (bpm / 60) / fps; // beats progressed at rate=1 473 474let currentBeat = startBeat; 475let lastFrameTime = Date.now(); 476 477const trackActivity = new Map(); // trackId -> transient count this frame 478 479function stepFrame() { 480 const now = Date.now(); 481 const dtSec = (now - lastFrameTime) / 1000; 482 lastFrameTime = now; 483 484 // Advance beats factoring dt to keep more stable timing 485 const localBpm = bpmAtBeat(currentBeat); 486 const beatsAdvance = dtSec * (localBpm / 60) * rate; 487 currentBeat += beatsAdvance; 488 if (currentBeat > endBeat) { 489 currentBeat = endBeat; 490 } 491 492 // Clear activity 493 trackActivity.clear(); 494 // Collect notes whose beat is <= currentBeat (play them) 495 let frameNoteCount = 0; 496 const frameNotes = []; 497 while (noteIndex < notes.length && notes[noteIndex].beat <= currentBeat) { 498 const n = notes[noteIndex]; 499 if (n.beat >= startBeat && n.beat <= endBeat) { 500 playedNotes++; 501 frameNoteCount++; 502 frameNotes.push(n); 503 const tId = String(n.trackId ?? ''); 504 trackActivity.set(tId, (trackActivity.get(tId) || 0) + 1); 505 trackTotals.set(tId, (trackTotals.get(tId)||0)+1); 506 windowEvents.push({ beat: n.beat, trackId: tId, pitch: n.pitch }); 507 // hat tracking independent of pitch filter (only among displayed notes already filtered) 508 if (hatPitchSet.size && hatPitchSet.has(String(n.pitch))) { 509 playedHats++; 510 trackHatTotals.set(tId, (trackHatTotals.get(tId)||0)+1); 511 } 512 if (showStream) { 513 recentEvents.push(n); 514 if (recentEvents.length > streamWidth * 5) recentEvents.shift(); 515 } 516 if (useMinimalLine && !useOmniLine) { 517 // Emit glyph per note using group precedence, then hat, then track dot 518 const pitchStr = String(n.pitch); 519 let outGlyph = ''; 520 if (pitchGroups.length) { 521 for (const g of pitchGroups) { 522 if (g.set.has(pitchStr)) { outGlyph = g.colorCode + g.glyph + '\x1b[0m'; break; } 523 } 524 } 525 if (!outGlyph) { 526 if (hatPitchSet.size && hatPitchSet.has(pitchStr)) outGlyph = '\x1b[35mH\x1b[0m'; 527 else if (useMinimalPitch) { 528 outGlyph = colorPitchGlyph(n.pitch); 529 } else outGlyph = minimalTrackGlyph(tId); 530 } 531 process.stdout.write(outGlyph); 532 } 533 } 534 noteIndex++; 535 } 536 537 if (useMinimalLine) { 538 const bf = Math.floor(currentBeat); 539 if (bf !== lastBeatFloor) { 540 // Beat boundary marker (every 4 beats stronger) 541 const bar = (bf % 16 === 0) ? '\x1b[1m|\x1b[0m' : (bf % 4 === 0 ? '|' : ''); 542 if (bar) process.stdout.write(color.dim(bar)); 543 lastBeatFloor = bf; 544 if (useMinimalLine && !useOmniLine) { 545 const bf = Math.floor(currentBeat); 546 if (bf !== lastBeatFloor) { 547 if (!noBeatMarks) { 548 const bar = (bf % 16 === 0) ? '\x1b[1m|\x1b[0m' : (bf % 4 === 0 ? '|' : ''); 549 if (bar) process.stdout.write(color.dim(bar)); 550 } 551 lastBeatFloor = bf; 552 } 553 } 554 // Omni-line emission (independent of filtered note stream) 555 if (useOmniLine) { 556 while (omniIndex < omniEvents.length && omniEvents[omniIndex].beat <= currentBeat) { 557 const ev = omniEvents[omniIndex]; 558 let glyph = ''; 559 switch (ev.kind) { 560 case 'note': { 561 const tId = String(ev.data.trackId ?? ''); 562 glyph = minimalTrackGlyph(tId); 563 break; } 564 case 'locator': glyph = '\x1b[35m|\x1b[0m'; break; 565 case 'clipStart': glyph = '\x1b[32m[\x1b[0m'; break; 566 case 'clipEnd': glyph = '\x1b[31m]\x1b[0m'; break; 567 case 'tempo': glyph = '\x1b[33mt\x1b[0m'; break; 568 default: glyph = '.'; break; 569 } 570 process.stdout.write(glyph); 571 omniIndex++; 572 } 573 const bf2 = Math.floor(currentBeat); 574 if (bf2 !== lastOmniBeatFloor) { 575 if (!noBeatMarks) { 576 const mark = (bf2 % 16 === 0) ? '\x1b[1m|\x1b[0m' : (bf2 % 4 === 0 ? '|' : ''); 577 if (mark) process.stdout.write(color.dim(mark)); 578 } 579 lastOmniBeatFloor = bf2; 580 } 581 } 582 } 583 } 584 585 // Sandcastle update 586 if (useSandcastle && frameNoteCount > 0 && endBeat > startBeat) { 587 // Determine current width and ensure sandCols sized 588 const width = process.stdout.columns || 100; 589 if (width !== lastSandWidth) { 590 // Resize preserving proportional mapping 591 const newCols = new Array(width).fill(0); 592 for (let i=0;i<width;i++) { 593 const srcIdx = sandCols.length ? Math.floor(i / width * sandCols.length) : 0; 594 newCols[i] = sandCols[srcIdx] || 0; 595 } 596 sandCols = newCols; 597 lastSandWidth = width; 598 } else if (sandCols.length === 0) { 599 sandCols = new Array(width).fill(0); 600 lastSandWidth = width; 601 } 602 const songPct = (currentBeat - startBeat) / (endBeat - startBeat || 1); 603 const col = Math.min(sandCols.length - 1, Math.max(0, Math.floor(songPct * sandCols.length))); 604 sandCols[col] = Math.min(sandHeight, sandCols[col] + frameNoteCount); // cap 605 } 606 607 // Burst line update: one mark per active track with burst (act>0) 608 if (useBurstLine) { 609 for (const [tId, act] of trackActivity.entries()) { 610 if (act > 0) { 611 const glyph = act > 4 ? '█' : act > 2 ? '▆' : '▃'; 612 // color by track id hash 613 let hash = 0; for (let i=0;i<tId.length;i++) hash = (hash*31 + tId.charCodeAt(i)) & 0xffff; 614 const hueBucket = hash % 3; // 0 green,1 yellow,2 red 615 const colorCode = hueBucket===0?'\x1b[32m':hueBucket===1?'\x1b[33m':'\x1b[31m'; 616 burstMarks.push(colorCode + glyph + '\x1b[0m'); 617 } 618 } 619 // Cap burstMarks length to avoid unbounded growth (keep last width* sandHeight *2) 620 const maxBurst = (process.stdout.columns||100) * sandHeight * 2; 621 if (burstMarks.length > maxBurst) burstMarks.splice(0, burstMarks.length - maxBurst); 622 } 623 624 // Prune old window events 625 const windowStartBeat = currentBeat - aggWindow; 626 while (windowEvents.length && windowEvents[0].beat < windowStartBeat) windowEvents.shift(); 627 628 // Locator passed? 629 let locatorMsg = ''; 630 while (nextLocatorIdx < locators.length && locators[nextLocatorIdx].time <= currentBeat) { 631 locatorMsg = ` Locator: ${locators[nextLocatorIdx].name || '(unnamed)'} @ ${locators[nextLocatorIdx].time}`; 632 nextLocatorIdx++; 633 } 634 // Groups-grid aggregation BEFORE drawing (eliminates one-frame lag) 635 if (useGroupsGrid) { 636 const termCols = process.stdout.columns || 100; 637 if (!gridInitialized || termCols !== lastTermCols) { 638 gridRows = []; 639 const palette = ['\x1b[32m','\x1b[33m','\x1b[31m','\x1b[36m','\x1b[35m','\x1b[34m','\x1b[92m','\x1b[95m']; 640 if (pitchGroups.length) { 641 pitchGroups.forEach(g=>{ gridRows.push({ label: g.name, glyph: g.glyph, colorCode: g.colorCode, cols: [] }); }); 642 } else if (hatPitchSet.size) { 643 gridRows.push({ label: 'Hats', glyph: 'H', colorCode: '\x1b[35m', cols: [] }); 644 } 645 if (!gridRows.length) { 646 (report.trackSummaries||[]).slice(0,6).forEach((t,i)=>{ 647 let hash = 0; const idStr = String(t.id); for (let k=0;k<idStr.length;k++) hash=(hash*31+idStr.charCodeAt(k))&0xffff; 648 const col = palette[i % palette.length]; 649 gridRows.push({ label: idStr, glyph: '•', colorCode: col, cols: [] }); 650 }); 651 } 652 gridWidth = termCols - 15; 653 lastTermCols = termCols; 654 gridInitialized = true; 655 groupBeatHits = new Array(gridRows.length).fill(false); 656 groupBeatCounts = new Array(gridRows.length).fill(0); 657 lastBeatBucket = Math.floor(currentBeat); 658 beatTickCols = []; // reset on resize 659 if (gridCsvStream) { 660 const header = ['beat', ...gridRows.map(r=>r.label.replace(/[,\n]/g,'_'))].join(','); 661 if (gridCsvStream.bytesWritten === 0) gridCsvStream.write(header + '\n'); 662 } 663 } 664 const beatBucket = Math.floor(currentBeat); 665 // accumulate into current bucket 666 for (let r=0; r<gridRows.length; r++) { 667 let hit = false; let countInc = 0; 668 if (pitchGroups.length) { 669 const group = pitchGroups.find(g=>g.name===gridRows[r].label); 670 if (group) { 671 for (const n of frameNotes) if (group.set.has(String(n.pitch))) { hit = true; countInc++; } 672 } } else if (gridRows[r].label === 'Hats' && hatPitchSet.size) { 673 for (const n of frameNotes) if (hatPitchSet.has(String(n.pitch))) { hit = true; countInc++; } 674 } else { 675 for (const n of frameNotes) if (String(n.trackId) === gridRows[r].label) { hit = true; countInc++; } 676 } 677 if (hit) { groupBeatHits[r] = true; groupBeatCounts[r] += countInc; } 678 } 679 // finalize previous bucket when we move to next 680 if (beatBucket !== lastBeatBucket) { 681 const finalizedBeat = lastBeatBucket; // the one we just finished accumulating 682 // push beat tick marker 683 let tickChar = '.'; 684 if (finalizedBeat % 16 === 0) tickChar = '\x1b[1m|\x1b[0m'; 685 else if (finalizedBeat % 4 === 0) tickChar = '|'; 686 beatTickCols.push(tickChar); 687 if (beatTickCols.length > gridWidth) beatTickCols.shift(); 688 // capture counts for CSV BEFORE resetting 689 const csvCounts = gridRows.map((_,i)=> groupBeatHits[i] ? groupBeatCounts[i] : 0); 690 // push each group cell 691 for (let r=0; r<gridRows.length; r++) { 692 const row = gridRows[r]; 693 const count = groupBeatCounts[r]; 694 const cell = groupBeatHits[r] ? groupIntensityGlyph(count, row, false) : ' '; 695 row.cols.push(cell); 696 if (row.cols.length > gridWidth) row.cols.shift(); 697 groupBeatHits[r] = false; groupBeatCounts[r] = 0; 698 } 699 if (gridCsvStream) { 700 gridCsvStream.write([finalizedBeat, ...csvCounts].join(',') + '\n'); 701 } 702 lastBeatBucket = beatBucket; 703 // write csv using counts captured before reset; need to capture earlier 704 } 705 } 706 707 drawScreen(locatorMsg); 708 // CSV snapshot 709 if (csvStream && currentBeat >= nextSnapshotBeat - 1e-6) { 710 writeSnapshotRow(currentBeat); 711 nextSnapshotBeat += snapshotIntervalBeats; 712 } 713 714 if (currentBeat >= endBeat || playedNotes >= totalNotes) { 715 drawScreen(locatorMsg, true); 716 cleanupAndExit(); 717 } 718} 719 720function drawScreen(locatorMsg, final=false) { 721 if (useMinimalLine || useOmniLine) { 722 // One-time legend header 723 if (useMinimalLine && !useOmniLine && !minimalLegendPrinted && !noMinimalLegend) { 724 const width = process.stdout.columns || 100; 725 const sampleTracks = (report.trackSummaries||[]).slice(0,6).map(t=>{ 726 const idStr = String(t.id); 727 return minimalTrackGlyph(idStr) + color.dim(idStr); 728 }).join(' '); 729 const legendParts = []; 730 legendParts.push('Key'); 731 legendParts.push('Hat='+ '\x1b[35mH\x1b[0m'); 732 if (pitchGroups.length) { 733 legendParts.push('Groups=' + pitchGroups.map(g=>g.colorCode+g.glyph+'\x1b[0m'+g.name).join(',')); 734 } 735 if (useMinimalPitch) legendParts.push('PitchClass letters'); 736 legendParts.push('Track='+sampleTracks); 737 legendParts.push('Beat markers: | (4), bold | (16)'); 738 const legendLine = truncate(legendParts.join(' '), width); 739 process.stdout.write('\n'+color.dim(legendLine)+'\n'); 740 minimalLegendPrinted = true; 741 } 742 if (final) { 743 process.stdout.write('\n'+color.green('Done.')+'\n'); 744 } 745 if (useMinimalLine) return; // prevent full UI below 746 } 747 const width = process.stdout.columns || 100; 748 const barWidth = Math.max(10, Math.min(40, Math.floor(width * 0.25))); 749 const pct = totalNotes ? playedNotes / totalNotes : 0; 750 const progressBar = renderBar(pct, barWidth); 751 const etaBeatsRemaining = (totalNotes - playedNotes) / ((playedNotes / (currentBeat - startBeat + 0.00001)) || 1); 752 const etaTimeSec = (function(){ 753 const beatsPerSec = currentBeat>startBeat ? currentBeat / (beatToSeconds(currentBeat)||1) : bpm/60; 754 return (totalNotes - playedNotes) / ((playedNotes/(currentBeat-startBeat+1e-6))||1) * (60 / bpmAtBeat(currentBeat)); 755 })(); 756 const etaMin = Math.floor(etaTimeSec / 60); 757 const etaSec = Math.floor(etaTimeSec % 60); 758 const headerLeft = `${formatTime(currentBeat)} Beat ${currentBeat.toFixed(2)}/${endBeat.toFixed(2)}`; 759 const headerMid = `${progressBar} ${(pct*100).toFixed(1)}%`; 760 const headerRight = `Notes ${playedNotes}/${totalNotes} ETA ${isFinite(etaSec)?etaMin+':' + String(etaSec).padStart(2,'0'):'--:--'}`; 761 const line1 = color.bold(truncate(`${headerLeft} ${headerMid} ${headerRight}`, width)); 762 763 // Build track lines (limit maybe 20 lines to avoid overflow) 764 // Build density map for window 765 const densityPerTrack = new Map(); 766 if (aggWindow > 0) { 767 for (const ev of windowEvents) { 768 densityPerTrack.set(ev.trackId, (densityPerTrack.get(ev.trackId) || 0) + 1); 769 } 770 } 771 772 const trackLines = []; 773 const trackLimit = showAllTracks ? Infinity : (isFinite(trackLimitFlag)?trackLimitFlag:20); 774 let shown = 0; 775 for (const t of report.trackSummaries || []) { 776 if (shown >= trackLimit) break; 777 const idStr = String(t.id); 778 const act = trackActivity.get(idStr) || 0; 779 const densityCount = densityPerTrack.get(idStr) || 0; 780 const densityRate = aggWindow > 0 ? densityCount / aggWindow : 0; 781 if (!showAllTracks && act === 0 && densityCount === 0 && !final) continue; // hide silent unless flag 782 if (adaptiveDensity && densityRate > densityFull) densityFull = densityRate; 783 const intensity = Math.min(1, densityRate / (densityFull||1)); 784 // Update history 785 if (useSparklines) { 786 if (!trackHistory.has(idStr)) trackHistory.set(idStr, []); 787 const arr = trackHistory.get(idStr); 788 arr.push(densityRate); 789 if (arr.length > sparkWidth) arr.shift(); 790 } 791 const meterLen = 20; 792 const filled = Math.round(intensity * meterLen); 793 const meter = colorizeBar(filled, meterLen); 794 const burst = act ? color.bold(color.yellow('*'.repeat(Math.min(10, act)))) : ' '; 795 const totals = trackTotals.get(idStr)||0; 796 const hats = hatPitchSet.size ? (trackHatTotals.get(idStr)||0) : null; 797 const tail = hats!=null ? ` ${totals}/${hats}` : ` ${totals}`; 798 const spark = useSparklines ? ' ' + buildSparkline(trackHistory.get(idStr)||[], densityFull) : ''; 799 const nameCol = t.name ? pad(t.name,12) : ''.padEnd(12,' '); 800 const line = `${idStr.padStart(4,' ')} ${pad(t.type,10)} ${nameCol} ${meter} ${burst}${tail}${spark}`; 801 trackLines.push(truncate(line, width)); 802 shown++; 803 } 804 if (!final && (report.trackSummaries||[]).length > shown && trackLimit !== Infinity) { 805 const remaining = (report.trackSummaries||[]).length - shown; 806 trackLines.push(color.dim(`(+${remaining} more tracks hidden; use --show-all-tracks or --track-limit ${shown+remaining})`)); 807 } 808 809 // Compose output 810 const locatorLine = locatorMsg ? color.magenta(truncate(locatorMsg, width)) : ''; 811 // Aggregation summary 812 const windowNoteCount = windowEvents.length; 813 const hatsInWindow = hatPitchSet.size ? windowEvents.filter(ev=>hatPitchSet.has(String(ev.pitch))).length : 0; 814 const densityLine = `Win(${aggWindow}b) notes=${windowNoteCount} rate=${(windowNoteCount/aggWindow).toFixed(2)}${hatPitchSet.size?` hats=${hatsInWindow}`:''} scale=${densityFull.toFixed(2)}`; 815 let pitchStatsLine = ''; 816 if (windowPitchStatsN > 0 && windowEvents.length) { 817 const freq = new Map(); 818 for (const ev of windowEvents) freq.set(ev.pitch, (freq.get(ev.pitch)||0)+1); 819 const sorted = [...freq.entries()].sort((a,b)=>b[1]-a[1]).slice(0, windowPitchStatsN); 820 pitchStatsLine = 'TopP ' + sorted.map(([p,c])=>`${p}:${c}`).join(' '); 821 } 822 823 // Recent stream 824 let streamLine = ''; 825 if (showStream) { 826 const slice = recentEvents.slice(-streamWidth); 827 streamLine = slice.map(ev => pitchGlyph(ev.pitch, hatPitchSet)).join(''); 828 streamLine = color.dim(streamLine); 829 } 830 831 const footer = final ? color.green('Simulation complete.') : color.dim("Press 'q' to quit"); 832 833 // Clear screen and write 834 process.stdout.write('\x1b[H\x1b[2J'); // home + clear 835 process.stdout.write(line1 + '\n'); 836 // Legend (color / glyph key) – always show once per frame for clarity 837 // Explains: meter colors, hat pitch glyph, sandcastle colors, burst line, star bursts 838 const legend = (() => { 839 const g = '\x1b[32m█\x1b[0m'; 840 const y = '\x1b[33m█\x1b[0m'; 841 const r = '\x1b[31m█\x1b[0m'; 842 const hat = '\x1b[35mH\x1b[0m'; 843 const sandG = '\x1b[42m \x1b[0m'; 844 const sandY = '\x1b[43m \x1b[0m'; 845 const sandR = '\x1b[41m \x1b[0m'; 846 const bursts = '\x1b[32m▃\x1b[0m\x1b[33m▆\x1b[0m\x1b[31m█\x1b[0m'; 847 // Key segments kept short; truncate at width 848 let parts = []; 849 parts.push('Key'); 850 parts.push(`Meter ${g}${y}${r}`); 851 parts.push(`Hat ${hat}`); 852 if (useSandcastle) parts.push(`Sand ${sandG}${sandY}${sandR}`); 853 if (useBurstLine) parts.push(`Burst ${bursts}`); 854 parts.push(`* instant count`); 855 return truncate(parts.join(' '), width); 856 })(); 857 process.stdout.write(color.dim(legend) + '\n'); 858 if (locatorLine) process.stdout.write(locatorLine + '\n'); 859 process.stdout.write(color.cyan(densityLine) + '\n'); 860 if (showStream) process.stdout.write(streamLine + '\n'); 861 if (useGroupsGrid && !useMinimalLine) { 862 // Render group lanes 863 if (beatTickCols.length) { 864 const beatLine = 'Beats'.padEnd(12,' ') + ' ' + beatTickCols.join('') + (useGroupsGrid?'' :''); 865 process.stdout.write(truncate(beatLine, width) + '\n'); 866 } 867 for (const row of gridRows) { 868 const label = row.label.padEnd(12,' ').slice(0,12); 869 // live (in-progress) bucket preview (dim) if accumulating 870 let liveCell = ''; 871 if (groupBeatCounts.length && groupBeatCounts[gridRows.indexOf(row)] !== undefined) { 872 const idx = gridRows.indexOf(row); 873 const count = groupBeatCounts[idx]; 874 if (count > 0 || groupBeatHits[idx]) { 875 liveCell = color.dim(stripReset(groupIntensityGlyph(count || 1, row, false))); // dim preview 876 } else { 877 liveCell = ' '; 878 } 879 } 880 const line = label + ' ' + row.cols.join('') + liveCell; 881 process.stdout.write(truncate(line, width) + '\n'); 882 } 883 } 884 if (condenseTracks && trackLines.length) { 885 // Build multi-column grid of track lines truncated to available width 886 const termWidth = width; 887 const colWidth = Math.min(38, Math.max(28, Math.floor(termWidth / Math.min(3, Math.ceil(trackLines.length/12))))); // dynamic 888 const cols = Math.max(1, Math.floor(termWidth / colWidth)); 889 const rows = Math.ceil(trackLines.length / cols); 890 for (let r=0;r<rows;r++) { 891 let line = ''; 892 for (let c=0;c<cols;c++) { 893 const idx = r + c*rows; 894 if (idx < trackLines.length) { 895 line += pad(trackLines[idx], colWidth); 896 } 897 } 898 process.stdout.write(truncate(line, termWidth) + '\n'); 899 } 900 } else { 901 for (const tl of trackLines) process.stdout.write(tl + '\n'); 902 } 903 if (pitchStatsLine) process.stdout.write(color.dim(truncate(pitchStatsLine, width)) + '\n'); 904 // Sandcastle render (after track lines, before footer) 905 if (useSandcastle) { 906 const width2 = process.stdout.columns || 100; 907 if (sandCols.length !== width2) { 908 // adjust to new width (simple stretch) 909 const newCols = new Array(width2).fill(0); 910 for (let i=0;i<width2;i++) { 911 const srcIdx = sandCols.length ? Math.floor(i / width2 * sandCols.length) : 0; 912 newCols[i] = sandCols[srcIdx] || 0; 913 } 914 sandCols = newCols; 915 lastSandWidth = width2; 916 } 917 // Build rows from top (sandHeight-1) to 0 918 let maxCol = 0; for (const h of sandCols) if (h>maxCol) maxCol = h; 919 const effectiveHeight = Math.min(sandHeight, Math.max(1, maxCol)); 920 for (let row = effectiveHeight-1; row >= 0; row--) { 921 let line = ''; 922 for (let c=0;c<sandCols.length;c++) { 923 const h = sandCols[c]; 924 if (h > row) { 925 const intensity = h / sandHeight; 926 let colCode; 927 if (intensity < 0.33) colCode = '\x1b[42m'; // green bg 928 else if (intensity < 0.66) colCode = '\x1b[43m'; // yellow bg 929 else colCode = '\x1b[41m'; // red bg 930 line += colCode + ' ' + '\x1b[0m'; 931 } else { 932 line += ' '; 933 } 934 } 935 process.stdout.write(line.slice(0, width2) + '\n'); 936 } 937 } 938 if (useBurstLine) { 939 const width3 = process.stdout.columns || 100; 940 const slice = burstMarks.slice(-width3); 941 process.stdout.write(slice.join('') + '\n'); 942 } 943 process.stdout.write(footer + '\n'); 944} 945 946function truncate(str, width) { 947 if (str.length <= width) return str; 948 return str.slice(0, width-1) + '…'; 949} 950function pad(str, w) { str = String(str||''); if (str.length >= w) return str.slice(0,w); return str + ' '.repeat(w - str.length); } 951 952function colorizeBar(filled, total) { 953 let out = ''; 954 for (let i=0;i<total;i++) { 955 if (i < filled) out += heatColor(i/total) + '█' + '\x1b[0m'; else out += ' '; 956 } 957 return out; 958} 959function heatColor(x) { 960 if (x < 0.33) return '\x1b[32m'; // green 961 if (x < 0.66) return '\x1b[33m'; // yellow 962 return '\x1b[31m'; // red 963} 964function pitchGlyph(pitch, hatSet) { 965 if (hatSet && hatSet.has(String(pitch))) return '\x1b[35mH\x1b[0m'; 966 if (pitch == null) return '.'; 967 const mod = Number(pitch) % 12; 968 const glyphs = ['C','c','D','d','E','F','f','G','g','A','a','B']; 969 return glyphs[mod] || '?'; 970} 971 972function buildSparkline(values, scale) { 973 if (!values.length) return ''.padStart(sparkWidth,' '); 974 const chars = ' ▁▂▃▄▅▆▇█'; // 8 levels plus space 975 const out = []; 976 const s = scale || 1; 977 for (const v of values) { 978 const ratio = Math.min(1, v / (s||1)); 979 const idx = Math.round(ratio * (chars.length - 1)); 980 out.push(chars[idx]); 981 } 982 return out.join('').padStart(sparkWidth,' '); 983} 984 985// Intensity glyph mapping for groups grid 986function groupIntensityGlyph(count, row, finalized=true) { 987 // finalized bool reserved for future styling differences 988 if (count < 2) return row.colorCode + row.glyph + '\x1b[0m'; 989 if (count < 4) return row.colorCode + '░' + '\x1b[0m'; 990 if (count < 8) return row.colorCode + '▒' + '\x1b[0m'; 991 if (count < 16) return row.colorCode + '▓' + '\x1b[0m'; 992 return row.colorCode + '█' + '\x1b[0m'; 993} 994function stripReset(s){ 995 return s.replace(/\x1b\[[0-9;]*m/g,''); 996} 997 998function minimalTrackGlyph(tId){ 999 // Stable small color-coded dot based on track id hash 1000 let hash = 0; for (let i=0;i<tId.length;i++) hash = (hash*31 + tId.charCodeAt(i)) & 0xffff; 1001 const bucket = hash % 6; 1002 const colors = ['\x1b[32m','\x1b[33m','\x1b[31m','\x1b[36m','\x1b[35m','\x1b[34m']; 1003 return (colors[bucket]||'') + '•' + '\x1b[0m'; 1004} 1005 1006function colorPitchGlyph(p){ 1007 if (p==null) return '.'; 1008 const mod = Number(p)%12; 1009 const letters = ['C','c','D','d','E','F','f','G','g','A','a','B']; 1010 const base = letters[mod]||'?'; 1011 // simple hue buckets by mod 1012 const col = ['\x1b[32m','\x1b[32m','\x1b[33m','\x1b[33m','\x1b[31m','\x1b[36m','\x1b[36m','\x1b[35m','\x1b[35m','\x1b[34m','\x1b[34m','\x1b[31m'][mod]||''; 1013 return col + base + '\x1b[0m'; 1014} 1015 1016// Initial draw 1017lastFrameTime = Date.now(); 1018drawScreen('', false); 1019 1020const interval = setInterval(stepFrame, 1000 / fps); 1021 1022process.on('SIGINT', () => cleanupAndExit(0)); 1023process.on('exit', () => { clearInterval(interval); if (csvStream) csvStream.end(); }); 1024process.on('exit', () => { if (gridCsvStream) gridCsvStream.end(); }); 1025 1026// CSV snapshot utilities 1027if (csvStream) { writeSnapshotHeader(); writeSnapshotRow(currentBeat); } 1028function writeSnapshotHeader() { 1029 const ids = (report.trackSummaries||[]).map(t=>t.id); 1030 const cols = ['beat','seconds','playedNotes','playedHats', ...ids.map(id=>`track_${id}_total`), ...(hatPitchSet.size? ids.map(id=>`track_${id}_hat`):[])]; 1031 csvStream.write(cols.join(',')+'\n'); 1032} 1033function writeSnapshotRow(beat) { 1034 if (!csvStream) return; 1035 const seconds = beatToSeconds(beat).toFixed(4); 1036 const ids = (report.trackSummaries||[]).map(t=>t.id); 1037 const totals = ids.map(id=>trackTotals.get(String(id))||0); 1038 const hats = hatPitchSet.size? ids.map(id=>trackHatTotals.get(String(id))||0):[]; 1039 const row = [beat.toFixed(4), seconds, playedNotes, playedHats, ...totals, ...hats]; 1040 csvStream.write(row.join(',')+'\n'); 1041}