Spaces:
Paused
Paused
Upload 3 files
Browse files
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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:
|