Monorepo for Aesthetic.Computer aesthetic.computer
at main 581 lines 18 kB view raw
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}