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