jayman commited on
Commit
7e1cb8d
·
verified ·
1 Parent(s): 6685d48

Upload 3 files

Browse files
Files changed (3) hide show
  1. DEPLOY.md +85 -0
  2. README.md +11 -1
  3. app.py +258 -7
DEPLOY.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying to jayman/neuralis-stem-worker
2
+
3
+ Upload these files into the root of the Hugging Face Space:
4
+
5
+ - `Dockerfile`
6
+ - `README.md`
7
+ - `app.py`
8
+ - `requirements.txt`
9
+ - `.dockerignore`
10
+
11
+ Keep `.gitignore` locally; it does not need to be uploaded.
12
+
13
+ ## Hugging Face Settings
14
+
15
+ In Space settings, add this secret:
16
+
17
+ ```text
18
+ NEURALIS_STEM_API_KEY
19
+ ```
20
+
21
+ Start on CPU Basic only to confirm the Docker build. For real testing, switch hardware to:
22
+
23
+ ```text
24
+ Nvidia T4 small
25
+ ```
26
+
27
+ Set sleep time to 15 or 30 minutes to avoid paying while idle.
28
+
29
+ ## Test URLs
30
+
31
+ Health:
32
+
33
+ ```text
34
+ https://jayman-neuralis-stem-worker.hf.space/health
35
+ ```
36
+
37
+ Browser upload page:
38
+
39
+ ```text
40
+ https://jayman-neuralis-stem-worker.hf.space/
41
+ ```
42
+
43
+ The browser page now uses a job endpoint and shows upload/separation/download progress.
44
+
45
+ ## API Test
46
+
47
+ Use the same key you saved in `NEURALIS_STEM_API_KEY`.
48
+
49
+ Fast 2-stem test:
50
+
51
+ ```bash
52
+ curl -L \
53
+ -H "X-Neuralis-Api-Key: your-key" \
54
+ -F "file=@song.wav" \
55
+ -F "mode=fast-2stem" \
56
+ -o neuralis-stems.zip \
57
+ https://jayman-neuralis-stem-worker.hf.space/separate
58
+ ```
59
+
60
+ Progress API flow:
61
+
62
+ ```bash
63
+ curl -L \
64
+ -H "X-Neuralis-Api-Key: your-key" \
65
+ -F "file=@song.wav" \
66
+ -F "mode=fast-2stem" \
67
+ https://jayman-neuralis-stem-worker.hf.space/jobs
68
+ ```
69
+
70
+ Then poll the returned job ID:
71
+
72
+ ```bash
73
+ curl -L https://jayman-neuralis-stem-worker.hf.space/jobs/JOB_ID
74
+ ```
75
+
76
+ Premium 4-stem test:
77
+
78
+ ```bash
79
+ curl -L \
80
+ -H "X-Neuralis-Api-Key: your-key" \
81
+ -F "file=@song.wav" \
82
+ -F "mode=premium-4stem" \
83
+ -o neuralis-stems-premium.zip \
84
+ https://jayman-neuralis-stem-worker.hf.space/separate
85
+ ```
README.md CHANGED
@@ -55,7 +55,15 @@ Health:
55
  GET /health
56
  ```
57
 
58
- Separate audio:
 
 
 
 
 
 
 
 
59
 
60
  ```text
61
  POST /separate
@@ -67,6 +75,8 @@ Form fields:
67
  - `mode`: optional, `fast-2stem` or `premium-4stem`
68
  - `model`: optional, defaults to `htdemucs`
69
 
 
 
70
  ## Suggested Hardware
71
 
72
  Start on CPU Basic to confirm the app builds, then switch to Nvidia T4 small for real stem separation tests.
 
55
  GET /health
56
  ```
57
 
58
+ Browser/progress workflow:
59
+
60
+ ```text
61
+ POST /jobs
62
+ GET /jobs/{job_id}
63
+ GET /jobs/{job_id}/download
64
+ ```
65
+
66
+ Direct separate audio endpoint:
67
 
68
  ```text
69
  POST /separate
 
75
  - `mode`: optional, `fast-2stem` or `premium-4stem`
76
  - `model`: optional, defaults to `htdemucs`
77
 
78
+ The browser page uses `/jobs` so it can show progress while separation is running. `/separate` remains available for direct API callers that want one request returning a ZIP.
79
+
80
  ## Suggested Hardware
81
 
82
  Start on CPU Basic to confirm the app builds, then switch to Nvidia T4 small for real stem separation tests.
app.py CHANGED
@@ -3,6 +3,7 @@ import os
3
  import shutil
4
  import subprocess
5
  import tempfile
 
6
  import time
7
  import uuid
8
  from pathlib import Path
@@ -27,8 +28,11 @@ ALLOWED_MODES = {
27
  "fast-2stem",
28
  "premium-4stem",
29
  }
 
30
 
31
  app = FastAPI(title=APP_NAME)
 
 
32
 
33
 
34
  def _client_key(request: Request) -> str:
@@ -46,6 +50,60 @@ def _require_api_key(request: Request) -> None:
46
  raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def _safe_name(filename: str) -> str:
50
  name = Path(filename or "upload.wav").name
51
  keep = []
@@ -160,6 +218,44 @@ def _make_zip(run_info: dict, work_dir: Path, original_name: str, model: str, mo
160
  return zip_path
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  @app.get("/", response_class=HTMLResponse)
164
  def index() -> str:
165
  return """
@@ -177,6 +273,13 @@ def index() -> str:
177
  label { display: block; margin: 20px 0 8px; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #b9d8d1; }
178
  input, select { width: 100%; box-sizing: border-box; padding: 12px; color: #fff; border: 1px solid #20343a; background: #070b0c; }
179
  button { margin-top: 22px; width: 100%; border: 0; padding: 14px; background: #2ff4be; color: #03110e; letter-spacing: .18em; text-transform: uppercase; cursor: pointer; }
 
 
 
 
 
 
 
180
  code { color: #2ff4be; }
181
  </style>
182
  </head>
@@ -184,7 +287,7 @@ def index() -> str:
184
  <main>
185
  <h1>Neuralis Stem Worker</h1>
186
  <p>Private Demucs worker for Neuralis. Use <code>/health</code> for status and <code>/separate</code> for API uploads.</p>
187
- <form action="/separate" method="post" enctype="multipart/form-data">
188
  <label>API Key</label>
189
  <input name="apiKey" type="password" autocomplete="off" />
190
  <label>Mode</label>
@@ -194,9 +297,80 @@ def index() -> str:
194
  </select>
195
  <label>Audio File</label>
196
  <input name="file" type="file" accept="audio/*" required />
197
- <button type="submit">Separate Stems</button>
198
  </form>
 
 
 
 
 
 
 
 
 
199
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </body>
201
  </html>
202
  """
@@ -217,6 +391,87 @@ def health() -> JSONResponse:
217
  )
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  @app.post("/separate")
221
  async def separate(
222
  request: Request,
@@ -225,11 +480,7 @@ async def separate(
225
  mode: str = Form(DEFAULT_MODE),
226
  apiKey: str = Form(""),
227
  ) -> FileResponse:
228
- expected = os.getenv("NEURALIS_STEM_API_KEY", "").strip()
229
- if expected and not _client_key(request) and apiKey.strip() != expected:
230
- raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
231
- if expected and _client_key(request) and _client_key(request) != expected:
232
- raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
233
 
234
  selected_model = (model or DEFAULT_MODEL).strip()
235
  if selected_model not in ALLOWED_MODELS:
 
3
  import shutil
4
  import subprocess
5
  import tempfile
6
+ import threading
7
  import time
8
  import uuid
9
  from pathlib import Path
 
28
  "fast-2stem",
29
  "premium-4stem",
30
  }
31
+ JOB_TTL_SECONDS = 2 * 60 * 60
32
 
33
  app = FastAPI(title=APP_NAME)
34
+ JOBS = {}
35
+ JOBS_LOCK = threading.Lock()
36
 
37
 
38
  def _client_key(request: Request) -> str:
 
50
  raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
51
 
52
 
53
+ def _check_api_key(request: Request, form_key: str = "") -> None:
54
+ expected = os.getenv("NEURALIS_STEM_API_KEY", "").strip()
55
+ if not expected:
56
+ return
57
+ header_key = _client_key(request)
58
+ if header_key and header_key != expected:
59
+ raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
60
+ if not header_key and form_key.strip() != expected:
61
+ raise HTTPException(status_code=401, detail="Invalid Neuralis stem API key")
62
+
63
+
64
+ def _set_job(job_id: str, **updates) -> None:
65
+ with JOBS_LOCK:
66
+ job = JOBS.get(job_id)
67
+ if not job:
68
+ return
69
+ job.update(updates)
70
+ job["updatedAt"] = time.time()
71
+
72
+
73
+ def _public_job(job: dict) -> dict:
74
+ status = job.get("status", "queued")
75
+ progress = float(job.get("progress", 0))
76
+ if status == "processing":
77
+ elapsed = max(0.0, time.time() - float(job.get("startedAt", time.time())))
78
+ estimate = 120.0 if job.get("mode") == "fast-2stem" else 240.0
79
+ progress = max(progress, min(92.0, 18.0 + (elapsed / estimate) * 70.0))
80
+ return {
81
+ "id": job["id"],
82
+ "status": status,
83
+ "progress": round(progress, 1),
84
+ "stage": job.get("stage", ""),
85
+ "mode": job.get("mode", DEFAULT_MODE),
86
+ "model": job.get("model", DEFAULT_MODEL),
87
+ "source": job.get("source", ""),
88
+ "downloadUrl": job.get("downloadUrl"),
89
+ "error": job.get("error"),
90
+ }
91
+
92
+
93
+ def _cleanup_old_jobs() -> None:
94
+ cutoff = time.time() - JOB_TTL_SECONDS
95
+ stale = []
96
+ with JOBS_LOCK:
97
+ for job_id, job in JOBS.items():
98
+ if float(job.get("createdAt", 0)) < cutoff:
99
+ stale.append((job_id, job.get("workDir")))
100
+ for job_id, _ in stale:
101
+ JOBS.pop(job_id, None)
102
+ for _, work_dir in stale:
103
+ if work_dir:
104
+ shutil.rmtree(work_dir, ignore_errors=True)
105
+
106
+
107
  def _safe_name(filename: str) -> str:
108
  name = Path(filename or "upload.wav").name
109
  keep = []
 
218
  return zip_path
219
 
220
 
221
+ def _process_job(job_id: str, input_path: Path, work_dir: Path, original_name: str, model: str, mode: str) -> None:
222
+ try:
223
+ _set_job(
224
+ job_id,
225
+ status="processing",
226
+ progress=16,
227
+ stage="Loading separation model",
228
+ startedAt=time.time(),
229
+ )
230
+ run_info = _run_demucs(input_path, work_dir, model, mode)
231
+ _set_job(job_id, progress=94, stage="Packing stems for download")
232
+ zip_path = _make_zip(run_info, work_dir, original_name, model, mode)
233
+ _set_job(
234
+ job_id,
235
+ status="ready",
236
+ progress=100,
237
+ stage="Stem separation complete",
238
+ zipPath=str(zip_path),
239
+ downloadUrl=f"/jobs/{job_id}/download",
240
+ )
241
+ except subprocess.TimeoutExpired as exc:
242
+ _set_job(
243
+ job_id,
244
+ status="failed",
245
+ progress=100,
246
+ stage="Stem separation timed out",
247
+ error=str(exc),
248
+ )
249
+ except Exception as exc:
250
+ _set_job(
251
+ job_id,
252
+ status="failed",
253
+ progress=100,
254
+ stage="Stem separation failed",
255
+ error=str(exc),
256
+ )
257
+
258
+
259
  @app.get("/", response_class=HTMLResponse)
260
  def index() -> str:
261
  return """
 
273
  label { display: block; margin: 20px 0 8px; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #b9d8d1; }
274
  input, select { width: 100%; box-sizing: border-box; padding: 12px; color: #fff; border: 1px solid #20343a; background: #070b0c; }
275
  button { margin-top: 22px; width: 100%; border: 0; padding: 14px; background: #2ff4be; color: #03110e; letter-spacing: .18em; text-transform: uppercase; cursor: pointer; }
276
+ button:disabled { opacity: .55; cursor: wait; }
277
+ .progress { display: none; margin-top: 24px; }
278
+ .track { height: 10px; overflow: hidden; border: 1px solid rgba(47, 244, 190, .25); background: #06100e; }
279
+ .bar { width: 0%; height: 100%; background: linear-gradient(90deg, #2ff4be, #ffd24a); transition: width .35s ease; }
280
+ .status { display: flex; justify-content: space-between; gap: 16px; margin-top: 10px; color: #b9d8d1; font-size: 13px; }
281
+ .download { display: none; margin-top: 18px; color: #2ff4be; letter-spacing: .12em; text-transform: uppercase; }
282
+ .error { display: none; margin-top: 18px; color: #ff8075; line-height: 1.4; }
283
  code { color: #2ff4be; }
284
  </style>
285
  </head>
 
287
  <main>
288
  <h1>Neuralis Stem Worker</h1>
289
  <p>Private Demucs worker for Neuralis. Use <code>/health</code> for status and <code>/separate</code> for API uploads.</p>
290
+ <form id="stemForm">
291
  <label>API Key</label>
292
  <input name="apiKey" type="password" autocomplete="off" />
293
  <label>Mode</label>
 
297
  </select>
298
  <label>Audio File</label>
299
  <input name="file" type="file" accept="audio/*" required />
300
+ <button id="submitButton" type="submit">Separate Stems</button>
301
  </form>
302
+ <section id="progressPanel" class="progress">
303
+ <div class="track"><div id="progressBar" class="bar"></div></div>
304
+ <div class="status">
305
+ <span id="statusText">Waiting</span>
306
+ <span id="percentText">0%</span>
307
+ </div>
308
+ <a id="downloadLink" class="download" href="#">Download Stems</a>
309
+ <div id="errorText" class="error"></div>
310
+ </section>
311
  </main>
312
+ <script>
313
+ const form = document.getElementById('stemForm');
314
+ const button = document.getElementById('submitButton');
315
+ const panel = document.getElementById('progressPanel');
316
+ const bar = document.getElementById('progressBar');
317
+ const statusText = document.getElementById('statusText');
318
+ const percentText = document.getElementById('percentText');
319
+ const downloadLink = document.getElementById('downloadLink');
320
+ const errorText = document.getElementById('errorText');
321
+
322
+ const setProgress = (value, stage) => {
323
+ const percent = Math.max(0, Math.min(100, Number(value) || 0));
324
+ bar.style.width = `${percent}%`;
325
+ percentText.textContent = `${Math.round(percent)}%`;
326
+ if (stage) statusText.textContent = stage;
327
+ };
328
+
329
+ const pollJob = async (id) => {
330
+ const res = await fetch(`/jobs/${id}`);
331
+ const job = await res.json();
332
+ setProgress(job.progress, job.stage || job.status);
333
+ if (job.status === 'ready') {
334
+ button.disabled = false;
335
+ button.textContent = 'Separate Stems';
336
+ downloadLink.href = job.downloadUrl;
337
+ downloadLink.style.display = 'inline-block';
338
+ statusText.textContent = 'Ready';
339
+ return;
340
+ }
341
+ if (job.status === 'failed') {
342
+ button.disabled = false;
343
+ button.textContent = 'Separate Stems';
344
+ errorText.textContent = job.error || 'Stem separation failed';
345
+ errorText.style.display = 'block';
346
+ return;
347
+ }
348
+ setTimeout(() => pollJob(id), 1500);
349
+ };
350
+
351
+ form.addEventListener('submit', async (event) => {
352
+ event.preventDefault();
353
+ button.disabled = true;
354
+ button.textContent = 'Processing';
355
+ panel.style.display = 'block';
356
+ downloadLink.style.display = 'none';
357
+ errorText.style.display = 'none';
358
+ setProgress(4, 'Uploading audio');
359
+
360
+ const data = new FormData(form);
361
+ const res = await fetch('/jobs', { method: 'POST', body: data });
362
+ const job = await res.json();
363
+ if (!res.ok) {
364
+ button.disabled = false;
365
+ button.textContent = 'Separate Stems';
366
+ errorText.textContent = job.detail || 'Upload failed';
367
+ errorText.style.display = 'block';
368
+ return;
369
+ }
370
+ setProgress(job.progress, job.stage || 'Queued');
371
+ pollJob(job.id);
372
+ });
373
+ </script>
374
  </body>
375
  </html>
376
  """
 
391
  )
392
 
393
 
394
+ @app.post("/jobs")
395
+ async def create_job(
396
+ request: Request,
397
+ file: UploadFile = File(...),
398
+ model: str = Form(DEFAULT_MODEL),
399
+ mode: str = Form(DEFAULT_MODE),
400
+ apiKey: str = Form(""),
401
+ ) -> JSONResponse:
402
+ _check_api_key(request, apiKey)
403
+
404
+ selected_model = (model or DEFAULT_MODEL).strip()
405
+ if selected_model not in ALLOWED_MODELS:
406
+ raise HTTPException(status_code=400, detail=f"Unsupported model: {selected_model}")
407
+ selected_mode = (mode or DEFAULT_MODE).strip()
408
+ if selected_mode not in ALLOWED_MODES:
409
+ raise HTTPException(status_code=400, detail=f"Unsupported mode: {selected_mode}")
410
+
411
+ _cleanup_old_jobs()
412
+ original_name = _safe_name(file.filename)
413
+ suffix = Path(original_name).suffix or ".wav"
414
+ job_id = str(uuid.uuid4())
415
+ work_dir = Path(tempfile.mkdtemp(prefix=f"neuralis-stems-{job_id}-"))
416
+
417
+ try:
418
+ input_path = work_dir / f"source{suffix}"
419
+ await _save_upload(file, input_path)
420
+ except Exception:
421
+ shutil.rmtree(work_dir, ignore_errors=True)
422
+ raise
423
+
424
+ job = {
425
+ "id": job_id,
426
+ "status": "queued",
427
+ "progress": 10,
428
+ "stage": "Upload received",
429
+ "mode": selected_mode,
430
+ "model": selected_model,
431
+ "source": original_name,
432
+ "workDir": str(work_dir),
433
+ "createdAt": time.time(),
434
+ "updatedAt": time.time(),
435
+ }
436
+ with JOBS_LOCK:
437
+ JOBS[job_id] = job
438
+
439
+ thread = threading.Thread(
440
+ target=_process_job,
441
+ args=(job_id, input_path, work_dir, original_name, selected_model, selected_mode),
442
+ daemon=True,
443
+ )
444
+ thread.start()
445
+ return JSONResponse(_public_job(job))
446
+
447
+
448
+ @app.get("/jobs/{job_id}")
449
+ def get_job(job_id: str) -> JSONResponse:
450
+ with JOBS_LOCK:
451
+ job = JOBS.get(job_id)
452
+ if not job:
453
+ raise HTTPException(status_code=404, detail="Job not found")
454
+ return JSONResponse(_public_job(job))
455
+
456
+
457
+ @app.get("/jobs/{job_id}/download")
458
+ def download_job(job_id: str) -> FileResponse:
459
+ with JOBS_LOCK:
460
+ job = JOBS.get(job_id)
461
+ if not job:
462
+ raise HTTPException(status_code=404, detail="Job not found")
463
+ if job.get("status") != "ready" or not job.get("zipPath"):
464
+ raise HTTPException(status_code=409, detail="Job is not ready")
465
+ zip_path = Path(job["zipPath"])
466
+ if not zip_path.exists():
467
+ raise HTTPException(status_code=404, detail="Stem ZIP was not found")
468
+ return FileResponse(
469
+ zip_path,
470
+ filename=f"neuralis-stems-{job_id}.zip",
471
+ media_type="application/zip",
472
+ )
473
+
474
+
475
  @app.post("/separate")
476
  async def separate(
477
  request: Request,
 
480
  mode: str = Form(DEFAULT_MODE),
481
  apiKey: str = Form(""),
482
  ) -> FileResponse:
483
+ _check_api_key(request, apiKey)
 
 
 
 
484
 
485
  selected_model = (model or DEFAULT_MODEL).strip()
486
  if selected_model not in ALLOWED_MODELS: