CoolFace
Apppublic

SaifuddinHanif/ai-music-engineer

sourceHugging Facemitupdated 6mo agoView on Hugging Face
0likes
server.js457 linesDownload Raw Back to ui
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