Monorepo for Aesthetic.Computer
aesthetic.computer
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}