ChatGPT commited on
Commit
5a90820
·
1 Parent(s): fa35534

fix: make upload and fallback robust

Browse files
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 defaults to Spleeter for lightweight source separation, can fall back to Demucs for quality, can bypass separation entirely for fast full-mix previews, 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,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 and automatic Spleeter→Demucs fallback when 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,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 now treated as the higher-cost quality/fallback backend.
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 = Path(file.filename or "input.wav").suffix or ".wav"
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, with Demucs fallback and full-mix preview still available.
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/fallback backend:
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 | Higher-cost quality backend and fallback when Spleeter is unavailable or insufficient. |
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 Demucs and logs that fallback in the stage details. Disable fallback only when actively debugging Spleeter.
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-v3-separation-backends"
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
- audio, sr = librosa.load(audio_path, sr=44100, mono=True)
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 exc:
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; falling back to Demucs: {exc}"})
574
- audio, sr, context, demucs_detail = _extract_demucs_separation(audio_path, params, progress_cb=progress_cb)
575
- detail = f"Spleeter unavailable ({exc}); fallback {demucs_detail}"
576
  elif params.separation_backend == "demucs":
577
- audio, sr, context, detail = _extract_demucs_separation(audio_path, params, progress_cb=progress_cb)
 
 
 
 
 
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 extra = stringifyErrorDetail(detail || error?.detail || error?.payload?.detail || "");
 
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 or choose audio";
1449
- $("dropMeta").textContent = file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : "Processing starts automatically";
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
- <label id="dropzone" class="file-picker" title="Click to browse, or drop audio anywhere">
35
- <input id="fileInput" type="file" accept="audio/*,.wav,.mp3,.flac,.aiff,.ogg,.m4a" />
36
- <span id="dropTitle">Drop or choose audio</span>
37
- <small id="dropMeta">Processing starts automatically</small>
38
  <span aria-hidden="true">⌄</span>
39
- </label>
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 Demucs if Spleeter is unavailable</label>
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: 460px; min-width: 260px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid transparent; color: var(--text); font-weight: 600; white-space: nowrap; }
38
- .file-picker:hover { background: var(--panel); border-color: var(--line); }
39
- .file-picker input { display: none; }
40
- .file-picker small { max-width: 140px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 11px; font-weight: 500; }
 
 
 
 
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); }