Spaces:
Sleeping
Sleeping
ChatGPT commited on
Commit ·
5a90820
1
Parent(s): fa35534
fix: make upload and fallback robust
Browse files- README.md +5 -4
- app.py +7 -2
- docs/API.md +8 -0
- docs/API_ERRORS_AND_PARAMETER_VALIDATION.md +8 -0
- docs/FEATURES.md +8 -0
- docs/PROGRESS.md +8 -0
- docs/REMAINING_WORK.md +1 -1
- docs/SPLEETER_AND_SEPARATION_BACKENDS.md +3 -3
- docs/TASKS.md +8 -0
- docs/UPLOAD_ERROR_AND_RUNTIME_FALLBACK.md +40 -0
- pipeline_runner.py +67 -10
- scripts/test_upload_error_visibility_and_fallback.py +74 -0
- web/app.js +59 -7
- web/index.html +6 -6
- web/styles.css +10 -5
README.md
CHANGED
|
@@ -12,7 +12,7 @@ pinned: false
|
|
| 12 |
|
| 13 |
A custom FastAPI + browser workstation for extracting, reviewing, and now semantically supervising reusable drum samples from an audio file.
|
| 14 |
|
| 15 |
-
The pipeline
|
| 16 |
|
| 17 |
## Current status
|
| 18 |
|
|
@@ -58,7 +58,7 @@ Implemented:
|
|
| 58 |
- constraint/event log.
|
| 59 |
|
| 60 |
- Spleeter source-separation backend selected by default, with `spleeter:4stems`, `spleeter:2stems`, and `spleeter:5stems` support.
|
| 61 |
-
- Optional Demucs backend
|
| 62 |
- True per-card checkbox selection and selected-only export under `selected/`.
|
| 63 |
- Persisted `draw another` card action that pins the next representative hit for the cluster.
|
| 64 |
- Immediate trim/extend card edits that rewrite preview WAVs under `overrides/hits/` and persist to supervised state.
|
|
@@ -71,7 +71,7 @@ Not fully complete yet:
|
|
| 71 |
- No cluster merge/split/relabel workflow beyond move/pull-to-new-cluster.
|
| 72 |
- No frontend TypeScript build/test harness yet.
|
| 73 |
- Spleeter progress is coarse-grained; Demucs progress exposes chunk-level work where available.
|
| 74 |
-
- Demucs remains offline/batch by design and is
|
| 75 |
|
| 76 |
See:
|
| 77 |
|
|
@@ -87,6 +87,7 @@ See:
|
|
| 87 |
- `docs/CLEAN_DEFAULT_UI.md`
|
| 88 |
- `docs/IMMEDIATE_WAVEFORM_AND_REAL_PROGRESS.md`
|
| 89 |
- `docs/API_ERRORS_AND_PARAMETER_VALIDATION.md`
|
|
|
|
| 90 |
|
| 91 |
## Run locally
|
| 92 |
|
|
@@ -105,7 +106,7 @@ For fast iteration, use the default automatic flow. To bypass source separation
|
|
| 105 |
- `Stem = all`
|
| 106 |
- `Clustering mode = online_preview`
|
| 107 |
|
| 108 |
-
That uses the full mix and the near-realtime clustering path. The default engine is Spleeter. Install it separately with `pip install -r requirements-spleeter.txt` in an environment compatible with Spleeter/TensorFlow. If Spleeter is unavailable and fallback is enabled, the app falls back to Demucs.
|
| 109 |
|
| 110 |
## Run checks
|
| 111 |
|
|
|
|
| 12 |
|
| 13 |
A custom FastAPI + browser workstation for extracting, reviewing, and now semantically supervising reusable drum samples from an audio file.
|
| 14 |
|
| 15 |
+
The pipeline is configured for Spleeter as the lightweight source-separation default when available, falls back to full-mix processing when optional separation dependencies are missing, keeps Demucs as an explicit quality backend, detects onsets, classifies hits, clusters similar transients, chooses representative samples, optionally synthesizes alternate samples, and exports WAVs, MIDI, target-stem reconstruction, full-context reproduced audio, manifests, selected-only packs, and complete ZIP sample packs. The interactive layer stores user corrections as replayable semantic state beside each run manifest.
|
| 16 |
|
| 17 |
## Current status
|
| 18 |
|
|
|
|
| 58 |
- constraint/event log.
|
| 59 |
|
| 60 |
- Spleeter source-separation backend selected by default, with `spleeter:4stems`, `spleeter:2stems`, and `spleeter:5stems` support.
|
| 61 |
+
- Optional Demucs backend for explicit higher-quality separation; Spleeter failures now fall back to full-mix processing when fallback is enabled.
|
| 62 |
- True per-card checkbox selection and selected-only export under `selected/`.
|
| 63 |
- Persisted `draw another` card action that pins the next representative hit for the cluster.
|
| 64 |
- Immediate trim/extend card edits that rewrite preview WAVs under `overrides/hits/` and persist to supervised state.
|
|
|
|
| 71 |
- No cluster merge/split/relabel workflow beyond move/pull-to-new-cluster.
|
| 72 |
- No frontend TypeScript build/test harness yet.
|
| 73 |
- Spleeter progress is coarse-grained; Demucs progress exposes chunk-level work where available.
|
| 74 |
+
- Demucs remains offline/batch by design and is treated as the higher-cost explicit quality backend.
|
| 75 |
|
| 76 |
See:
|
| 77 |
|
|
|
|
| 87 |
- `docs/CLEAN_DEFAULT_UI.md`
|
| 88 |
- `docs/IMMEDIATE_WAVEFORM_AND_REAL_PROGRESS.md`
|
| 89 |
- `docs/API_ERRORS_AND_PARAMETER_VALIDATION.md`
|
| 90 |
+
- `docs/UPLOAD_ERROR_AND_RUNTIME_FALLBACK.md`
|
| 91 |
|
| 92 |
## Run locally
|
| 93 |
|
|
|
|
| 106 |
- `Stem = all`
|
| 107 |
- `Clustering mode = online_preview`
|
| 108 |
|
| 109 |
+
That uses the full mix and the near-realtime clustering path. The default engine is Spleeter. Install it separately with `pip install -r requirements-spleeter.txt` in an environment compatible with Spleeter/TensorFlow. If Spleeter is unavailable and fallback is enabled, the app falls back to full-mix processing so the UI still works. Choose Demucs explicitly under Expert controls for slower quality separation.
|
| 110 |
|
| 111 |
## Run checks
|
| 112 |
|
app.py
CHANGED
|
@@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 24 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 25 |
from fastapi.staticfiles import StaticFiles
|
| 26 |
|
| 27 |
-
from pipeline_runner import PipelineParams, SPLEETER_MODELS, SPLEETER_STEMS, SEPARATION_BACKENDS, clear_disk_cache, initial_stages, run_extraction_pipeline
|
| 28 |
from sample_extractor import DEMUCS_MODELS, DEMUCS_STEMS, cache_clear
|
| 29 |
from supervised_state import (
|
| 30 |
accept_suggestion,
|
|
@@ -257,6 +257,7 @@ def config() -> dict[str, Any]:
|
|
| 257 |
"demucs_models": DEMUCS_MODELS,
|
| 258 |
"demucs_stems": {key: value + ["all"] for key, value in DEMUCS_STEMS.items()},
|
| 259 |
"defaults": asdict(PipelineParams()),
|
|
|
|
| 260 |
"stages": initial_stages(),
|
| 261 |
"clustering_modes": {
|
| 262 |
"batch_quality": "Batch quality: all-pairs mel/NCC + agglomerative clustering",
|
|
@@ -283,6 +284,10 @@ def list_jobs(limit: int = 50) -> dict[str, Any]:
|
|
| 283 |
|
| 284 |
@app.post("/api/jobs")
|
| 285 |
async def create_job(file: UploadFile = File(...), params: str = Form("{}")) -> JSONResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
try:
|
| 287 |
parsed_params = json.loads(params)
|
| 288 |
validated = PipelineParams.from_mapping(parsed_params)
|
|
@@ -300,7 +305,7 @@ async def create_job(file: UploadFile = File(...), params: str = Form("{}")) ->
|
|
| 300 |
input_dir.mkdir(parents=True, exist_ok=True)
|
| 301 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 302 |
|
| 303 |
-
suffix =
|
| 304 |
input_path = input_dir / f"source{suffix}"
|
| 305 |
with input_path.open("wb") as handle:
|
| 306 |
shutil.copyfileobj(file.file, handle)
|
|
|
|
| 24 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 25 |
from fastapi.staticfiles import StaticFiles
|
| 26 |
|
| 27 |
+
from pipeline_runner import PipelineParams, SPLEETER_MODELS, SPLEETER_STEMS, SEPARATION_BACKENDS, clear_disk_cache, initial_stages, run_extraction_pipeline, separation_runtime_status
|
| 28 |
from sample_extractor import DEMUCS_MODELS, DEMUCS_STEMS, cache_clear
|
| 29 |
from supervised_state import (
|
| 30 |
accept_suggestion,
|
|
|
|
| 257 |
"demucs_models": DEMUCS_MODELS,
|
| 258 |
"demucs_stems": {key: value + ["all"] for key, value in DEMUCS_STEMS.items()},
|
| 259 |
"defaults": asdict(PipelineParams()),
|
| 260 |
+
"runtime": separation_runtime_status(),
|
| 261 |
"stages": initial_stages(),
|
| 262 |
"clustering_modes": {
|
| 263 |
"batch_quality": "Batch quality: all-pairs mel/NCC + agglomerative clustering",
|
|
|
|
| 284 |
|
| 285 |
@app.post("/api/jobs")
|
| 286 |
async def create_job(file: UploadFile = File(...), params: str = Form("{}")) -> JSONResponse:
|
| 287 |
+
filename = file.filename or "input.wav"
|
| 288 |
+
suffix = Path(filename).suffix.lower()
|
| 289 |
+
if suffix and suffix not in {".wav", ".mp3", ".flac", ".aiff", ".aif", ".ogg", ".m4a", ".aac"}:
|
| 290 |
+
raise HTTPException(status_code=400, detail=f"Unsupported audio file extension: {suffix}")
|
| 291 |
try:
|
| 292 |
parsed_params = json.loads(params)
|
| 293 |
validated = PipelineParams.from_mapping(parsed_params)
|
|
|
|
| 305 |
input_dir.mkdir(parents=True, exist_ok=True)
|
| 306 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 307 |
|
| 308 |
+
suffix = suffix or ".wav"
|
| 309 |
input_path = input_dir / f"source{suffix}"
|
| 310 |
with input_path.open("wb") as handle:
|
| 311 |
shutil.copyfileobj(file.file, handle)
|
docs/API.md
CHANGED
|
@@ -595,3 +595,11 @@ Final results remain authoritative under `result.samples`.
|
|
| 595 |
## `PipelineParams.auto_tune`
|
| 596 |
|
| 597 |
`auto_tune` defaults to `true`. When enabled, the pipeline tunes onset sensitivity and group bounds from the loaded target stem before final onset detection. The effective tuned values are returned in the job params/result manifest.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
## `PipelineParams.auto_tune`
|
| 596 |
|
| 597 |
`auto_tune` defaults to `true`. When enabled, the pipeline tunes onset sensitivity and group bounds from the loaded target stem before final onset detection. The effective tuned values are returned in the job params/result manifest.
|
| 598 |
+
|
| 599 |
+
## Upload/runtime fallback update (2026-05-12)
|
| 600 |
+
|
| 601 |
+
- Added a visible top-bar `Choose audio` affordance in addition to whole-app drag/drop.
|
| 602 |
+
- Fixed the default hidden state of the error banner so placeholder errors are not shown on page load.
|
| 603 |
+
- API errors now surface request path/status/detail in the visible banner and pipeline logs.
|
| 604 |
+
- `/api/config` now includes runtime diagnostics for optional separation backends.
|
| 605 |
+
- If Spleeter is unavailable, the simple UI keeps the app usable by switching to full-mix mode; backend fallback also uses full-mix rather than silently launching Demucs.
|
docs/API_ERRORS_AND_PARAMETER_VALIDATION.md
CHANGED
|
@@ -45,3 +45,11 @@ This verifies:
|
|
| 45 |
- invalid subdivisions still fail;
|
| 46 |
- `/api/jobs` accepts a string `subdivision` from the UI;
|
| 47 |
- bad parameter responses include visible actionable details.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
- invalid subdivisions still fail;
|
| 46 |
- `/api/jobs` accepts a string `subdivision` from the UI;
|
| 47 |
- bad parameter responses include visible actionable details.
|
| 48 |
+
|
| 49 |
+
## Upload/runtime fallback update (2026-05-12)
|
| 50 |
+
|
| 51 |
+
- Added a visible top-bar `Choose audio` affordance in addition to whole-app drag/drop.
|
| 52 |
+
- Fixed the default hidden state of the error banner so placeholder errors are not shown on page load.
|
| 53 |
+
- API errors now surface request path/status/detail in the visible banner and pipeline logs.
|
| 54 |
+
- `/api/config` now includes runtime diagnostics for optional separation backends.
|
| 55 |
+
- If Spleeter is unavailable, the simple UI keeps the app usable by switching to full-mix mode; backend fallback also uses full-mix rather than silently launching Demucs.
|
docs/FEATURES.md
CHANGED
|
@@ -166,3 +166,11 @@ Implemented after the reference-image UI pass:
|
|
| 166 |
| Cards | Trim/extend preview | Implemented | Rewrites a playable preview WAV immediately under `overrides/hits/`. |
|
| 167 |
| Docs | Separation backend docs | Implemented | See `docs/SPLEETER_AND_SEPARATION_BACKENDS.md`. |
|
| 168 |
| Docs | Card action docs | Implemented | See `docs/CARD_SELECTION_EXPORT_AND_EDITING.md`. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
| Cards | Trim/extend preview | Implemented | Rewrites a playable preview WAV immediately under `overrides/hits/`. |
|
| 167 |
| Docs | Separation backend docs | Implemented | See `docs/SPLEETER_AND_SEPARATION_BACKENDS.md`. |
|
| 168 |
| Docs | Card action docs | Implemented | See `docs/CARD_SELECTION_EXPORT_AND_EDITING.md`. |
|
| 169 |
+
|
| 170 |
+
## Upload/runtime fallback update (2026-05-12)
|
| 171 |
+
|
| 172 |
+
- Added a visible top-bar `Choose audio` affordance in addition to whole-app drag/drop.
|
| 173 |
+
- Fixed the default hidden state of the error banner so placeholder errors are not shown on page load.
|
| 174 |
+
- API errors now surface request path/status/detail in the visible banner and pipeline logs.
|
| 175 |
+
- `/api/config` now includes runtime diagnostics for optional separation backends.
|
| 176 |
+
- If Spleeter is unavailable, the simple UI keeps the app usable by switching to full-mix mode; backend fallback also uses full-mix rather than silently launching Demucs.
|
docs/PROGRESS.md
CHANGED
|
@@ -388,3 +388,11 @@ Completed in this pass:
|
|
| 388 |
Outcome:
|
| 389 |
|
| 390 |
The default app now behaves more like a card review tool: drop audio, let Spleeter/fallback separation run, review grouped cards, select/dismiss/draw/trim, and export only the selected pack.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
Outcome:
|
| 389 |
|
| 390 |
The default app now behaves more like a card review tool: drop audio, let Spleeter/fallback separation run, review grouped cards, select/dismiss/draw/trim, and export only the selected pack.
|
| 391 |
+
|
| 392 |
+
## Upload/runtime fallback update (2026-05-12)
|
| 393 |
+
|
| 394 |
+
- Added a visible top-bar `Choose audio` affordance in addition to whole-app drag/drop.
|
| 395 |
+
- Fixed the default hidden state of the error banner so placeholder errors are not shown on page load.
|
| 396 |
+
- API errors now surface request path/status/detail in the visible banner and pipeline logs.
|
| 397 |
+
- `/api/config` now includes runtime diagnostics for optional separation backends.
|
| 398 |
+
- If Spleeter is unavailable, the simple UI keeps the app usable by switching to full-mix mode; backend fallback also uses full-mix rather than silently launching Demucs.
|
docs/REMAINING_WORK.md
CHANGED
|
@@ -110,7 +110,7 @@ The default UI is now a cleaner fixed, non-scrolling workstation layout with col
|
|
| 110 |
- Sample card checkboxes are real per-card state.
|
| 111 |
- Draw-next is persisted as a representative override in `supervision_state.json`.
|
| 112 |
- Trim/extend rewrites preview audio immediately and persists the edited representative hit.
|
| 113 |
-
- Spleeter is now the default backend,
|
| 114 |
|
| 115 |
## Remaining after selected-card/Spleeter pass
|
| 116 |
|
|
|
|
| 110 |
- Sample card checkboxes are real per-card state.
|
| 111 |
- Draw-next is persisted as a representative override in `supervision_state.json`.
|
| 112 |
- Trim/extend rewrites preview audio immediately and persists the edited representative hit.
|
| 113 |
+
- Spleeter is now the configured default backend; when unavailable, the UI/backend fall back to full-mix mode and Demucs remains an explicit quality backend.
|
| 114 |
|
| 115 |
## Remaining after selected-card/Spleeter pass
|
| 116 |
|
docs/SPLEETER_AND_SEPARATION_BACKENDS.md
CHANGED
|
@@ -17,7 +17,7 @@ The application now defaults to Spleeter:
|
|
| 17 |
|
| 18 |
Spleeter is treated as the normal first-pass separation backend because it is much lighter for the common UX: drop a track, get drum-card candidates quickly, and only escalate to heavier processing when necessary.
|
| 19 |
|
| 20 |
-
Demucs remains available as a higher-cost quality
|
| 21 |
|
| 22 |
```json
|
| 23 |
{"separation_backend":"demucs","demucs_model":"htdemucs_ft"}
|
|
@@ -34,7 +34,7 @@ Full-mix preview remains available for the fastest possible iteration:
|
|
| 34 |
| Backend | Status | Use |
|
| 35 |
|---|---:|---|
|
| 36 |
| `spleeter` | Default | Lightweight drum/source separation for the common automatic workflow. |
|
| 37 |
-
| `demucs` | Supported |
|
| 38 |
| `none` | Supported | Bypass source separation and process the full mix. Best for quick UI/debug iteration. |
|
| 39 |
|
| 40 |
## Spleeter models
|
|
@@ -55,7 +55,7 @@ Install it only when needed:
|
|
| 55 |
pip install -r requirements-spleeter.txt
|
| 56 |
```
|
| 57 |
|
| 58 |
-
For the normal local app, leave `allow_backend_fallback=true`. If Spleeter is unavailable or fails, the job falls back to
|
| 59 |
|
| 60 |
## Caching
|
| 61 |
|
|
|
|
| 17 |
|
| 18 |
Spleeter is treated as the normal first-pass separation backend because it is much lighter for the common UX: drop a track, get drum-card candidates quickly, and only escalate to heavier processing when necessary.
|
| 19 |
|
| 20 |
+
Demucs remains available as a higher-cost quality backend:
|
| 21 |
|
| 22 |
```json
|
| 23 |
{"separation_backend":"demucs","demucs_model":"htdemucs_ft"}
|
|
|
|
| 34 |
| Backend | Status | Use |
|
| 35 |
|---|---:|---|
|
| 36 |
| `spleeter` | Default | Lightweight drum/source separation for the common automatic workflow. |
|
| 37 |
+
| `demucs` | Supported | Explicit higher-cost quality backend when Spleeter/full-mix results are insufficient. |
|
| 38 |
| `none` | Supported | Bypass source separation and process the full mix. Best for quick UI/debug iteration. |
|
| 39 |
|
| 40 |
## Spleeter models
|
|
|
|
| 55 |
pip install -r requirements-spleeter.txt
|
| 56 |
```
|
| 57 |
|
| 58 |
+
For the normal local app, leave `allow_backend_fallback=true`. If Spleeter is unavailable or fails, the job falls back to full-mix processing and logs that fallback in the stage details. Demucs is no longer used as a silent fallback for Spleeter because full-track Demucs is too slow and surprising for the default automatic UI. Disable fallback only when actively debugging Spleeter.
|
| 59 |
|
| 60 |
## Caching
|
| 61 |
|
docs/TASKS.md
CHANGED
|
@@ -224,3 +224,11 @@ Next:
|
|
| 224 |
- [ ] Add card-column relabel/merge/split actions.
|
| 225 |
- [ ] Add browser-level tests for card selection/export/edit flows.
|
| 226 |
- [ ] Add localized high-quality separation refinement on short candidate windows.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
- [ ] Add card-column relabel/merge/split actions.
|
| 225 |
- [ ] Add browser-level tests for card selection/export/edit flows.
|
| 226 |
- [ ] Add localized high-quality separation refinement on short candidate windows.
|
| 227 |
+
|
| 228 |
+
## Upload/runtime fallback update (2026-05-12)
|
| 229 |
+
|
| 230 |
+
- Added a visible top-bar `Choose audio` affordance in addition to whole-app drag/drop.
|
| 231 |
+
- Fixed the default hidden state of the error banner so placeholder errors are not shown on page load.
|
| 232 |
+
- API errors now surface request path/status/detail in the visible banner and pipeline logs.
|
| 233 |
+
- `/api/config` now includes runtime diagnostics for optional separation backends.
|
| 234 |
+
- If Spleeter is unavailable, the simple UI keeps the app usable by switching to full-mix mode; backend fallback also uses full-mix rather than silently launching Demucs.
|
docs/UPLOAD_ERROR_AND_RUNTIME_FALLBACK.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Upload, visible errors, and runtime fallback
|
| 2 |
+
|
| 3 |
+
Last updated: 2026-05-12
|
| 4 |
+
|
| 5 |
+
## Problem addressed
|
| 6 |
+
|
| 7 |
+
A deployed UI could appear broken before a file was even processed:
|
| 8 |
+
|
| 9 |
+
- the default error banner was visible with placeholder text because CSS overrode the HTML `hidden` attribute;
|
| 10 |
+
- the upload affordance was too subtle because the middle top-bar control looked like passive text;
|
| 11 |
+
- if a user selected a file before `/api/config` finished loading, automatic extraction could start with incomplete control defaults;
|
| 12 |
+
- a Spleeter-default runtime without Spleeter installed could fail or escalate into a slower backend instead of staying usable.
|
| 13 |
+
|
| 14 |
+
## Current behavior
|
| 15 |
+
|
| 16 |
+
- The error banner is hidden by default via an explicit `[hidden]` CSS rule.
|
| 17 |
+
- Errors shown in the UI include the request path, HTTP status, and server detail/traceback when available.
|
| 18 |
+
- The top bar now contains a visible `Choose audio` button plus whole-app drag/drop.
|
| 19 |
+
- File selection waits for backend config before submitting `/api/jobs`.
|
| 20 |
+
- `/api/config` exposes runtime diagnostics for optional separation engines.
|
| 21 |
+
- Spleeter remains the configured default, but if the runtime does not have Spleeter available the UI switches the default control to full-mix mode so the app still works.
|
| 22 |
+
- Backend Spleeter failures with fallback enabled now fall back to full-mix processing, not automatic full-track Demucs. Demucs is an explicit quality option.
|
| 23 |
+
|
| 24 |
+
## Rationale
|
| 25 |
+
|
| 26 |
+
The normal user path should be reliable even on minimal hosted runtimes. Full-mix extraction is less clean than source-separated extraction, but it is fast, dependency-light, and visibly produces cards. Demucs remains available for deliberate high-quality separation, but it is too expensive and unpredictable as a silent automatic fallback for a simple drop-to-process UI.
|
| 27 |
+
|
| 28 |
+
## Validation
|
| 29 |
+
|
| 30 |
+
Covered by:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
python3 scripts/test_upload_error_visibility_and_fallback.py
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
The script checks:
|
| 37 |
+
|
| 38 |
+
- runtime diagnostics are exposed by `/api/config`;
|
| 39 |
+
- invalid upload extensions produce actionable `400` details;
|
| 40 |
+
- Spleeter failure falls back to full-mix extraction without attempting Demucs.
|
pipeline_runner.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import hashlib
|
|
|
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
import shutil
|
|
@@ -48,6 +49,38 @@ SPLEETER_STEMS = {
|
|
| 48 |
SEPARATION_BACKENDS = ["spleeter", "demucs", "none"]
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@dataclass
|
| 52 |
class PipelineParams:
|
| 53 |
stem: str = "drums"
|
|
@@ -396,7 +429,7 @@ def _make_reproduction_mix(target_reconstruction: np.ndarray, context_bed: np.nd
|
|
| 396 |
MODULE_ROOT = Path(__file__).resolve().parent
|
| 397 |
CACHE_DIR = Path(os.environ["DSE_CACHE_DIR"]) if os.environ.get("DSE_CACHE_DIR") else MODULE_ROOT / ".cache"
|
| 398 |
STEM_CACHE_DIR = CACHE_DIR / "stems"
|
| 399 |
-
CACHE_VERSION = "dse-cache-
|
| 400 |
|
| 401 |
|
| 402 |
def _write_audio(path: Path, audio: np.ndarray, sr: int, subtype: str = "PCM_24") -> None:
|
|
@@ -543,12 +576,32 @@ def _extract_demucs_separation(
|
|
| 543 |
return audio, sr, None, f"{params.stem} via Demucs {params.demucs_model}"
|
| 544 |
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
def _load_or_extract_separation(audio_path: str | os.PathLike[str], params: PipelineParams, progress_cb: Callable[[dict[str, Any]], None] | None = None) -> tuple[np.ndarray, int, str, np.ndarray | None]:
|
| 547 |
if params.stem == "all" or params.separation_backend == "none":
|
| 548 |
-
|
| 549 |
-
if progress_cb:
|
| 550 |
-
progress_cb({"fraction": 1.0, "completed_units": 1, "total_units": 1, "detail": "loaded full mix"})
|
| 551 |
-
return _mono(audio), int(sr), "loaded full mix", None
|
| 552 |
|
| 553 |
cache_path = _stem_cache_path(audio_path, params)
|
| 554 |
context_cache = _context_cache_path(cache_path)
|
|
@@ -566,15 +619,19 @@ def _load_or_extract_separation(audio_path: str | os.PathLike[str], params: Pipe
|
|
| 566 |
if params.separation_backend == "spleeter":
|
| 567 |
try:
|
| 568 |
audio, sr, context, detail = _extract_spleeter_separation(audio_path, params, progress_cb=progress_cb)
|
| 569 |
-
except Exception as
|
| 570 |
if not params.allow_backend_fallback:
|
| 571 |
raise
|
| 572 |
if progress_cb:
|
| 573 |
-
progress_cb({"fraction": 0.0, "completed_units": 0, "total_units": 1, "detail": f"Spleeter unavailable;
|
| 574 |
-
audio, sr,
|
| 575 |
-
detail = f"Spleeter unavailable ({exc}); fallback {demucs_detail}"
|
| 576 |
elif params.separation_backend == "demucs":
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
else:
|
| 579 |
raise ValueError(f"Unsupported separation backend: {params.separation_backend}")
|
| 580 |
|
|
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import hashlib
|
| 7 |
+
import importlib.util
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
import shutil
|
|
|
|
| 49 |
SEPARATION_BACKENDS = ["spleeter", "demucs", "none"]
|
| 50 |
|
| 51 |
|
| 52 |
+
def _module_available(name: str) -> bool:
|
| 53 |
+
return importlib.util.find_spec(name) is not None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def separation_runtime_status() -> dict[str, Any]:
|
| 57 |
+
"""Return lightweight runtime diagnostics for optional separation engines.
|
| 58 |
+
|
| 59 |
+
This is intentionally import-spec/CLI based and does not instantiate models or
|
| 60 |
+
download weights. It is safe to call on page load.
|
| 61 |
+
"""
|
| 62 |
+
spleeter_available = _module_available("spleeter") or shutil.which("spleeter") is not None
|
| 63 |
+
demucs_available = _module_available("demucs")
|
| 64 |
+
return {
|
| 65 |
+
"backends": {
|
| 66 |
+
"spleeter": {
|
| 67 |
+
"available": bool(spleeter_available),
|
| 68 |
+
"default": True,
|
| 69 |
+
"install_hint": "Install requirements-spleeter.txt or use the full-mix fallback.",
|
| 70 |
+
},
|
| 71 |
+
"demucs": {
|
| 72 |
+
"available": bool(demucs_available),
|
| 73 |
+
"default": False,
|
| 74 |
+
"install_hint": "Install demucs and model dependencies for quality fallback separation.",
|
| 75 |
+
},
|
| 76 |
+
"none": {
|
| 77 |
+
"available": True,
|
| 78 |
+
"default": False,
|
| 79 |
+
"install_hint": "Always available; uses the uploaded mix without source separation.",
|
| 80 |
+
},
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
@dataclass
|
| 85 |
class PipelineParams:
|
| 86 |
stem: str = "drums"
|
|
|
|
| 429 |
MODULE_ROOT = Path(__file__).resolve().parent
|
| 430 |
CACHE_DIR = Path(os.environ["DSE_CACHE_DIR"]) if os.environ.get("DSE_CACHE_DIR") else MODULE_ROOT / ".cache"
|
| 431 |
STEM_CACHE_DIR = CACHE_DIR / "stems"
|
| 432 |
+
CACHE_VERSION = "dse-cache-v4-runtime-fallback"
|
| 433 |
|
| 434 |
|
| 435 |
def _write_audio(path: Path, audio: np.ndarray, sr: int, subtype: str = "PCM_24") -> None:
|
|
|
|
| 576 |
return audio, sr, None, f"{params.stem} via Demucs {params.demucs_model}"
|
| 577 |
|
| 578 |
|
| 579 |
+
|
| 580 |
+
def _load_full_mix_separation(
|
| 581 |
+
audio_path: str | os.PathLike[str],
|
| 582 |
+
progress_cb: Callable[[dict[str, Any]], None] | None = None,
|
| 583 |
+
detail: str = "loaded full mix",
|
| 584 |
+
) -> tuple[np.ndarray, int, str, np.ndarray | None]:
|
| 585 |
+
audio, sr = librosa.load(audio_path, sr=44100, mono=True)
|
| 586 |
+
if progress_cb:
|
| 587 |
+
progress_cb({"fraction": 1.0, "completed_units": 1, "total_units": 1, "detail": detail})
|
| 588 |
+
return _mono(audio), int(sr), detail, None
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _fallback_to_full_mix(
|
| 592 |
+
audio_path: str | os.PathLike[str],
|
| 593 |
+
progress_cb: Callable[[dict[str, Any]], None] | None,
|
| 594 |
+
reason: str,
|
| 595 |
+
) -> tuple[np.ndarray, int, str, np.ndarray | None]:
|
| 596 |
+
return _load_full_mix_separation(
|
| 597 |
+
audio_path,
|
| 598 |
+
progress_cb=progress_cb,
|
| 599 |
+
detail=f"fallback full mix because separation failed: {reason}",
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
def _load_or_extract_separation(audio_path: str | os.PathLike[str], params: PipelineParams, progress_cb: Callable[[dict[str, Any]], None] | None = None) -> tuple[np.ndarray, int, str, np.ndarray | None]:
|
| 603 |
if params.stem == "all" or params.separation_backend == "none":
|
| 604 |
+
return _load_full_mix_separation(audio_path, progress_cb=progress_cb, detail="loaded full mix")
|
|
|
|
|
|
|
|
|
|
| 605 |
|
| 606 |
cache_path = _stem_cache_path(audio_path, params)
|
| 607 |
context_cache = _context_cache_path(cache_path)
|
|
|
|
| 619 |
if params.separation_backend == "spleeter":
|
| 620 |
try:
|
| 621 |
audio, sr, context, detail = _extract_spleeter_separation(audio_path, params, progress_cb=progress_cb)
|
| 622 |
+
except Exception as spleeter_exc:
|
| 623 |
if not params.allow_backend_fallback:
|
| 624 |
raise
|
| 625 |
if progress_cb:
|
| 626 |
+
progress_cb({"fraction": 0.0, "completed_units": 0, "total_units": 1, "detail": f"Spleeter unavailable; using full-mix fallback: {spleeter_exc}"})
|
| 627 |
+
audio, sr, detail, context = _fallback_to_full_mix(audio_path, progress_cb, f"Spleeter failed ({spleeter_exc})")
|
|
|
|
| 628 |
elif params.separation_backend == "demucs":
|
| 629 |
+
try:
|
| 630 |
+
audio, sr, context, detail = _extract_demucs_separation(audio_path, params, progress_cb=progress_cb)
|
| 631 |
+
except Exception as demucs_exc:
|
| 632 |
+
if not params.allow_backend_fallback:
|
| 633 |
+
raise
|
| 634 |
+
audio, sr, detail, context = _fallback_to_full_mix(audio_path, progress_cb, f"Demucs failed ({demucs_exc})")
|
| 635 |
else:
|
| 636 |
raise ValueError(f"Unsupported separation backend: {params.separation_backend}")
|
| 637 |
|
scripts/test_upload_error_visibility_and_fallback.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import soundfile as sf
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
|
| 13 |
+
import pipeline_runner as runner
|
| 14 |
+
from app import app
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def make_wav_buffer() -> io.BytesIO:
|
| 18 |
+
sr = 22050
|
| 19 |
+
y = np.zeros(sr, dtype=np.float32)
|
| 20 |
+
for pos in [0, sr // 4, sr // 2, 3 * sr // 4]:
|
| 21 |
+
n = min(1600, len(y) - pos)
|
| 22 |
+
t = np.arange(n) / sr
|
| 23 |
+
y[pos:pos + n] += 0.7 * np.exp(-t * 42) * np.sin(2 * np.pi * 90 * t)
|
| 24 |
+
buf = io.BytesIO()
|
| 25 |
+
sf.write(buf, y, sr, format="WAV")
|
| 26 |
+
buf.seek(0)
|
| 27 |
+
return buf
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_config_exposes_runtime_diagnostics() -> None:
|
| 31 |
+
client = TestClient(app)
|
| 32 |
+
payload = client.get("/api/config").json()
|
| 33 |
+
assert "runtime" in payload
|
| 34 |
+
assert payload["runtime"]["backends"]["none"]["available"] is True
|
| 35 |
+
assert payload["defaults"]["separation_backend"] == "spleeter"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_invalid_upload_extension_is_actionable() -> None:
|
| 39 |
+
client = TestClient(app)
|
| 40 |
+
response = client.post(
|
| 41 |
+
"/api/jobs",
|
| 42 |
+
files={"file": ("not-audio.txt", b"hello", "text/plain")},
|
| 43 |
+
data={"params": json.dumps({})},
|
| 44 |
+
)
|
| 45 |
+
assert response.status_code == 400
|
| 46 |
+
assert "Unsupported audio file extension" in response.json()["detail"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_spleeter_falls_back_to_full_mix_without_demucs() -> None:
|
| 50 |
+
original_spleeter = runner._extract_spleeter_separation
|
| 51 |
+
original_demucs = runner._extract_demucs_separation
|
| 52 |
+
runner._extract_spleeter_separation = lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("synthetic spleeter failure"))
|
| 53 |
+
runner._extract_demucs_separation = lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("demucs should not be used for Spleeter fallback"))
|
| 54 |
+
try:
|
| 55 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 56 |
+
wav_path = Path(tmp) / "input.wav"
|
| 57 |
+
wav_path.write_bytes(make_wav_buffer().getvalue())
|
| 58 |
+
params = runner.PipelineParams(separation_backend="spleeter", stem="drums", use_disk_cache=False, allow_backend_fallback=True)
|
| 59 |
+
audio, sr, detail, context = runner._load_or_extract_separation(wav_path, params)
|
| 60 |
+
assert sr == 44100
|
| 61 |
+
assert audio.size > 0
|
| 62 |
+
assert context is None
|
| 63 |
+
assert "fallback full mix" in detail
|
| 64 |
+
assert "Spleeter failed" in detail
|
| 65 |
+
finally:
|
| 66 |
+
runner._extract_spleeter_separation = original_spleeter
|
| 67 |
+
runner._extract_demucs_separation = original_demucs
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
test_config_exposes_runtime_diagnostics()
|
| 72 |
+
test_invalid_upload_extension_is_actionable()
|
| 73 |
+
test_spleeter_falls_back_to_full_mix_without_demucs()
|
| 74 |
+
print("upload/error/fallback contract passed")
|
web/app.js
CHANGED
|
@@ -32,6 +32,7 @@ let sampleOverrides = new Map();
|
|
| 32 |
let userChangedSampleSelection = false;
|
| 33 |
let waveZoom = 1;
|
| 34 |
let waveOffset = 0;
|
|
|
|
| 35 |
|
| 36 |
const palette = ["#9b72ef", "#4f7df2", "#42b8b4", "#ef9343", "#ea5ca9", "#6d9be8", "#8abc59", "#805fe6"];
|
| 37 |
|
|
@@ -73,10 +74,11 @@ function showError(title, error, detail = "") {
|
|
| 73 |
const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
|
| 74 |
const status = error?.status ? `HTTP ${error.status}${error.statusText ? ` ${error.statusText}` : ""}` : "";
|
| 75 |
const path = error?.path ? ` · ${error.path}` : "";
|
| 76 |
-
const
|
|
|
|
| 77 |
$("errorTitle").textContent = title || "Request failed";
|
| 78 |
-
$("errorMessage").textContent = message;
|
| 79 |
-
$("errorDetail").textContent = [status + path, extra].filter(Boolean).join("
|
| 80 |
banner.hidden = false;
|
| 81 |
}
|
| 82 |
|
|
@@ -295,6 +297,35 @@ async function jsonApi(path, body = {}, method = "POST") {
|
|
| 295 |
});
|
| 296 |
}
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
function hitIdFromIndex(index) {
|
| 299 |
if (index === null || index === undefined) return null;
|
| 300 |
return `hit:${String(Number(index)).padStart(5, "0")}`;
|
|
@@ -368,6 +399,8 @@ function populateConfig() {
|
|
| 368 |
else el.value = defaults[field];
|
| 369 |
}
|
| 370 |
updateStemOptions();
|
|
|
|
|
|
|
| 371 |
renderStages(config.stages);
|
| 372 |
}
|
| 373 |
|
|
@@ -408,6 +441,7 @@ function collectParams() {
|
|
| 408 |
}
|
| 409 |
}
|
| 410 |
params.auto_tune = $("auto_tune") ? Boolean($("auto_tune").checked) : true;
|
|
|
|
| 411 |
return params;
|
| 412 |
}
|
| 413 |
|
|
@@ -1381,6 +1415,13 @@ async function watchJob(id) {
|
|
| 1381 |
async function runExtraction(options = {}) {
|
| 1382 |
if (!selectedFile) return;
|
| 1383 |
const automatic = Boolean(options.automatic);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1384 |
selectedHitIndex = null;
|
| 1385 |
lastResult = null;
|
| 1386 |
lastSupervisionState = null;
|
|
@@ -1404,9 +1445,10 @@ async function runExtraction(options = {}) {
|
|
| 1404 |
} catch (error) {
|
| 1405 |
$("runButton").disabled = false;
|
| 1406 |
$("jobPill").textContent = "error";
|
|
|
|
| 1407 |
$("resultSummary").textContent = `${automatic ? "Automatic extraction" : "Extraction"} could not start: ${error.message}`;
|
| 1408 |
-
$("logs").textContent = `Request failed before the pipeline started.\n${error.message}`;
|
| 1409 |
-
showError("Extraction could not start", error, "Check the visible controls first; advanced parameters may also be invalid.");
|
| 1410 |
}
|
| 1411 |
}
|
| 1412 |
|
|
@@ -1445,8 +1487,8 @@ function setFile(file) {
|
|
| 1445 |
clearError();
|
| 1446 |
selectedFile = file;
|
| 1447 |
clearRunViews();
|
| 1448 |
-
$("dropTitle").textContent = file ? file.name : "Drop
|
| 1449 |
-
$("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : "
|
| 1450 |
$("runButton").disabled = !file;
|
| 1451 |
currentProgress = { fraction: 0, status: "idle", stage_label: null, stage_fraction: 0 };
|
| 1452 |
currentJobStatus = "idle";
|
|
@@ -1507,6 +1549,7 @@ async function boot() {
|
|
| 1507 |
drawWaveform({ envelope: [], onsets: [], duration_sec: 0 });
|
| 1508 |
setHealth(true, "Ready", "Backend online");
|
| 1509 |
} catch (error) {
|
|
|
|
| 1510 |
setHealth(false, "Offline", error.message);
|
| 1511 |
showError("Backend unavailable", error, "Start the FastAPI server, then reload this page.");
|
| 1512 |
}
|
|
@@ -1517,6 +1560,15 @@ if ($("separation_backend")) $("separation_backend").addEventListener("change",
|
|
| 1517 |
if ($("spleeter_model")) $("spleeter_model").addEventListener("change", updateStemOptions);
|
| 1518 |
$("demucs_model").addEventListener("change", updateStemOptions);
|
| 1519 |
$("fileInput").addEventListener("change", (event) => setFile(event.target.files?.[0] ?? null));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1520 |
$("runButton").addEventListener("click", () => runExtraction({ automatic: false }));
|
| 1521 |
$("usePreviewButton").addEventListener("click", () => {
|
| 1522 |
$("separation_backend").value = "none";
|
|
|
|
| 32 |
let userChangedSampleSelection = false;
|
| 33 |
let waveZoom = 1;
|
| 34 |
let waveOffset = 0;
|
| 35 |
+
let configLoadError = null;
|
| 36 |
|
| 37 |
const palette = ["#9b72ef", "#4f7df2", "#42b8b4", "#ef9343", "#ea5ca9", "#6d9be8", "#8abc59", "#805fe6"];
|
| 38 |
|
|
|
|
| 74 |
const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
|
| 75 |
const status = error?.status ? `HTTP ${error.status}${error.statusText ? ` ${error.statusText}` : ""}` : "";
|
| 76 |
const path = error?.path ? ` · ${error.path}` : "";
|
| 77 |
+
const payloadDetail = error?.payload?.detail ?? error?.payload?.error ?? error?.payload?.message ?? "";
|
| 78 |
+
const extra = stringifyErrorDetail(detail || error?.detail || payloadDetail || "");
|
| 79 |
$("errorTitle").textContent = title || "Request failed";
|
| 80 |
+
$("errorMessage").textContent = message || "Request failed";
|
| 81 |
+
$("errorDetail").textContent = [status + path, extra].filter(Boolean).join("\n");
|
| 82 |
banner.hidden = false;
|
| 83 |
}
|
| 84 |
|
|
|
|
| 297 |
});
|
| 298 |
}
|
| 299 |
|
| 300 |
+
async function waitForConfigReady() {
|
| 301 |
+
if (config) return config;
|
| 302 |
+
if (configLoadError) throw configLoadError;
|
| 303 |
+
for (let i = 0; i < 80; i += 1) {
|
| 304 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
| 305 |
+
if (config) return config;
|
| 306 |
+
if (configLoadError) throw configLoadError;
|
| 307 |
+
}
|
| 308 |
+
throw new Error("Backend configuration did not load yet. Reload the page or check the server logs.");
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
function backendAvailable(name) {
|
| 312 |
+
return Boolean(config?.runtime?.backends?.[name]?.available);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
function applyRuntimeBackendHints() {
|
| 316 |
+
const backend = $("separation_backend");
|
| 317 |
+
if (!backend || !config?.runtime?.backends) return;
|
| 318 |
+
const spleeterAvailable = backendAvailable("spleeter");
|
| 319 |
+
const demucsAvailable = backendAvailable("demucs");
|
| 320 |
+
if (!spleeterAvailable && backend.value === "spleeter") {
|
| 321 |
+
backend.value = "none";
|
| 322 |
+
updateStemOptions();
|
| 323 |
+
$("resultSummary").textContent = demucsAvailable
|
| 324 |
+
? "Spleeter is not available in this runtime. Full-mix mode is active by default; choose Demucs under Expert controls when you want slower quality separation."
|
| 325 |
+
: "Spleeter is not available in this runtime. Full-mix mode is active so the app still works.";
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
function hitIdFromIndex(index) {
|
| 330 |
if (index === null || index === undefined) return null;
|
| 331 |
return `hit:${String(Number(index)).padStart(5, "0")}`;
|
|
|
|
| 399 |
else el.value = defaults[field];
|
| 400 |
}
|
| 401 |
updateStemOptions();
|
| 402 |
+
applyRuntimeBackendHints();
|
| 403 |
+
updateControlOutputs();
|
| 404 |
renderStages(config.stages);
|
| 405 |
}
|
| 406 |
|
|
|
|
| 441 |
}
|
| 442 |
}
|
| 443 |
params.auto_tune = $("auto_tune") ? Boolean($("auto_tune").checked) : true;
|
| 444 |
+
if (params.separation_backend === "none") params.stem = "all";
|
| 445 |
return params;
|
| 446 |
}
|
| 447 |
|
|
|
|
| 1415 |
async function runExtraction(options = {}) {
|
| 1416 |
if (!selectedFile) return;
|
| 1417 |
const automatic = Boolean(options.automatic);
|
| 1418 |
+
try {
|
| 1419 |
+
await waitForConfigReady();
|
| 1420 |
+
} catch (error) {
|
| 1421 |
+
showError("App is still loading", error);
|
| 1422 |
+
$("resultSummary").textContent = error.message;
|
| 1423 |
+
return;
|
| 1424 |
+
}
|
| 1425 |
selectedHitIndex = null;
|
| 1426 |
lastResult = null;
|
| 1427 |
lastSupervisionState = null;
|
|
|
|
| 1445 |
} catch (error) {
|
| 1446 |
$("runButton").disabled = false;
|
| 1447 |
$("jobPill").textContent = "error";
|
| 1448 |
+
const detail = stringifyErrorDetail(error.detail || error.payload || "");
|
| 1449 |
$("resultSummary").textContent = `${automatic ? "Automatic extraction" : "Extraction"} could not start: ${error.message}`;
|
| 1450 |
+
$("logs").textContent = `Request failed before the pipeline started.\n${error.message}${detail ? `\n\n${detail}` : ""}`;
|
| 1451 |
+
showError("Extraction could not start", error, "The request did not enter the extraction pipeline. Check the visible controls first; advanced parameters may also be invalid.");
|
| 1452 |
}
|
| 1453 |
}
|
| 1454 |
|
|
|
|
| 1487 |
clearError();
|
| 1488 |
selectedFile = file;
|
| 1489 |
clearRunViews();
|
| 1490 |
+
$("dropTitle").textContent = file ? file.name : "Drop audio anywhere";
|
| 1491 |
+
$("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB · processing starts automatically` : "or use Choose audio";
|
| 1492 |
$("runButton").disabled = !file;
|
| 1493 |
currentProgress = { fraction: 0, status: "idle", stage_label: null, stage_fraction: 0 };
|
| 1494 |
currentJobStatus = "idle";
|
|
|
|
| 1549 |
drawWaveform({ envelope: [], onsets: [], duration_sec: 0 });
|
| 1550 |
setHealth(true, "Ready", "Backend online");
|
| 1551 |
} catch (error) {
|
| 1552 |
+
configLoadError = error;
|
| 1553 |
setHealth(false, "Offline", error.message);
|
| 1554 |
showError("Backend unavailable", error, "Start the FastAPI server, then reload this page.");
|
| 1555 |
}
|
|
|
|
| 1560 |
if ($("spleeter_model")) $("spleeter_model").addEventListener("change", updateStemOptions);
|
| 1561 |
$("demucs_model").addEventListener("change", updateStemOptions);
|
| 1562 |
$("fileInput").addEventListener("change", (event) => setFile(event.target.files?.[0] ?? null));
|
| 1563 |
+
if ($("chooseAudioButton")) $("chooseAudioButton").addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); $("fileInput").click(); });
|
| 1564 |
+
if ($("dropzone")) {
|
| 1565 |
+
$("dropzone").addEventListener("keydown", (event) => {
|
| 1566 |
+
if (event.key === "Enter" || event.key === " ") {
|
| 1567 |
+
event.preventDefault();
|
| 1568 |
+
$("fileInput").click();
|
| 1569 |
+
}
|
| 1570 |
+
});
|
| 1571 |
+
}
|
| 1572 |
$("runButton").addEventListener("click", () => runExtraction({ automatic: false }));
|
| 1573 |
$("usePreviewButton").addEventListener("click", () => {
|
| 1574 |
$("separation_backend").value = "none";
|
web/index.html
CHANGED
|
@@ -31,12 +31,12 @@
|
|
| 31 |
<span class="beta-pill">BETA</span>
|
| 32 |
</div>
|
| 33 |
|
| 34 |
-
<
|
| 35 |
-
<input id="fileInput" type="file" accept="audio/*,.wav,.mp3,.flac,.aiff,.ogg,.m4a" />
|
| 36 |
-
<
|
| 37 |
-
<small id="dropMeta">Processing starts automatically</small>
|
| 38 |
<span aria-hidden="true">⌄</span>
|
| 39 |
-
</
|
| 40 |
|
| 41 |
<div class="top-actions">
|
| 42 |
<div class="backend-pill" aria-live="polite">
|
|
@@ -163,7 +163,7 @@
|
|
| 163 |
<label><input id="quantize_midi" type="checkbox" /> quantize MIDI</label>
|
| 164 |
<label><input id="auto_tune" type="checkbox" checked /> automatic parameter tuning</label>
|
| 165 |
<label><input id="use_disk_cache" type="checkbox" /> disk cache stems/source loads</label>
|
| 166 |
-
<label><input id="allow_backend_fallback" type="checkbox" /> fallback to
|
| 167 |
</div>
|
| 168 |
<div class="preset-row">
|
| 169 |
<button id="usePreviewButton" class="secondary-action" type="button">Fast preview</button>
|
|
|
|
| 31 |
<span class="beta-pill">BETA</span>
|
| 32 |
</div>
|
| 33 |
|
| 34 |
+
<div id="dropzone" class="file-picker" title="Choose audio, or drop audio anywhere" role="button" tabindex="0">
|
| 35 |
+
<input id="fileInput" type="file" accept="audio/*,.wav,.mp3,.flac,.aiff,.ogg,.m4a,.aac" aria-label="Choose audio file" />
|
| 36 |
+
<button id="chooseAudioButton" class="file-picker-button" type="button">Choose audio</button>
|
| 37 |
+
<span class="file-picker-copy"><span id="dropTitle">Drop audio anywhere</span><small id="dropMeta">Processing starts automatically</small></span>
|
| 38 |
<span aria-hidden="true">⌄</span>
|
| 39 |
+
</div>
|
| 40 |
|
| 41 |
<div class="top-actions">
|
| 42 |
<div class="backend-pill" aria-live="polite">
|
|
|
|
| 163 |
<label><input id="quantize_midi" type="checkbox" /> quantize MIDI</label>
|
| 164 |
<label><input id="auto_tune" type="checkbox" checked /> automatic parameter tuning</label>
|
| 165 |
<label><input id="use_disk_cache" type="checkbox" /> disk cache stems/source loads</label>
|
| 166 |
+
<label><input id="allow_backend_fallback" type="checkbox" /> fallback to full mix if separation is unavailable</label>
|
| 167 |
</div>
|
| 168 |
<div class="preset-row">
|
| 169 |
<button id="usePreviewButton" class="secondary-action" type="button">Fast preview</button>
|
web/styles.css
CHANGED
|
@@ -22,6 +22,7 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); c
|
|
| 22 |
button, input, select { font: inherit; }
|
| 23 |
button { cursor: pointer; }
|
| 24 |
button:disabled { cursor: not-allowed; opacity: .48; }
|
|
|
|
| 25 |
|
| 26 |
.app-shell { height: 100vh; display: grid; grid-template-rows: 70px minmax(0, 1fr) 54px; }
|
| 27 |
.topbar { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(280px, 460px) minmax(440px, 1fr); align-items: center; gap: 18px; padding: 14px 26px; background: rgba(255,255,255,.86); border-bottom: 1px solid var(--line); backdrop-filter: blur(16px); }
|
|
@@ -34,10 +35,14 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
|
| 34 |
.brand-mark i:nth-child(2), .brand-mark i:nth-child(4) { height: 20px; opacity: .86; }
|
| 35 |
.brand-mark i:nth-child(3) { height: 28px; }
|
| 36 |
.beta-pill { color: var(--purple); background: var(--purple-soft); border: 1px solid #dfd2ff; border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 700; }
|
| 37 |
-
.file-picker { justify-self: center; max-width:
|
| 38 |
-
.file-picker:hover { background: var(--panel); border-color: var(--line); }
|
| 39 |
-
.file-picker input {
|
| 40 |
-
.file-picker
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
.top-actions { justify-content: end; gap: 10px; min-width: 0; }
|
| 42 |
.backend-pill { gap: 7px; color: var(--muted); font-size: 11px; opacity: .75; }
|
| 43 |
.backend-pill strong { display: block; line-height: 1; }
|
|
@@ -156,7 +161,7 @@ th, td { padding: 8px; border-bottom: 1px solid var(--line); text-align: left; w
|
|
| 156 |
.error-banner { position: fixed; z-index: 30; left: 50%; top: 78px; transform: translateX(-50%); width: min(760px, calc(100vw - 32px)); display: flex; align-items: start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-radius: 14px; border: 1px solid #fac8d0; background: #fff4f6; box-shadow: var(--shadow); color: #611923; }
|
| 157 |
.error-copy { display: grid; gap: 3px; }
|
| 158 |
.error-copy span { font-size: 13px; }
|
| 159 |
-
.error-copy small { color: #8a3440; white-space: pre-wrap; }
|
| 160 |
.global-drop-overlay { position: fixed; inset: 0; z-index: 25; display: none; place-items: center; background: rgba(109,61,242,.12); backdrop-filter: blur(4px); }
|
| 161 |
.global-drop-overlay.active { display: grid; }
|
| 162 |
.global-drop-overlay > div { display: grid; gap: 6px; place-items: center; padding: 32px 42px; border-radius: 18px; border: 1px solid #d9ccff; background: rgba(255,255,255,.94); box-shadow: var(--shadow); }
|
|
|
|
| 22 |
button, input, select { font: inherit; }
|
| 23 |
button { cursor: pointer; }
|
| 24 |
button:disabled { cursor: not-allowed; opacity: .48; }
|
| 25 |
+
[hidden], .error-banner[hidden], .global-drop-overlay[hidden] { display: none !important; }
|
| 26 |
|
| 27 |
.app-shell { height: 100vh; display: grid; grid-template-rows: 70px minmax(0, 1fr) 54px; }
|
| 28 |
.topbar { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(280px, 460px) minmax(440px, 1fr); align-items: center; gap: 18px; padding: 14px 26px; background: rgba(255,255,255,.86); border-bottom: 1px solid var(--line); backdrop-filter: blur(16px); }
|
|
|
|
| 35 |
.brand-mark i:nth-child(2), .brand-mark i:nth-child(4) { height: 20px; opacity: .86; }
|
| 36 |
.brand-mark i:nth-child(3) { height: 28px; }
|
| 37 |
.beta-pill { color: var(--purple); background: var(--purple-soft); border: 1px solid #dfd2ff; border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 700; }
|
| 38 |
+
.file-picker { justify-self: center; max-width: 540px; min-width: 320px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; padding: 5px 8px; border-radius: 12px; border: 1px solid transparent; color: var(--text); font-weight: 600; white-space: nowrap; }
|
| 39 |
+
.file-picker:hover, .file-picker:focus-within, .file-picker.dragging { background: var(--panel); border-color: var(--line); }
|
| 40 |
+
.file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
| 41 |
+
.file-picker-button { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 9px; min-height: 34px; padding: 0 13px; font-weight: 750; box-shadow: 0 1px 1px rgba(0,0,0,.02); }
|
| 42 |
+
.file-picker-button:hover { border-color: var(--line-strong); background: #fafafd; }
|
| 43 |
+
.file-picker-copy { min-width: 0; display: grid; gap: 1px; justify-items: start; }
|
| 44 |
+
.file-picker-copy > span { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
| 45 |
+
.file-picker small { max-width: 190px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 11px; font-weight: 500; }
|
| 46 |
.top-actions { justify-content: end; gap: 10px; min-width: 0; }
|
| 47 |
.backend-pill { gap: 7px; color: var(--muted); font-size: 11px; opacity: .75; }
|
| 48 |
.backend-pill strong { display: block; line-height: 1; }
|
|
|
|
| 161 |
.error-banner { position: fixed; z-index: 30; left: 50%; top: 78px; transform: translateX(-50%); width: min(760px, calc(100vw - 32px)); display: flex; align-items: start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-radius: 14px; border: 1px solid #fac8d0; background: #fff4f6; box-shadow: var(--shadow); color: #611923; }
|
| 162 |
.error-copy { display: grid; gap: 3px; }
|
| 163 |
.error-copy span { font-size: 13px; }
|
| 164 |
+
.error-copy small { color: #8a3440; white-space: pre-wrap; max-height: 160px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
| 165 |
.global-drop-overlay { position: fixed; inset: 0; z-index: 25; display: none; place-items: center; background: rgba(109,61,242,.12); backdrop-filter: blur(4px); }
|
| 166 |
.global-drop-overlay.active { display: grid; }
|
| 167 |
.global-drop-overlay > div { display: grid; gap: 6px; place-items: center; padding: 32px 42px; border-radius: 18px; border: 1px solid #d9ccff; background: rgba(255,255,255,.94); box-shadow: var(--shadow); }
|