Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2/**
3 * Ableton Live Session View Viewer
4 *
5 * A live session view that simulates Ableton's session interface:
6 * - Clips trigger and loop in real-time
7 * - Aggregated data flows through tracks
8 * - Dynamic session grid visualization
9 *
10 * Usage:
11 * node ableton-session-viewer.mjs [path-to-extracted.xml]
12 */
13
14import { createReadStream, existsSync } from 'node:fs';
15import { resolve, dirname } from 'node:path';
16import { fileURLToPath } from 'node:url';
17import { SaxesParser } from 'saxes';
18import chalk from 'chalk';
19
20const __dirname = dirname(fileURLToPath(import.meta.url));
21
22// Default paths
23const DEFAULT_XML_PATH = resolve(__dirname, '../system/public/assets/wipppps/zzzZWAP_extracted.xml');
24const FALLBACK_XML_PATH = '/Users/jas/Desktop/code/aesthetic-computer/system/public/assets/wipppps/zzzZWAP_extracted.xml';
25
26class AbletonSessionParser {
27 constructor() {
28 this.tracks = [];
29 this.clips = [];
30 this.tempo = 120;
31 this.scenes = 8; // Number of scenes (rows)
32
33 // Parser state
34 this.currentPath = [];
35 this.currentTrack = null;
36 this.currentClip = null;
37 this.attributes = {};
38 this.textContent = '';
39 }
40
41 async parseXML(xmlPath) {
42 console.log(chalk.blue('🎛️ Parsing Ableton XML for Session View...'));
43
44 const parser = new SaxesParser();
45 const stream = createReadStream(xmlPath);
46
47 parser.on('opentag', (node) => {
48 this.currentPath.push(node.name);
49 this.attributes = node.attributes || {};
50 this.textContent = '';
51
52 if (node.name.endsWith('Track') && node.name !== 'GroupTrackSlot') {
53 this.currentTrack = {
54 id: this.attributes.Id,
55 type: node.name,
56 name: '',
57 color: Math.floor(Math.random() * 64), // Random color if not found
58 clips: [],
59 level: 0,
60 activity: 0,
61 outputData: []
62 };
63 }
64
65 if (node.name.endsWith('Clip') && !node.name.includes('Slot')) {
66 this.currentClip = {
67 id: this.attributes.Id,
68 name: `Clip ${this.clips.length + 1}`,
69 duration: 4 + Math.random() * 8, // Random clip length 4-12 beats
70 notes: [],
71 scene: Math.floor(Math.random() * this.scenes),
72 isActive: false,
73 loopLength: 4,
74 color: Math.floor(Math.random() * 64)
75 };
76 }
77 });
78
79 parser.on('text', (text) => {
80 this.textContent += text.trim();
81 });
82
83 parser.on('closetag', (node) => {
84 const path = this.currentPath.join('/');
85
86 if (path.endsWith('Name/EffectiveName') && this.currentTrack) {
87 this.currentTrack.name = this.attributes.Value || this.textContent || `Track ${this.tracks.length + 1}`;
88 }
89
90 if (path.endsWith('Color') && this.currentTrack && this.attributes.Value) {
91 this.currentTrack.color = parseInt(this.attributes.Value);
92 }
93
94 // Extract MIDI notes for session simulation
95 if (path.endsWith('KeyTrack/Notes/MidiNoteEvent')) {
96 if (this.currentClip) {
97 const note = {
98 time: parseFloat(this.attributes.Time || Math.random() * 4),
99 duration: parseFloat(this.attributes.Duration || 0.25),
100 pitch: parseInt(this.attributes.Pitch || 36 + Math.random() * 48),
101 velocity: parseInt(this.attributes.Velocity || 80 + Math.random() * 40)
102 };
103 this.currentClip.notes.push(note);
104 }
105 }
106
107 if (path.endsWith('MasterTrack/DeviceChain/Mixer/Tempo/Manual') && this.attributes.Value) {
108 this.tempo = parseFloat(this.attributes.Value);
109 }
110
111 if (node.name.endsWith('Track') && this.currentTrack) {
112 if (this.currentClip) {
113 this.currentTrack.clips.push(this.currentClip);
114 }
115 this.tracks.push(this.currentTrack);
116 this.currentTrack = null;
117 }
118
119 if (node.name.endsWith('Clip') && this.currentClip) {
120 // Add some synthetic notes if none found
121 if (this.currentClip.notes.length === 0) {
122 for (let i = 0; i < 4 + Math.random() * 8; i++) {
123 this.currentClip.notes.push({
124 time: Math.random() * this.currentClip.duration,
125 duration: 0.125 + Math.random() * 0.5,
126 pitch: 36 + Math.floor(Math.random() * 48),
127 velocity: 60 + Math.random() * 60
128 });
129 }
130 }
131 this.clips.push(this.currentClip);
132 this.currentClip = null;
133 }
134
135 this.currentPath.pop();
136 });
137
138 return new Promise((resolve, reject) => {
139 parser.on('error', reject);
140 parser.on('end', () => {
141 this.setupSessionData();
142 resolve(this.getSessionData());
143 });
144
145 stream.on('data', chunk => parser.write(chunk));
146 stream.on('end', () => parser.close());
147 stream.on('error', reject);
148 });
149 }
150
151 setupSessionData() {
152 // Ensure we have enough tracks
153 while (this.tracks.length < 6) {
154 this.tracks.push({
155 id: this.tracks.length,
156 type: Math.random() > 0.5 ? 'MidiTrack' : 'AudioTrack',
157 name: `Track ${this.tracks.length + 1}`,
158 color: Math.floor(Math.random() * 64),
159 clips: [],
160 level: 0,
161 activity: 0,
162 outputData: []
163 });
164 }
165
166 // Distribute clips across tracks and scenes
167 this.clips.forEach((clip, index) => {
168 const trackIndex = index % this.tracks.length;
169 const track = this.tracks[trackIndex];
170 clip.trackIndex = trackIndex;
171 track.clips.push(clip);
172 });
173
174 // Fill empty slots with placeholder clips
175 this.tracks.forEach((track, trackIndex) => {
176 for (let scene = 0; scene < this.scenes; scene++) {
177 const hasClipInScene = track.clips.some(clip => clip.scene === scene);
178 if (!hasClipInScene && Math.random() > 0.3) {
179 const syntheticClip = {
180 id: `synthetic_${trackIndex}_${scene}`,
181 name: `Clip ${scene + 1}`,
182 duration: 2 + Math.random() * 6,
183 notes: [],
184 scene: scene,
185 isActive: false,
186 loopLength: 2 + Math.random() * 6,
187 color: track.color,
188 trackIndex: trackIndex,
189 synthetic: true
190 };
191
192 // Add some notes
193 for (let i = 0; i < 2 + Math.random() * 6; i++) {
194 syntheticClip.notes.push({
195 time: Math.random() * syntheticClip.duration,
196 duration: 0.125 + Math.random() * 0.5,
197 pitch: 36 + Math.floor(Math.random() * 48),
198 velocity: 40 + Math.random() * 80
199 });
200 }
201
202 track.clips.push(syntheticClip);
203 this.clips.push(syntheticClip);
204 }
205 }
206 });
207 }
208
209 getSessionData() {
210 return {
211 tracks: this.tracks,
212 clips: this.clips,
213 tempo: this.tempo,
214 scenes: this.scenes
215 };
216 }
217}
218
219class SessionVisualizer {
220 constructor(sessionData) {
221 this.data = sessionData;
222 this.currentBeat = 0;
223 this.isPlaying = false;
224 this.startTime = null;
225 this.fps = 20;
226
227 // Session state
228 this.activeClips = new Map(); // clip.id -> { startBeat, loopCount }
229 this.trackOutputs = new Map(); // track index -> activity level
230 this.aggregateData = [];
231
232 // Auto-trigger some clips initially
233 this.autoTriggerClips();
234
235 this.setupInput();
236 }
237
238 autoTriggerClips() {
239 // Randomly trigger some clips to start
240 const clipsToTrigger = this.data.clips.filter(() => Math.random() > 0.7);
241 clipsToTrigger.forEach(clip => {
242 this.triggerClip(clip);
243 });
244 }
245
246 triggerClip(clip) {
247 // Stop other clips in the same scene
248 this.data.tracks[clip.trackIndex].clips
249 .filter(c => c.scene === clip.scene && c.id !== clip.id)
250 .forEach(c => {
251 this.activeClips.delete(c.id);
252 c.isActive = false;
253 });
254
255 // Start this clip
256 this.activeClips.set(clip.id, {
257 startBeat: this.currentBeat,
258 loopCount: 0
259 });
260 clip.isActive = true;
261
262 // Random chance to trigger clips in other tracks
263 if (Math.random() > 0.8) {
264 setTimeout(() => {
265 const otherTrackClips = this.data.clips.filter(c =>
266 c.trackIndex !== clip.trackIndex && !c.isActive && Math.random() > 0.7
267 );
268 if (otherTrackClips.length > 0) {
269 const randomClip = otherTrackClips[Math.floor(Math.random() * otherTrackClips.length)];
270 this.triggerClip(randomClip);
271 }
272 }, 500 + Math.random() * 2000);
273 }
274 }
275
276 setupInput() {
277 if (process.stdin.isTTY) {
278 process.stdin.setRawMode(true);
279 process.stdin.resume();
280 process.stdin.on('data', (key) => {
281 const keyStr = key.toString();
282
283 if (key[0] === 3 || keyStr === 'q') { // Ctrl+C or 'q'
284 this.stop();
285 } else if (keyStr === ' ') {
286 this.togglePlayback();
287 } else if (keyStr >= '1' && keyStr <= '8') {
288 // Trigger scene
289 const scene = parseInt(keyStr) - 1;
290 this.triggerScene(scene);
291 } else if (keyStr === 't') {
292 // Random trigger
293 this.randomTrigger();
294 }
295 });
296 }
297 }
298
299 triggerScene(sceneIndex) {
300 if (sceneIndex >= this.data.scenes) return;
301
302 this.data.tracks.forEach(track => {
303 const sceneClips = track.clips.filter(clip => clip.scene === sceneIndex);
304 if (sceneClips.length > 0) {
305 const clipToTrigger = sceneClips[Math.floor(Math.random() * sceneClips.length)];
306 this.triggerClip(clipToTrigger);
307 }
308 });
309 }
310
311 randomTrigger() {
312 const inactiveClips = this.data.clips.filter(clip => !clip.isActive);
313 if (inactiveClips.length > 0) {
314 const randomClip = inactiveClips[Math.floor(Math.random() * inactiveClips.length)];
315 this.triggerClip(randomClip);
316 }
317 }
318
319 start() {
320 console.clear();
321 console.log(chalk.green('🎛️ Ableton Live Session View'));
322 console.log(chalk.gray('Press SPACE to play/pause, 1-8 to trigger scenes, T for random trigger, Q to quit\n'));
323
324 this.isPlaying = true;
325 this.startTime = Date.now();
326
327 this.interval = setInterval(() => {
328 if (this.isPlaying) {
329 this.updateCurrentBeat();
330 this.updateClipStates();
331 this.updateTrackOutputs();
332 this.updateAggregateData();
333 }
334 this.render();
335 }, 1000 / this.fps);
336 }
337
338 updateCurrentBeat() {
339 const elapsed = (Date.now() - this.startTime) / 1000;
340 const beatsPerSecond = this.data.tempo / 60;
341 this.currentBeat = elapsed * beatsPerSecond;
342 }
343
344 updateClipStates() {
345 for (const [clipId, clipState] of this.activeClips.entries()) {
346 const clip = this.data.clips.find(c => c.id === clipId);
347 if (!clip) continue;
348
349 const elapsed = this.currentBeat - clipState.startBeat;
350
351 // Check if clip should loop
352 if (elapsed >= clip.loopLength) {
353 clipState.startBeat = this.currentBeat;
354 clipState.loopCount++;
355
356 // Sometimes stop clips after several loops
357 if (clipState.loopCount > 2 + Math.random() * 4) {
358 if (Math.random() > 0.7) {
359 this.activeClips.delete(clipId);
360 clip.isActive = false;
361 }
362 }
363 }
364 }
365 }
366
367 updateTrackOutputs() {
368 this.data.tracks.forEach((track, index) => {
369 let activity = 0;
370
371 // Calculate activity from active clips
372 track.clips.forEach(clip => {
373 if (clip.isActive) {
374 const clipState = this.activeClips.get(clip.id);
375 if (clipState) {
376 const elapsed = this.currentBeat - clipState.startBeat;
377 const position = elapsed % clip.loopLength;
378
379 // Check for notes at current position
380 clip.notes.forEach(note => {
381 if (Math.abs(note.time - position) < 0.1) {
382 activity += note.velocity / 127;
383 }
384 });
385 }
386 }
387 });
388
389 // Smooth activity decay
390 const currentActivity = this.trackOutputs.get(index) || 0;
391 const newActivity = Math.max(activity, currentActivity * 0.9);
392 this.trackOutputs.set(index, newActivity);
393 track.activity = newActivity;
394 track.level = Math.min(1, newActivity);
395 });
396 }
397
398 updateAggregateData() {
399 // Aggregate all track outputs
400 let totalActivity = 0;
401 this.data.tracks.forEach(track => {
402 totalActivity += track.activity;
403 });
404
405 this.aggregateData.push(totalActivity);
406 if (this.aggregateData.length > 60) {
407 this.aggregateData.shift();
408 }
409 }
410
411 togglePlayback() {
412 this.isPlaying = !this.isPlaying;
413 if (this.isPlaying) {
414 this.startTime = Date.now() - (this.currentBeat / (this.data.tempo / 60)) * 1000;
415 }
416 }
417
418 stop() {
419 if (this.interval) {
420 clearInterval(this.interval);
421 }
422 console.log(chalk.yellow('\n👋 Session ended!'));
423 process.exit(0);
424 }
425
426 getColorFunction(colorIndex) {
427 const colors = [
428 chalk.red, chalk.green, chalk.yellow, chalk.blue,
429 chalk.magenta, chalk.cyan, chalk.white, chalk.gray,
430 chalk.redBright, chalk.greenBright, chalk.yellowBright, chalk.blueBright,
431 chalk.magentaBright, chalk.cyanBright, chalk.whiteBright
432 ];
433 return colors[colorIndex % colors.length] || chalk.white;
434 }
435
436 renderSessionGrid() {
437 const lines = [];
438
439 // Header with track names
440 let header = ' ';
441 this.data.tracks.forEach((track, index) => {
442 const trackName = track.name.substring(0, 8).padEnd(8);
443 const colorFn = this.getColorFunction(track.color);
444 header += colorFn(trackName) + ' ';
445 });
446 lines.push(header);
447
448 // Scene rows
449 for (let scene = 0; scene < this.data.scenes; scene++) {
450 let row = `${scene + 1} │ `;
451
452 this.data.tracks.forEach((track, trackIndex) => {
453 const sceneClips = track.clips.filter(clip => clip.scene === scene);
454
455 if (sceneClips.length > 0) {
456 const clip = sceneClips[0];
457 const colorFn = this.getColorFunction(clip.color);
458
459 if (clip.isActive) {
460 const clipState = this.activeClips.get(clip.id);
461 const elapsed = clipState ? this.currentBeat - clipState.startBeat : 0;
462 const position = elapsed % clip.loopLength;
463 const intensity = Math.floor((position / clip.loopLength) * 4);
464 const symbols = ['▁', '▃', '▅', '█'];
465 row += colorFn(`[${symbols[intensity]}${symbols[intensity]}${symbols[intensity]}]`) + ' ';
466 } else {
467 row += chalk.dim(colorFn('[░░░]')) + ' ';
468 }
469 } else {
470 row += chalk.gray(' · ') + ' ';
471 }
472 });
473
474 lines.push(row);
475 }
476
477 return lines;
478 }
479
480 renderTrackMeters() {
481 const lines = [];
482
483 this.data.tracks.forEach((track, index) => {
484 const activity = track.activity;
485 const level = Math.floor(activity * 20);
486 const meter = '█'.repeat(Math.min(level, 20)) + '░'.repeat(Math.max(0, 20 - level));
487 const colorFn = this.getColorFunction(track.color);
488
489 const trackName = track.name.substring(0, 10).padEnd(10);
490 lines.push(`${colorFn(trackName)} │${colorFn(meter)}│ ${(activity * 100).toFixed(0).padStart(3)}%`);
491 });
492
493 return lines;
494 }
495
496 renderAggregateStream() {
497 if (this.aggregateData.length < 2) return '';
498
499 const maxVal = Math.max(...this.aggregateData, 1);
500 const stream = this.aggregateData.map(val => {
501 const normalized = val / maxVal;
502 if (normalized > 0.8) return chalk.red('█');
503 if (normalized > 0.6) return chalk.yellow('▇');
504 if (normalized > 0.4) return chalk.green('▅');
505 if (normalized > 0.2) return chalk.cyan('▃');
506 return chalk.gray('▁');
507 }).join('');
508
509 return stream;
510 }
511
512 render() {
513 process.stdout.write('\x1B[2J\x1B[H');
514
515 const statusIcon = this.isPlaying ? '▶️' : '⏸️';
516 const activeClipCount = this.activeClips.size;
517
518 // Header
519 console.log(chalk.bold.blue('🎛️ Ableton Live Session View'));
520 console.log(chalk.gray('━'.repeat(80)));
521
522 // Status
523 console.log(`${statusIcon} Beat: ${this.currentBeat.toFixed(2)} | Tempo: ${this.data.tempo} BPM | Active Clips: ${activeClipCount}`);
524 console.log('');
525
526 // Session Grid
527 console.log(chalk.bold('Session Grid:'));
528 const gridLines = this.renderSessionGrid();
529 gridLines.forEach(line => console.log(line));
530
531 console.log('');
532
533 // Track Meters
534 console.log(chalk.bold('Track Activity:'));
535 const meterLines = this.renderTrackMeters();
536 meterLines.forEach(line => console.log(line));
537
538 console.log('');
539
540 // Aggregate Data Stream
541 console.log(chalk.bold('Aggregate Output:'));
542 console.log(this.renderAggregateStream());
543
544 console.log('');
545 console.log(chalk.gray('Controls: SPACE=play/pause, 1-8=trigger scenes, T=random trigger, Q=quit'));
546 }
547}
548
549// Main execution
550async function main() {
551 const xmlPath = process.argv[2] ||
552 (existsSync(DEFAULT_XML_PATH) ? DEFAULT_XML_PATH : FALLBACK_XML_PATH);
553
554 if (!existsSync(xmlPath)) {
555 console.error(chalk.red('❌ XML file not found:'), xmlPath);
556 console.log(chalk.yellow('Usage: node ableton-session-viewer.mjs [path-to-extracted.xml]'));
557 process.exit(1);
558 }
559
560 try {
561 const parser = new AbletonSessionParser();
562 const sessionData = await parser.parseXML(xmlPath);
563
564 console.log(chalk.green('✅ Session loaded!'));
565 console.log(chalk.gray(`Tracks: ${sessionData.tracks.length}`));
566 console.log(chalk.gray(`Clips: ${sessionData.clips.length}`));
567 console.log(chalk.gray(`Scenes: ${sessionData.scenes}`));
568 console.log('');
569
570 const visualizer = new SessionVisualizer(sessionData);
571 visualizer.start();
572
573 } catch (error) {
574 console.error(chalk.red('❌ Error:'), error.message);
575 process.exit(1);
576 }
577}
578
579if (import.meta.url === `file://${process.argv[1]}`) {
580 main();
581}