SaifuddinHanif/ai-music-engineer
0
1/**2 * AI Music Engineer — Web UI Server3 * REST API + Server-Sent Events for real-time pipeline updates4 * No external dependencies — pure Node.js http module5 */6 7"use strict";8 9const http = require("http");10const fs = require("fs");11const path = require("path");12const url = require("url");13const crypto = require("crypto");14 15const { Orchestrator } = require("../index");16const Project = require("../core/project");17const config = require("../../config/default");18 19const PORT = process.env.PORT || 3000;20const STATIC_DIR = path.join(__dirname, "static");21 22// ─── MIME types ───────────────────────────────────────────────────────────────23const MIME = {24 ".html": "text/html; charset=utf-8",25 ".css": "text/css; charset=utf-8",26 ".js": "application/javascript; charset=utf-8",27 ".json": "application/json",28 ".png": "image/png",29 ".svg": "image/svg+xml",30 ".ico": "image/x-icon",31 ".wav": "audio/wav",32 ".mp3": "audio/mpeg"33};34 35// ─── SSE client registry ──────────────────────────────────────────────────────36const sseClients = new Map(); // projectId → [res, ...]37 38function sseSubscribe(projectId, res) {39 res.writeHead(200, {40 "Content-Type": "text/event-stream",41 "Cache-Control": "no-cache",42 "Connection": "keep-alive",43 "Access-Control-Allow-Origin": "*"44 });45 if (res.flushHeaders) res.flushHeaders();46 res.write("retry: 1000\n\n");47 48 if (!sseClients.has(projectId)) sseClients.set(projectId, []);49 sseClients.get(projectId).push(res);50 console.log(`[SSE] Client connected to ${projectId}`);51 52 // Heartbeat53 const hb = setInterval(() => {54 try { res.write(": heartbeat\n\n"); }55 catch { clearInterval(hb); }56 }, 15000);57 58 res.on("close", () => {59 console.log(`[SSE] Client disconnected from ${projectId}`);60 clearInterval(hb);61 const list = sseClients.get(projectId) || [];62 sseClients.set(projectId, list.filter(r => r !== res));63 });64}65 66function sseEmit(projectId, event, data) {67 const clients = sseClients.get(projectId) || [];68 const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;69 let sentCount = 0;70 for (const res of clients) {71 try { res.write(payload); sentCount++; }72 catch {}73 }74 // Also broadcast to wildcard "*" subscribers75 const all = sseClients.get("*") || [];76 for (const res of all) {77 try { res.write(payload); sentCount++; }78 catch {}79 }80 console.log(`[SSE] Emitted '${event}' to ${projectId} (${sentCount} clients)`);81}82 83// ─── Orchestrator singleton ───────────────────────────────────────────────────84const orch = new Orchestrator();85 86// ─── Router ───────────────────────────────────────────────────────────────────87async function router(req, res) {88 const parsed = url.parse(req.url, true);89 const pathname = parsed.pathname;90 const method = req.method;91 92 // CORS93 res.setHeader("Access-Control-Allow-Origin", "*");94 res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");95 res.setHeader("Access-Control-Allow-Headers", "Content-Type");96 if (method === "OPTIONS") { res.writeHead(204); res.end(); return; }97 98 // JSON helper99 const json = (data, status = 200) => {100 res.writeHead(status, { "Content-Type": "application/json" });101 res.end(JSON.stringify(data, null, 2));102 };103 const err = (msg, status = 400) => json({ error: msg }, status);104 105 // Body reader106 const body = () => new Promise((resolve, reject) => {107 let data = "";108 req.on("data", c => data += c);109 req.on("end", () => {110 try { resolve(data ? JSON.parse(data) : {}); }111 catch { resolve({}); }112 });113 req.on("error", reject);114 });115 116 // ── API Routes ─────────────────────────────────────────────────────────────117 118 // POST /api/upload — direct file upload from browser (multipart/form-data)119 // Works from any OS — Windows paths in Content-Disposition are stripped to basename.120 if (pathname === "/api/upload" && method === "POST") {121 const uploadDir = process.env.AUDIO_DIR || path.join(__dirname, "../../audio");122 fs.mkdirSync(uploadDir, { recursive: true });123 124 const contentType = req.headers["content-type"] || "";125 const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);126 if (!boundaryMatch) return err("Expected multipart/form-data");127 128 const boundary = "--" + boundaryMatch[1];129 const uploaded = [];130 131 // Read raw body as buffer132 const rawBody = await new Promise((resolve, reject) => {133 const chunks = [];134 req.on("data", c => chunks.push(c));135 req.on("end", () => resolve(Buffer.concat(chunks)));136 req.on("error", reject);137 });138 139 // Parse multipart manually140 const rawStr = rawBody.toString("binary");141 const parts = rawStr.split(boundary).slice(1);142 143 for (const part of parts) {144 if (part.trim() === "--" || part.trim() === "--\r\n") continue;145 146 const headerEnd = part.indexOf("\r\n\r\n");147 if (headerEnd === -1) continue;148 149 const headers = part.slice(0, headerEnd);150 const bodyBin = part.slice(headerEnd + 4, part.lastIndexOf("\r\n"));151 152 const fileMatch = headers.match(/filename="([^"]+)"/i);153 if (!fileMatch) continue;154 155 // ALWAYS extract just the basename — browsers (especially on Windows) may156 // include the full local path like "D:\Music\song.wav" in filename=157 const rawFilename = fileMatch[1];158 const basename = rawFilename159 .replace(/\\/g, "/") // normalise backslashes160 .split("/").pop() // take only the filename part161 .trim();162 163 if (!basename) continue;164 165 // Sanitise: keep alphanumeric, dots, hyphens, underscores, spaces166 const safeName = basename.replace(/[^a-zA-Z0-9._\- ()]/g, "_");167 const destPath = path.join(uploadDir, safeName);168 169 const buf = Buffer.from(bodyBin, "binary");170 fs.writeFileSync(destPath, buf);171 172 const ext = path.extname(safeName).slice(1).toLowerCase();173 const validAudio = ["wav","aiff","aif","flac","mp3","aac","m4a","zip"].includes(ext);174 uploaded.push({175 originalName: rawFilename,176 filename: safeName,177 path: destPath, // always a clean Linux absolute path178 size: buf.length,179 valid: validAudio,180 warning: !validAudio181 ? `${ext || "unknown"} is not a supported audio format`182 : ["mp3","aac","m4a"].includes(ext)183 ? "Lossy format — lossless preferred"184 : null185 });186 }187 188 if (!uploaded.length) return err("No files received in upload");189 return json({ uploaded, count: uploaded.length });190 }191 192 // GET /api/health193 if (pathname === "/api/health" && method === "GET") {194 return json({ status: "ok", version: "1.0.0", ts: new Date().toISOString() });195 }196 197 // GET /api/config198 if (pathname === "/api/config" && method === "GET") {199 return json({200 loudnessTargets: config.loudnessTargets,201 stemPresets: config.stemPresets,202 autonomyLevels: config.autonomy.levels,203 formats: config.formats,204 busStructure: config.busStructure205 });206 }207 208 // GET /api/projects209 if (pathname === "/api/projects" && method === "GET") {210 const projects = orch.listProjects();211 return json(projects.map(p => ({212 id: p.id, artist: p.artist, title: p.title,213 createdAt: p.createdAt, tracks: p.tracks.length,214 outputs: p.outputs.length, versions: p.versions215 })));216 }217 218 // POST /api/projects219 if (pathname === "/api/projects" && method === "POST") {220 const data = await body();221 222 // Use all paths without filtering out Windows paths223 const allPaths = [...(data.files || []), ...(data.references || [])];224 const goodPaths = (data.files || []).filter(Boolean);225 226 if (goodPaths.length > 0) {227 data.files = goodPaths;228 }229 230 const project = orch.createProject(data);231 232 let fileResult = { added: [], missing: [] };233 if (data.files?.length) {234 fileResult = orch.addFiles(project, data.files);235 }236 if (data.references?.length) {237 project.references = data.references.filter(r => r && typeof r === "string");238 project.save();239 }240 241 return json({242 id: project.id,243 projectDir: project.projectDir,244 project: project.toJSON(),245 filesAdded: fileResult.added?.length || 0,246 filesMissing: fileResult.missing?.length || 0,247 missingPaths: fileResult.missing || [],248 warning: fileResult.missing?.length249 ? `${fileResult.missing.length} file(s) not found on server. Verify paths are correct Linux paths.`250 : null251 });252 }253 254 // POST /api/find-files — search for audio files by name on the server255 // Helps users locate files when they only know the filename256 if (pathname === "/api/find-files" && method === "POST") {257 const data = await body();258 const names = (data.names || []).filter(Boolean);259 const roots = data.searchRoots || [260 process.env.AUDIO_DIR || path.join(__dirname, "../../audio"),261 "/data/audio",262 "/home", "/tmp", "/root", process.cwd()263 ];264 const found = {};265 const { spawnSync } = require("child_process");266 267 for (const name of names.slice(0, 10)) { // max 10 files268 const safeName = name.replace(/['"\\]/g, "");269 // Use `find` to search common locations270 for (const root of roots.slice(0, 4)) {271 if (!fs.existsSync(root)) continue;272 const res = spawnSync("find", [273 root, "-maxdepth", "6",274 "-iname", safeName,275 "-not", "-path", "*/node_modules/*",276 "-not", "-path", "*/.git/*"277 ], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024 });278 279 if (res.stdout) {280 const paths = res.stdout.trim().split("\n").filter(Boolean);281 if (paths.length) { found[name] = paths; break; }282 }283 }284 if (!found[name]) found[name] = [];285 }286 return json({ found });287 }288 289 // GET /api/projects/:id290 const projMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);291 if (projMatch && method === "GET") {292 try {293 const p = orch.loadProject(path.join(config.paths.projects, projMatch[1]));294 return json(p.toJSON());295 } catch (e) { return err("Project not found", 404); }296 }297 298 // POST /api/projects/:id/files — register file paths299 const filesMatch = pathname.match(/^\/api\/projects\/([^/]+)\/files$/);300 if (filesMatch && method === "POST") {301 try {302 const p = orch.loadProject(path.join(config.paths.projects, filesMatch[1]));303 const data = await body();304 if (!Array.isArray(data.files)) return err("files must be an array of paths");305 orch.addFiles(p, data.files);306 return json({ added: data.files.length, tracks: p.tracks.length });307 } catch (e) { return err(e.message, 404); }308 }309 310 // POST /api/projects/:id/run — start pipeline311 const runMatch = pathname.match(/^\/api\/projects\/([^/]+)\/run$/);312 if (runMatch && method === "POST") {313 try {314 const p = orch.loadProject(path.join(config.paths.projects, runMatch[1]));315 const data = await body();316 317 // Fire & forget — emit SSE events318 orch.run(p, {319 fromStage: data.fromStage || "INGEST",320 autonomyLevel: data.autonomyLevel ?? 2,321 onEvent: (event, evData) => {322 sseEmit(p.id, event, evData);323 // Also emit specific events that the UI needs to handle324 if (event === "missing_info") sseEmit(p.id, "missing_info", evData);325 if (event === "escalation") sseEmit(p.id, "escalation", evData);326 }327 }).then(result => {328 sseEmit(p.id, "pipeline_complete", result.summary);329 }).catch(e => {330 sseEmit(p.id, "pipeline_error", { error: e.message });331 });332 333 return json({ status: "started", projectId: p.id });334 } catch (e) { return err(e.message); }335 }336 337 // POST /api/projects/:id/stage — run single stage338 const stageMatch = pathname.match(/^\/api\/projects\/([^/]+)\/stage$/);339 if (stageMatch && method === "POST") {340 try {341 const p = orch.loadProject(path.join(config.paths.projects, stageMatch[1]));342 const data = await body();343 if (!data.stage) return err("stage is required");344 345 orch.runStage(p, data.stage, { autonomyLevel: data.autonomyLevel ?? 2 })346 .then(result => sseEmit(p.id, "stage_complete", { stage: data.stage, result }))347 .catch(e => sseEmit(p.id, "stage_failed", { stage: data.stage, error: e.message }));348 349 return json({ status: "started", stage: data.stage });350 } catch (e) { return err(e.message); }351 }352 353 // POST /api/projects/:id/approve354 const approveMatch = pathname.match(/^\/api\/projects\/([^/]+)\/approve$/);355 if (approveMatch && method === "POST") {356 try {357 const data = await body();358 orch.grantApproval(approveMatch[1], data.stage, data.feedback);359 return json({ status: "approved", stage: data.stage });360 } catch (e) { return err(e.message); }361 }362 363 // POST /api/projects/:id/abort364 const abortMatch = pathname.match(/^\/api\/projects\/([^/]+)\/abort$/);365 if (abortMatch && method === "POST") {366 orch.abort(abortMatch[1], "user_abort");367 return json({ status: "aborted" });368 }369 370 // GET /api/projects/:id/status371 const statusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/status$/);372 if (statusMatch && method === "GET") {373 const status = orch.getStatus(statusMatch[1]);374 if (!status) return json({ status: "idle" });375 return json(status);376 }377 378 // GET /api/projects/:id/events — SSE379 const eventsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/events$/);380 if (eventsMatch && method === "GET") {381 sseSubscribe(eventsMatch[1], res);382 return;383 }384 385 // GET /api/events — global SSE386 if (pathname === "/api/events" && method === "GET") {387 sseSubscribe("*", res);388 return;389 }390 391 // POST /api/analyze — quick analyze without project392 if (pathname === "/api/analyze" && method === "POST") {393 try {394 const data = await body();395 if (!data.files?.length) return err("files required");396 const results = await orch.analyzeFiles(data.files);397 return json({ results });398 } catch (e) { return err(e.message); }399 }400 401 // ── Static files ───────────────────────────────────────────────────────────402 let filePath;403 if (pathname === "/" || pathname === "/index.html") {404 filePath = path.join(STATIC_DIR, "index.html");405 } else {406 filePath = path.join(STATIC_DIR, pathname);407 }408 409 if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {410 const ext = path.extname(filePath);411 const mime = MIME[ext] || "application/octet-stream";412 res.writeHead(200, { "Content-Type": mime });413 fs.createReadStream(filePath).pipe(res);414 return;415 }416 417 // SPA fallback — serve index.html418 const indexPath = path.join(STATIC_DIR, "index.html");419 if (fs.existsSync(indexPath)) {420 res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });421 fs.createReadStream(indexPath).pipe(res);422 return;423 }424 425 json({ error: "Not found" }, 404);426}427 428// ─── Start server ─────────────────────────────────────────────────────────────429 430function start() {431 fs.mkdirSync(STATIC_DIR, { recursive: true });432 fs.mkdirSync(config.paths.projects, { recursive: true });433 434 const server = http.createServer((req, res) => {435 router(req, res).catch(e => {436 console.error("Request error:", e);437 try {438 res.writeHead(500, { "Content-Type": "application/json" });439 res.end(JSON.stringify({ error: "Internal server error" }));440 } catch {}441 });442 });443 444 server.listen(PORT, () => {445 console.log(`\x1b[36m╔════════════════════════════════════════╗`);446 console.log(`║ AI Music Engineer UI ║`);447 console.log(`║ http://localhost:${PORT} ║`);448 console.log(`╚════════════════════════════════════════╝\x1b[0m\n`);449 });450 451 return server;452}453 454if (require.main === module) start();455 456module.exports = { start, sseEmit };457 