Monorepo for Aesthetic.Computer aesthetic.computer
at main 392 lines 11 kB view raw
1#!/usr/bin/env node 2/** 3 * 🎹 DAW Debug - CDP bridge for M4L jweb~ debugging 4 * 5 * Connects to the Chrome DevTools Protocol exposed by Max's jweb~ 6 * and forwards console logs to artery-tui or stdout. 7 * 8 * Usage: 9 * node daw-debug.mjs # Connect and show console logs 10 * node daw-debug.mjs --json # Output as JSON (for piping) 11 * node daw-debug.mjs --eval "code" # Evaluate JS in jweb~ 12 */ 13 14import WebSocket from 'ws'; 15import http from 'http'; 16 17const RESET = '\x1b[0m'; 18const BOLD = '\x1b[1m'; 19const DIM = '\x1b[2m'; 20const FG_RED = '\x1b[31m'; 21const FG_GREEN = '\x1b[32m'; 22const FG_YELLOW = '\x1b[33m'; 23const FG_BLUE = '\x1b[34m'; 24const FG_MAGENTA = '\x1b[35m'; 25const FG_CYAN = '\x1b[36m'; 26const FG_GRAY = '\x1b[90m'; 27 28// Default port for jweb~ CDP (can be overridden) 29const DAW_CDP_PORT = parseInt(process.env.DAW_CDP_PORT || '9229'); 30 31// Check if we're in a container 32const IN_CONTAINER = process.env.REMOTE_CONTAINERS === 'true' || 33 process.env.CODESPACES === 'true' || 34 process.env.container === 'true'; 35 36// Try multiple hosts 37const CDP_HOSTS = IN_CONTAINER 38 ? ['host.docker.internal', '172.17.0.1', 'localhost'] 39 : ['localhost', '127.0.0.1']; 40 41class DAWDebugger { 42 constructor(options = {}) { 43 this.ws = null; 44 this.msgId = 1; 45 this.pending = new Map(); 46 this.host = null; 47 this.port = DAW_CDP_PORT; 48 this.connected = false; 49 this.targetInfo = null; 50 this.jsonOutput = options.json || false; 51 this.onLog = options.onLog || this.defaultLogHandler.bind(this); 52 this.onConnect = options.onConnect || (() => {}); 53 this.onDisconnect = options.onDisconnect || (() => {}); 54 } 55 56 log(msg) { 57 if (!this.jsonOutput) { 58 console.log(msg); 59 } 60 } 61 62 defaultLogHandler(entry) { 63 const { type, text, timestamp, source } = entry; 64 const time = new Date(timestamp).toLocaleTimeString('en-US', { 65 hour12: false, 66 hour: '2-digit', 67 minute: '2-digit', 68 second: '2-digit', 69 fractionalSecondDigits: 3 70 }); 71 72 if (this.jsonOutput) { 73 console.log(JSON.stringify(entry)); 74 return; 75 } 76 77 let color = FG_GRAY; 78 let prefix = ' '; 79 switch (type) { 80 case 'error': 81 color = FG_RED; 82 prefix = '❌'; 83 break; 84 case 'warning': 85 color = FG_YELLOW; 86 prefix = '⚠️ '; 87 break; 88 case 'info': 89 color = FG_CYAN; 90 prefix = 'ℹ️ '; 91 break; 92 case 'log': 93 color = FG_GREEN; 94 prefix = '📝'; 95 break; 96 case 'debug': 97 color = FG_MAGENTA; 98 prefix = '🔍'; 99 break; 100 } 101 102 const sourceTag = source ? `${FG_GRAY}[${source}]${RESET} ` : ''; 103 console.log(`${DIM}${time}${RESET} ${prefix} ${sourceTag}${color}${text}${RESET}`); 104 } 105 106 async findCDPHost() { 107 for (const host of CDP_HOSTS) { 108 try { 109 const targets = await this.fetchTargets(host, this.port); 110 if (targets && targets.length > 0) { 111 return { host, targets }; 112 } 113 } catch (e) { 114 // Try next host 115 } 116 } 117 return null; 118 } 119 120 fetchTargets(host, port) { 121 return new Promise((resolve, reject) => { 122 const req = http.get({ 123 hostname: host, 124 port: port, 125 path: '/json', 126 timeout: 2000, 127 headers: { 'Host': 'localhost' } // Required for CDP 128 }, (res) => { 129 let data = ''; 130 res.on('data', chunk => data += chunk); 131 res.on('end', () => { 132 try { 133 resolve(JSON.parse(data)); 134 } catch (e) { 135 reject(e); 136 } 137 }); 138 }); 139 req.on('error', reject); 140 req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); 141 }); 142 } 143 144 async connect() { 145 this.log(`${FG_CYAN}🎹 DAW Debug - Searching for jweb~ on port ${this.port}...${RESET}`); 146 147 const result = await this.findCDPHost(); 148 if (!result) { 149 this.log(`${FG_RED}❌ No jweb~ CDP found on port ${this.port}${RESET}`); 150 this.log(`${FG_YELLOW} Make sure Ableton is running with an AC M4L device loaded${RESET}`); 151 return false; 152 } 153 154 const { host, targets } = result; 155 this.host = host; 156 157 // Find the page target (should be metronome or other AC piece) 158 const pageTarget = targets.find(t => t.type === 'page'); 159 if (!pageTarget) { 160 this.log(`${FG_RED}❌ No page target found in CDP${RESET}`); 161 return false; 162 } 163 164 this.targetInfo = pageTarget; 165 this.log(`${FG_GREEN}✓ Found: ${pageTarget.title}${RESET}`); 166 this.log(`${FG_GRAY} URL: ${pageTarget.url}${RESET}`); 167 168 // Build WebSocket URL 169 let wsUrl = pageTarget.webSocketDebuggerUrl; 170 if (wsUrl.includes('localhost') && host !== 'localhost') { 171 // Fix host in WebSocket URL for container access 172 wsUrl = wsUrl.replace(/ws:\/\/localhost/, `ws://${host}:${this.port}`); 173 } 174 175 this.log(`${FG_GRAY} Connecting to: ${wsUrl}${RESET}`); 176 177 return new Promise((resolve, reject) => { 178 this.ws = new WebSocket(wsUrl); 179 180 this.ws.on('open', async () => { 181 this.connected = true; 182 this.log(`${FG_GREEN}✓ Connected to jweb~ debugger${RESET}\n`); 183 184 // Enable console and runtime 185 await this.send('Runtime.enable'); 186 await this.send('Console.enable'); 187 await this.send('Log.enable'); 188 189 this.onConnect(this.targetInfo); 190 resolve(true); 191 }); 192 193 this.ws.on('message', (data) => { 194 const msg = JSON.parse(data.toString()); 195 196 // Handle pending responses 197 if (msg.id && this.pending.has(msg.id)) { 198 this.pending.get(msg.id)(msg); 199 this.pending.delete(msg.id); 200 return; 201 } 202 203 // Handle events 204 this.handleEvent(msg); 205 }); 206 207 this.ws.on('close', () => { 208 this.connected = false; 209 this.log(`${FG_YELLOW}⚠️ Disconnected from jweb~${RESET}`); 210 this.onDisconnect(); 211 }); 212 213 this.ws.on('error', (err) => { 214 this.log(`${FG_RED}❌ WebSocket error: ${err.message}${RESET}`); 215 reject(err); 216 }); 217 218 setTimeout(() => reject(new Error('Connection timeout')), 5000); 219 }); 220 } 221 222 handleEvent(msg) { 223 if (!msg.method) return; 224 225 switch (msg.method) { 226 case 'Console.messageAdded': { 227 const m = msg.params.message; 228 this.onLog({ 229 type: m.level, 230 text: m.text, 231 timestamp: Date.now(), 232 source: m.source, 233 url: m.url, 234 line: m.line 235 }); 236 break; 237 } 238 239 case 'Runtime.consoleAPICalled': { 240 const { type, args, timestamp } = msg.params; 241 const text = args.map(a => { 242 if (a.type === 'string') return a.value; 243 if (a.type === 'number') return a.value; 244 if (a.type === 'boolean') return a.value; 245 if (a.type === 'undefined') return 'undefined'; 246 if (a.type === 'object' && a.preview) { 247 return JSON.stringify(a.preview.properties?.reduce((acc, p) => { 248 acc[p.name] = p.value; 249 return acc; 250 }, {}) || a.description); 251 } 252 return a.description || a.value || `[${a.type}]`; 253 }).join(' '); 254 255 this.onLog({ 256 type, 257 text, 258 timestamp: timestamp / 1000, // Convert from microseconds 259 source: 'console' 260 }); 261 break; 262 } 263 264 case 'Runtime.exceptionThrown': { 265 const { exceptionDetails } = msg.params; 266 this.onLog({ 267 type: 'error', 268 text: exceptionDetails.text + (exceptionDetails.exception?.description || ''), 269 timestamp: Date.now(), 270 source: 'exception', 271 url: exceptionDetails.url, 272 line: exceptionDetails.lineNumber 273 }); 274 break; 275 } 276 277 case 'Log.entryAdded': { 278 const { entry } = msg.params; 279 this.onLog({ 280 type: entry.level, 281 text: entry.text, 282 timestamp: entry.timestamp, 283 source: entry.source, 284 url: entry.url 285 }); 286 break; 287 } 288 } 289 } 290 291 send(method, params = {}) { 292 return new Promise((resolve, reject) => { 293 if (!this.ws || !this.connected) { 294 reject(new Error('Not connected')); 295 return; 296 } 297 298 const id = this.msgId++; 299 const timer = setTimeout(() => { 300 this.pending.delete(id); 301 reject(new Error('Timeout')); 302 }, 10000); 303 304 this.pending.set(id, (msg) => { 305 clearTimeout(timer); 306 if (msg.error) { 307 reject(new Error(msg.error.message)); 308 } else { 309 resolve(msg.result); 310 } 311 }); 312 313 this.ws.send(JSON.stringify({ id, method, params })); 314 }); 315 } 316 317 async evaluate(expression) { 318 const result = await this.send('Runtime.evaluate', { 319 expression, 320 returnByValue: true, 321 awaitPromise: true 322 }); 323 return result.result?.value; 324 } 325 326 async getDAWState() { 327 return this.evaluate(` 328 (function() { 329 if (typeof $commonApi !== 'undefined' && $commonApi.sound) { 330 const daw = $commonApi.sound.daw; 331 return daw ? { 332 bpm: daw.bpm, 333 playing: daw.playing, 334 time: daw.time, 335 sampleRate: daw.sampleRate 336 } : null; 337 } 338 return null; 339 })() 340 `); 341 } 342 343 disconnect() { 344 if (this.ws) { 345 this.ws.close(); 346 this.ws = null; 347 } 348 this.connected = false; 349 } 350} 351 352// CLI usage 353if (import.meta.url === `file://${process.argv[1]}`) { 354 const args = process.argv.slice(2); 355 const jsonOutput = args.includes('--json'); 356 const evalCode = args.includes('--eval') ? args[args.indexOf('--eval') + 1] : null; 357 const stateCheck = args.includes('--state'); 358 359 const debugger_ = new DAWDebugger({ json: jsonOutput }); 360 361 try { 362 await debugger_.connect(); 363 364 if (evalCode) { 365 // One-shot eval 366 const result = await debugger_.evaluate(evalCode); 367 console.log(jsonOutput ? JSON.stringify(result) : result); 368 debugger_.disconnect(); 369 process.exit(0); 370 } else if (stateCheck) { 371 // One-shot state check 372 const state = await debugger_.getDAWState(); 373 console.log(jsonOutput ? JSON.stringify(state) : state); 374 debugger_.disconnect(); 375 process.exit(0); 376 } else { 377 // Stream console logs 378 console.log(`${FG_CYAN}📡 Streaming console logs from jweb~... (Ctrl+C to stop)${RESET}\n`); 379 380 // Keep process alive 381 process.on('SIGINT', () => { 382 debugger_.disconnect(); 383 process.exit(0); 384 }); 385 } 386 } catch (err) { 387 console.error(`${FG_RED}Error: ${err.message}${RESET}`); 388 process.exit(1); 389 } 390} 391 392export default DAWDebugger;