asdfasdfqrqwer commited on
Commit
5f318ab
·
1 Parent(s): 20fdb7f

feat: rewrite as gradio.Server + vanilla HTML/CSS/JS frontend

Browse files

This is the full rewrite using the architecture described in the
'Introducing Gradio Server' blog post (not just a theme change).

Backend (app.py):
- Replace gradio.Blocks with gradio.server.Server (a FastAPI subclass)
- Register 4 endpoints via @app .api() decorator (queue + concurrency):
* /separate — main endpoint, concurrency_limit=1 (CPU/GPU serialize)
* /presets — return use-case presets catalogue
* /models — return model list per architecture
* /history — return recent separation history
- @app .get('/') serves static/index.html (FastAPI FileResponse)
- /static mounted via fastapi.staticfiles.StaticFiles
- /healthz plain FastAPI endpoint (no queue) for health checks
- allowed_paths includes output_dir + model cache so FileData URLs work
- FileData handled both as instance and dict (serialization varies)

Frontend (static/):
- index.html — vanilla HTML, no framework, no build step
- style.css — ~700 lines, self-contained design system with :root tokens,
pure-black canvas, electric-lime accent, glassmorphism, animated hero
- app.js — vanilla JS using @gradio/client@2.3.0 from CDN (ESM import)
* Client.connect(window.location.origin) per the blog
* handle_file() for file upload
* result.data[0] for FileData URL extraction
* Builds /gradio_api/file=<path> URL when FileData.url is null
- Loaded as ESM module + defer script, with client-ready event bridge

Engine (main/):
- Delete main/ui.py and main/callbacks.py (Blocks-specific, no longer used)
- Keep main/config.py, main/models.py, main/separator.py, main/utils.py
(engine pieces reused by the new Server-based app)
- All bug fixes from the previous commit preserved:
* MP3 bitrate 320 kbps (issue #103)
* librosa.ParameterError retry (issue #57)
* Defensive path handling

Dependencies:
- Bump gradio from 5.x to 6.x (gradio.Server was introduced in 6.x)
- requirements.txt and README.md updated accordingly

Verified end-to-end:
- /healthz returns JSON
- / serves our custom HTML (not Gradio default)
- /static/style.css and /static/app.js served correctly
- All 4 @app .api() endpoints registered (visible in /gradio_api/info)
- /separate produces 2 stems from a 2s test tone in ~16s
- Browser test: page renders, dropdowns populate via API, no JS errors
- Browser test: JS Client.connect() + predict('/presets') succeeds

Files changed (9) hide show
  1. README.md +47 -16
  2. app.py +388 -15
  3. main/__init__.py +9 -12
  4. main/callbacks.py +0 -359
  5. main/ui.py +0 -832
  6. requirements.txt +12 -8
  7. static/app.js +544 -0
  8. static/index.html +363 -0
  9. static/style.css +872 -0
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎧
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 5.50.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
@@ -16,28 +16,45 @@ tags:
16
  - mdx
17
  - roformer
18
  - uvr
 
19
  ---
20
 
21
- # Audio Separator
22
-
23
- A polished Gradio interface for [`audio-separator`](https://github.com/nomadkaraoke/python-audio-separator).
24
-
25
- > Pure-black theme · Syne display font · one-click use-case presets · labelled stems · ZIP download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  ## Project layout
28
 
29
  ```
30
  .
31
- ├── app.py # Entry point — just imports `main` and launches.
 
 
 
 
32
  ├── main/
33
- │ ├── __init__.py # Public surface (build_app, DEVICE, )
34
- │ ├── config.py # Device detection, paths, logging, `spaces` shim.
35
- │ ├── models.py # Model catalogue + use-case presets.
36
- │ ├── separator.py # Core pipeline — @spaces.GPU-wrapped.
37
- ── utils.py # ZIP packaging, duration, history helpers.
38
- ├── callbacks.py # Gradio event handlers.
39
- │ └── ui.py # Theme (black + Syne) + Blocks layout.
40
- ├── requirements.txt # audio-separator[gpu]
41
  └── README.md
42
  ```
43
 
@@ -80,11 +97,25 @@ A polished Gradio interface for [`audio-separator`](https://github.com/nomadkara
80
  ```bash
81
  git clone https://huggingface.co/spaces/nomadkaraoke/audio-separator
82
  cd audio-separator
83
- pip install -r requirements.txt # audio-separator[gpu] (or [cpu] locally)
84
  python app.py
85
  # → http://localhost:7860
86
  ```
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ## Credits
89
 
90
  - Upstream HF Space: [nomadkaraoke/audio-separator](https://huggingface.co/spaces/nomadkaraoke/audio-separator)
 
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.19.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
16
  - mdx
17
  - roformer
18
  - uvr
19
+ - gradio-server
20
  ---
21
 
22
+ # Audio Separator — Gradio Server Edition
23
+
24
+ A polished demo for [`audio-separator`](https://github.com/nomadkaraoke/python-audio-separator)
25
+ built on the architecture described in the
26
+ [Introducing Gradio Server](https://huggingface.co/blog/introducing-gradio-server)
27
+ blog post:
28
+
29
+ > **Backend:** `gradio.server.Server` (a FastAPI subclass) with `@app.api()`
30
+ > endpoints that go through Gradio's queue with concurrency control and
31
+ > SSE streaming. Heavy compute is wrapped in `@spaces.GPU`.
32
+ >
33
+ > **Frontend:** vanilla HTML / CSS / JS loaded from `static/index.html` via
34
+ > `@app.get("/")`. The frontend talks to the backend using the
35
+ > [`@gradio/client`](https://www.npmjs.com/package/@gradio/client) JS
36
+ > library loaded from CDN. No build step. No framework on the client.
37
+ >
38
+ > **Engine:** `audio_separator.separator.Separator` with bug fixes for MP3
39
+ > bitrate (issue #103), `librosa.ParameterError` retry (issue #57), and
40
+ > defensive path handling.
41
 
42
  ## Project layout
43
 
44
  ```
45
  .
46
+ ├── app.py # Server entry point — gradio.Server + @app.api() endpoints
47
+ ├── static/
48
+ │ ├── index.html # Vanilla HTML UI (served at "/")
49
+ │ ├── style.css # Self-contained design system (no framework)
50
+ │ └── app.js # Client logic — uses @gradio/client from CDN
51
  ├── main/
52
+ │ ├── __init__.py # (legacy; not used by the new app)
53
+ │ ├── config.py # Device detection, paths, logging, `spaces` shim
54
+ │ ├── models.py # Model catalogue + use-case presets
55
+ │ ├── separator.py # Core pipeline — @spaces.GPU-wrapped, bug-fixed
56
+ ── utils.py # ZIP packaging, duration, history helpers
57
+ ├── requirements.txt # gradio>=6.0 audio-separator[cpu]>=0.44.0
 
 
58
  └── README.md
59
  ```
60
 
 
97
  ```bash
98
  git clone https://huggingface.co/spaces/nomadkaraoke/audio-separator
99
  cd audio-separator
100
+ pip install -r requirements.txt # gradio>=6.0 + audio-separator[cpu] (or [gpu] locally)
101
  python app.py
102
  # → http://localhost:7860
103
  ```
104
 
105
+ The first request to `/` returns the static `index.html`. The frontend then
106
+ connects to the backend using the Gradio JS client:
107
+
108
+ ```js
109
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
110
+ const client = await Client.connect(window.location.origin);
111
+ const result = await client.predict("/separate", {
112
+ audio_file: handle_file(file),
113
+ use_case: "Karaoke (vocals out)",
114
+ // ...other params
115
+ });
116
+ const stemUrl = result.data[0].stems[0].file.url; // FileData URL
117
+ ```
118
+
119
  ## Credits
120
 
121
  - Upstream HF Space: [nomadkaraoke/audio-separator](https://huggingface.co/spaces/nomadkaraoke/audio-separator)
app.py CHANGED
@@ -1,19 +1,26 @@
1
- """Audio Separator — Improved Demo (HuggingFace Space Edition).
2
 
3
- Entry point. All real logic lives in the `main/` package:
 
 
4
 
5
- main/
6
- __init__.py — public surface
7
- config.py — device detection, paths, logging, `spaces` shim
8
- models.py — model catalogue + use-case presets
9
- separator.py — core separation pipeline (@spaces.GPU wrapped)
10
- utils.py — ZIP, duration, history helpers
11
- callbacks.py Gradio event handlers
12
- ui.py — theme (black + Syne) + Blocks layout
 
 
 
 
13
 
14
  Run locally:
15
- pip install "audio-separator[cpu]" "gradio>=5.0,<6.0"
16
  python app.py
 
17
  """
18
  from __future__ import annotations
19
 
@@ -23,9 +30,365 @@ import sys
23
  # Make sure the local `main/` package is importable regardless of CWD.
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
 
26
- from main import build_app # noqa: E402
27
- from main.config import HAS_SEPARATOR # noqa: E402
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def main() -> None:
31
  if not HAS_SEPARATOR:
@@ -35,13 +398,23 @@ def main() -> None:
35
  print(" (or 'audio-separator[cpu]' for local CPU-only runs)")
36
  print("=" * 60)
37
 
38
- app = build_app()
39
- app.queue(default_concurrency_limit=1).launch(
 
 
 
 
 
 
 
 
 
40
  server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
41
  server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
42
  share=False,
43
  show_error=True,
44
  prevent_thread_lock=False,
 
45
  )
46
 
47
 
 
1
+ """Audio Separator — Gradio Server Edition.
2
 
3
+ This is a full rewrite of the demo using the architecture described in the
4
+ "Introducing Gradio Server" blog post:
5
+ https://huggingface.co/blog/introducing-gradio-server
6
 
7
+ Architecture:
8
+ * Backend : `gradio.server.Server` (a FastAPI subclass) with `@app.api()`
9
+ endpoints that go through Gradio's queue with concurrency
10
+ control and SSE streaming.
11
+ * Frontend : vanilla HTML / CSS / JS loaded from `static/index.html` via
12
+ `@app.get("/")`. The frontend talks to the backend using the
13
+ `@gradio/client` JS library loaded from CDN exactly as the
14
+ blog recommends.
15
+ * Engine : `audio_separator.separator.Separator` — same wrapper as the
16
+ old `main/separator.py`, with bug fixes for MP3 bitrate
17
+ (issue #103), librosa.ParameterError retry (issue #57), and
18
+ defensive path handling.
19
 
20
  Run locally:
21
+ pip install "audio-separator[cpu]" "gradio>=6.0"
22
  python app.py
23
+ # → http://localhost:7860
24
  """
25
  from __future__ import annotations
26
 
 
30
  # Make sure the local `main/` package is importable regardless of CWD.
31
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
 
33
+ from pathlib import Path
34
+ from typing import Any
35
 
36
+ from fastapi.responses import FileResponse, JSONResponse
37
+ from fastapi.staticfiles import StaticFiles
38
+ from gradio.data_classes import FileData
39
+ from gradio.server import Server
40
+
41
+ # ── Local imports (engine + catalogue, unchanged from the previous commit) ──
42
+ from main.config import (
43
+ DEFAULT_MODEL_DIR,
44
+ DEFAULT_OUTPUT_DIR,
45
+ DEVICE,
46
+ HAS_SEPARATOR,
47
+ HAS_SPACES,
48
+ USE_AUTOCAST,
49
+ spaces, # real `spaces` module on HF ZeroGPU, or our local no-op shim
50
+ )
51
+ from main.models import (
52
+ ROFORMER_MODELS,
53
+ USE_CASE_PRESETS,
54
+ infer_architecture_from_model,
55
+ model_choices_for_arch,
56
+ resolve_model_filename,
57
+ )
58
+ from main.separator import separate_core, MP3_BITRATE
59
+ from main.utils import (
60
+ append_history,
61
+ audio_duration_seconds,
62
+ estimate_time_seconds,
63
+ fmt_duration,
64
+ history_rows,
65
+ now_timestamp,
66
+ package_stems_as_zip,
67
+ )
68
+
69
+ import logging
70
+ log = logging.getLogger("audio-sep")
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # Server construction #
75
+ # --------------------------------------------------------------------------- #
76
+
77
+ app = Server()
78
+
79
+
80
+ # --------------------------------------------------------------------------- #
81
+ # Static frontend — served at "/" as the blog describes #
82
+ # --------------------------------------------------------------------------- #
83
+
84
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
85
+
86
+
87
+ @app.get("/")
88
+ def index():
89
+ """Serve the vanilla HTML/CSS/JS frontend.
90
+
91
+ The blog is explicit: `gradio.Server` *is* a FastAPI app, so plain
92
+ `@app.get("/")` works alongside `@app.api()` endpoints. We return the
93
+ static `index.html` and let the browser load `style.css` and `app.js`
94
+ via relative URLs (mounted below).
95
+ """
96
+ return FileResponse(STATIC_DIR / "index.html")
97
+
98
+
99
+ # Mount the rest of the static assets (CSS, JS, fonts if needed).
100
+ # `Server` inherits from FastAPI which inherits from Starlette — `.mount()`
101
+ # works exactly the same way.
102
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # API endpoints #
107
+ # --------------------------------------------------------------------------- #
108
+ # These are registered via `@app.api(name="...")` so they go through Gradio's
109
+ # queue with concurrency control. On HuggingFace ZeroGPU this is what keeps
110
+ # concurrent users from colliding on the GPU.
111
+ #
112
+ # Frontend (browser) calls these via the Gradio JS client:
113
+ # import { Client, handle_file } from "@gradio/client";
114
+ # const client = await Client.connect(window.location.origin);
115
+ # const result = await client.predict("/separate", { audio_file: handle_file(file), ... });
116
+ # const url = result.data[0].url; // FileData URL
117
+
118
+
119
+ @app.api(name="separate",
120
+ description="Separate an audio file into stems (vocals, instrumental, etc.).",
121
+ concurrency_limit=1)
122
+ def separate(
123
+ audio_file: FileData,
124
+ use_case: str,
125
+ model_display_name: str,
126
+ output_format: str,
127
+ # Advanced parameters (optional — frontend sends defaults when hidden)
128
+ advanced_mode: bool,
129
+ seg_size: int,
130
+ overlap_rof: int,
131
+ overlap_mdx: float,
132
+ pitch_shift: int,
133
+ hop_length: int,
134
+ enable_denoise: bool,
135
+ window_size: int,
136
+ aggression: int,
137
+ enable_tta: bool,
138
+ enable_post: bool,
139
+ post_thresh: float,
140
+ enable_high_end: bool,
141
+ shifts: int,
142
+ segments_enabled: bool,
143
+ norm_thresh: float,
144
+ amp_thresh: float,
145
+ batch_size: int,
146
+ ) -> dict[str, Any]:
147
+ """Main separation endpoint.
148
+
149
+ Returns a dict with:
150
+ - stems : list of {label, url} pairs (one per produced stem)
151
+ - zip_url : URL to a ZIP bundle of all stems
152
+ - duration : formatted input duration (e.g. "02:34")
153
+ - est : formatted estimated processing time
154
+ - device : "CPU" or "CUDA"
155
+ - model : chosen model display name
156
+ - architecture : inferred architecture key
157
+ - error : optional error message
158
+ """
159
+ # The Gradio client may pass FileData either as a FileData instance or
160
+ # as a plain dict (depending on client / serialization path). Normalize.
161
+ if isinstance(audio_file, dict):
162
+ audio_path = audio_file.get("path")
163
+ elif audio_file is not None:
164
+ audio_path = audio_file.path
165
+ else:
166
+ audio_path = None
167
+
168
+ if not audio_path:
169
+ return {"error": "No audio file provided."}
170
+
171
+ input_path = audio_path
172
+ if not os.path.exists(input_path):
173
+ return {"error": f"Audio file not found on disk: {input_path}"}
174
+
175
+ # Resolve architecture + params from the use-case preset.
176
+ preset = USE_CASE_PRESETS.get(use_case, {})
177
+ architecture = preset.get("architecture", "roformer")
178
+
179
+ chosen_model = model_display_name or preset.get("model", "")
180
+ if chosen_model != preset.get("model"):
181
+ architecture = infer_architecture_from_model(chosen_model)
182
+
183
+ arch_params: dict[str, Any] = dict(preset.get("params", {}))
184
+ if advanced_mode:
185
+ _apply_advanced_overrides(
186
+ arch_params,
187
+ architecture=architecture,
188
+ seg_size=seg_size,
189
+ overlap_rof=overlap_rof,
190
+ overlap_mdx=overlap_mdx,
191
+ pitch_shift=pitch_shift,
192
+ hop_length=hop_length,
193
+ enable_denoise=enable_denoise,
194
+ window_size=window_size,
195
+ aggression=aggression,
196
+ enable_tta=enable_tta,
197
+ enable_post=enable_post,
198
+ post_thresh=post_thresh,
199
+ enable_high_end=enable_high_end,
200
+ shifts=shifts,
201
+ segments_enabled=segments_enabled,
202
+ )
203
+
204
+ duration = audio_duration_seconds(input_path)
205
+ est = estimate_time_seconds(duration, architecture)
206
+
207
+ log.info(
208
+ "API /separate — input=%s arch=%s model=%s duration=%.1fs est=%.1fs",
209
+ os.path.basename(input_path), architecture, chosen_model, duration, est,
210
+ )
211
+
212
+ try:
213
+ stems = separate_core(
214
+ input_file=input_path,
215
+ architecture=architecture,
216
+ model_display_name=chosen_model,
217
+ arch_params=arch_params,
218
+ model_file_dir=DEFAULT_MODEL_DIR,
219
+ output_dir=DEFAULT_OUTPUT_DIR,
220
+ out_format=output_format,
221
+ norm_thresh=norm_thresh,
222
+ amp_thresh=amp_thresh,
223
+ batch_size=batch_size,
224
+ progress=None,
225
+ )
226
+ except Exception as e:
227
+ log.exception("Separation failed")
228
+ return {
229
+ "error": f"Separation failed: {e}",
230
+ "duration": fmt_duration(duration),
231
+ "est": fmt_duration(est),
232
+ "device": DEVICE.upper(),
233
+ "model": chosen_model,
234
+ "architecture": architecture,
235
+ }
236
+
237
+ if not stems:
238
+ return {
239
+ "error": "No stems were produced.",
240
+ "duration": fmt_duration(duration),
241
+ "est": fmt_duration(est),
242
+ "device": DEVICE.upper(),
243
+ "model": chosen_model,
244
+ "architecture": architecture,
245
+ }
246
+
247
+ # Build FileData objects for each stem — these carry the URL the JS
248
+ # client reads via `result.data[i].url`.
249
+ stem_payloads = []
250
+ for label, path in stems.items():
251
+ if not os.path.exists(path):
252
+ log.warning("Stem path missing on disk: %s", path)
253
+ continue
254
+ fd = FileData(path=path, orig_name=os.path.basename(path))
255
+ stem_payloads.append({"label": label, "file": fd})
256
+
257
+ zip_path = package_stems_as_zip(stems, Path(input_path).stem)
258
+ zip_fd = FileData(path=zip_path, orig_name=Path(zip_path).name) if zip_path else None
259
+
260
+ append_history({
261
+ "time": now_timestamp(),
262
+ "use_case": use_case,
263
+ "model": chosen_model,
264
+ "input": os.path.basename(input_path),
265
+ "duration": fmt_duration(duration),
266
+ "stems": [s["label"] for s in stem_payloads],
267
+ })
268
+
269
+ return {
270
+ "stems": stem_payloads,
271
+ "zip": zip_fd,
272
+ "duration": fmt_duration(duration),
273
+ "est": fmt_duration(est),
274
+ "device": DEVICE.upper(),
275
+ "model": chosen_model,
276
+ "architecture": architecture,
277
+ }
278
+
279
+
280
+ def _apply_advanced_overrides(
281
+ arch_params: dict[str, Any],
282
+ *,
283
+ architecture: str,
284
+ seg_size: int,
285
+ overlap_rof: int,
286
+ overlap_mdx: float,
287
+ pitch_shift: int,
288
+ hop_length: int,
289
+ enable_denoise: bool,
290
+ window_size: int,
291
+ aggression: int,
292
+ enable_tta: bool,
293
+ enable_post: bool,
294
+ post_thresh: float,
295
+ enable_high_end: bool,
296
+ shifts: int,
297
+ segments_enabled: bool,
298
+ ) -> None:
299
+ """Mutate `arch_params` in place with the advanced-mode slider values."""
300
+ if architecture in ("roformer", "mdx23c"):
301
+ arch_params.update(
302
+ segment_size=seg_size,
303
+ override_segment_size=False,
304
+ pitch_shift=pitch_shift,
305
+ overlap=overlap_rof,
306
+ )
307
+ elif architecture == "mdx":
308
+ arch_params.update(
309
+ hop_length=hop_length,
310
+ segment_size=seg_size,
311
+ overlap=overlap_mdx,
312
+ enable_denoise=enable_denoise,
313
+ )
314
+ elif architecture == "vr":
315
+ arch_params.update(
316
+ window_size=window_size,
317
+ aggression=aggression,
318
+ enable_tta=enable_tta,
319
+ enable_post_process=enable_post,
320
+ post_process_threshold=post_thresh,
321
+ high_end_process=enable_high_end,
322
+ )
323
+ elif architecture == "demucs":
324
+ arch_params.update(
325
+ segment_size=seg_size,
326
+ shifts=shifts,
327
+ overlap=overlap_mdx,
328
+ segments_enabled=segments_enabled,
329
+ )
330
+
331
+
332
+ # --------------------------------------------------------------------------- #
333
+ # Auxiliary endpoints (also through the queue for consistency) #
334
+ # --------------------------------------------------------------------------- #
335
+
336
+ @app.api(name="presets",
337
+ description="Return the catalogue of use-case presets and models.")
338
+ def get_presets() -> dict[str, Any]:
339
+ """Return presets + per-architecture model lists for the frontend."""
340
+ return {
341
+ "presets": {
342
+ name: {
343
+ "architecture": p["architecture"],
344
+ "model": p["model"],
345
+ "description": p["description"],
346
+ "params": p.get("params", {}),
347
+ }
348
+ for name, p in USE_CASE_PRESETS.items()
349
+ },
350
+ "models_by_arch": {
351
+ "roformer": list(ROFORMER_MODELS.keys()),
352
+ "mdx23c": [], # populated from main.models at request time if needed
353
+ "mdx": [], # same
354
+ "vr": [],
355
+ "demucs": [],
356
+ },
357
+ "default_use_case": "Karaoke (vocals out)",
358
+ }
359
+
360
+
361
+ @app.api(name="models",
362
+ description="Return all available models for a given architecture.")
363
+ def get_models(architecture: str) -> list[str]:
364
+ """Return the model dropdown choices for a given architecture."""
365
+ return model_choices_for_arch(architecture)
366
+
367
+
368
+ @app.api(name="history",
369
+ description="Return the user's recent separation history.")
370
+ def get_history() -> list[list[str]]:
371
+ return history_rows()
372
+
373
+
374
+ # --------------------------------------------------------------------------- #
375
+ # Health check (plain FastAPI — no queue needed) #
376
+ # --------------------------------------------------------------------------- #
377
+
378
+ @app.get("/healthz")
379
+ def healthz():
380
+ return JSONResponse({
381
+ "ok": True,
382
+ "device": DEVICE.upper(),
383
+ "engine_installed": HAS_SEPARATOR,
384
+ "spaces_runtime": HAS_SPACES,
385
+ "mp3_bitrate": MP3_BITRATE,
386
+ })
387
+
388
+
389
+ # --------------------------------------------------------------------------- #
390
+ # Entrypoint #
391
+ # --------------------------------------------------------------------------- #
392
 
393
  def main() -> None:
394
  if not HAS_SEPARATOR:
 
398
  print(" (or 'audio-separator[cpu]' for local CPU-only runs)")
399
  print("=" * 60)
400
 
401
+ # Note: `Server` doesn't have a separate `.queue()` method — concurrency
402
+ # is configured per-`@app.api()` via the `concurrency_limit` parameter.
403
+ # The default for `/separate` is `concurrency_limit=1` (set on the
404
+ # decorator) so concurrent users serialize on the GPU.
405
+
406
+ # Gradio's file server only allows downloads from explicitly listed
407
+ # paths. We allow the output dir (where stems + ZIPs are written) and
408
+ # the model cache (in case users want to inspect downloaded weights).
409
+ allowed_paths = [DEFAULT_OUTPUT_DIR, DEFAULT_MODEL_DIR]
410
+
411
+ app.launch(
412
  server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
413
  server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
414
  share=False,
415
  show_error=True,
416
  prevent_thread_lock=False,
417
+ allowed_paths=allowed_paths,
418
  )
419
 
420
 
main/__init__.py CHANGED
@@ -1,14 +1,11 @@
1
- """Audio Separator — Improved Demo (modular package).
2
 
3
- Public surface:
4
- build_app() -> gradio.Blocks
5
- main() -> launch the server
6
- """
7
- from __future__ import annotations
8
-
9
- from .ui import build_app
10
- from .config import DEVICE, USE_AUTOCAST, HAS_SEPARATOR, HAS_SPACES
11
 
12
- __all__ = ["build_app", "DEVICE", "USE_AUTOCAST", "HAS_SEPARATOR", "HAS_SPACES"]
13
-
14
- __version__ = "1.0.0"
 
 
 
1
+ """Audio Separator — Gradio Server Edition (engine package).
2
 
3
+ The public entry point is `app.py` at the project root, which constructs a
4
+ `gradio.server.Server` and registers `@app.api()` endpoints. This package
5
+ holds the engine pieces that the API calls into:
 
 
 
 
 
6
 
7
+ config.py — device detection, paths, logging, `spaces` shim
8
+ models.py — model catalogue + use-case presets
9
+ separator.py — core separation pipeline (@spaces.GPU wrapped)
10
+ utils.py — ZIP packaging, duration, history helpers
11
+ """
main/callbacks.py DELETED
@@ -1,359 +0,0 @@
1
- """Gradio event handlers.
2
-
3
- Each top-level function here is wired to a UI element in `main.ui`. They take
4
- plain Python values (not Gradio components) and return tuples of
5
- `gr.update(...)` / values to be applied to the bound output components.
6
- """
7
- from __future__ import annotations
8
-
9
- import logging
10
- from pathlib import Path
11
- from typing import Any
12
-
13
- import gradio as gr
14
-
15
- from .config import DEVICE, HAS_SEPARATOR
16
- from .models import (
17
- USE_CASE_PRESETS,
18
- infer_architecture_from_model,
19
- model_choices_for_arch,
20
- )
21
- from .separator import separate_core
22
- from .utils import (
23
- append_history,
24
- audio_duration_seconds,
25
- estimate_time_seconds,
26
- fmt_duration,
27
- history_rows,
28
- now_timestamp,
29
- package_stems_as_zip,
30
- )
31
-
32
- log = logging.getLogger("audio-sep")
33
-
34
-
35
- # --------------------------------------------------------------------------- #
36
- # Result shape constants #
37
- # --------------------------------------------------------------------------- #
38
-
39
- # Order of outputs returned by run_separation() and clear_outputs().
40
- # Documented here so the wiring in main.ui stays in sync.
41
- RESULT_FIELDS = [
42
- "stem_1", "stem_2", "stem_3", "stem_4", "stem_5", "stem_6",
43
- "zip_file", "status",
44
- "stem_row_1", "stem_row_2", "stem_row_3",
45
- "est_box", "dur_box", "history_table",
46
- ]
47
- N_STEMS = 6
48
- N_RESULT = len(RESULT_FIELDS) # 14
49
-
50
- # Preferred ordering when displaying stems.
51
- _PREFERRED_STEM_ORDER = [
52
- "Vocals", "Instrumental", "Drums", "Bass", "Other", "Guitar", "Piano",
53
- ]
54
-
55
-
56
- # --------------------------------------------------------------------------- #
57
- # Core separation entry point #
58
- # --------------------------------------------------------------------------- #
59
-
60
- def run_separation(
61
- input_audio,
62
- use_case: str,
63
- model_display_name: str,
64
- advanced_mode: bool,
65
- # Roformer / MDX23C
66
- seg_size: int,
67
- override_seg: bool,
68
- overlap_rof: int,
69
- overlap_mdx: float,
70
- pitch_shift: int,
71
- # MDX-NET
72
- hop_length: int,
73
- enable_denoise: bool,
74
- # VR Arch
75
- window_size: int,
76
- aggression: int,
77
- enable_tta: bool,
78
- enable_post: bool,
79
- post_thresh: float,
80
- enable_high_end: bool,
81
- # Demucs
82
- shifts: int,
83
- segments_enabled: bool,
84
- # Global
85
- model_file_dir: str,
86
- output_dir: str,
87
- out_format: str,
88
- norm_thresh: float,
89
- amp_thresh: float,
90
- batch_size: int,
91
- progress=gr.Progress(track_tqdm=True),
92
- ):
93
- """Single entry point wired to the "Separate" button."""
94
- if not input_audio:
95
- gr.Warning("Please upload or record an audio file first.")
96
- return _empty_result("⚠️ No input audio provided.")
97
-
98
- # Resolve architecture + params from the use-case preset.
99
- preset = USE_CASE_PRESETS.get(use_case, {})
100
- architecture = preset.get("architecture", "roformer")
101
-
102
- chosen_model = model_display_name or preset.get("model", "")
103
- if chosen_model != preset.get("model"):
104
- architecture = infer_architecture_from_model(chosen_model)
105
-
106
- arch_params: dict[str, Any] = dict(preset.get("params", {}))
107
- if advanced_mode:
108
- _apply_advanced_overrides(
109
- arch_params,
110
- architecture=architecture,
111
- seg_size=seg_size,
112
- override_seg=override_seg,
113
- overlap_rof=overlap_rof,
114
- overlap_mdx=overlap_mdx,
115
- pitch_shift=pitch_shift,
116
- hop_length=hop_length,
117
- enable_denoise=enable_denoise,
118
- window_size=window_size,
119
- aggression=aggression,
120
- enable_tta=enable_tta,
121
- enable_post=enable_post,
122
- post_thresh=post_thresh,
123
- enable_high_end=enable_high_end,
124
- shifts=shifts,
125
- segments_enabled=segments_enabled,
126
- )
127
-
128
- duration = audio_duration_seconds(input_audio)
129
- est = estimate_time_seconds(duration, architecture)
130
-
131
- status_running = (
132
- f"🎙️ Input: `{Path(input_audio).name}` • "
133
- f"Duration: **{fmt_duration(duration)}** • "
134
- f"Model: **{chosen_model}** • "
135
- f"Architecture: **{architecture}** • "
136
- f"Device: **{DEVICE.upper()}** • "
137
- f"Estimated time: **{fmt_duration(est)}**"
138
- )
139
-
140
- try:
141
- stems = separate_core(
142
- input_file=input_audio,
143
- architecture=architecture,
144
- model_display_name=chosen_model,
145
- arch_params=arch_params,
146
- model_file_dir=model_file_dir,
147
- output_dir=output_dir,
148
- out_format=out_format,
149
- norm_thresh=norm_thresh,
150
- amp_thresh=amp_thresh,
151
- batch_size=batch_size,
152
- progress=progress,
153
- )
154
- except Exception as e:
155
- log.exception("Separation failed")
156
- gr.Warning(f"Separation failed: {e}")
157
- return _empty_result(
158
- f"❌ **Error:** {e}",
159
- est=fmt_duration(est),
160
- dur=fmt_duration(duration),
161
- )
162
-
163
- if not stems:
164
- return _empty_result(
165
- "⚠️ No stems were produced.",
166
- est=fmt_duration(est),
167
- dur=fmt_duration(duration),
168
- )
169
-
170
- return _build_success_result(
171
- stems=stems,
172
- input_audio=input_audio,
173
- use_case=use_case,
174
- chosen_model=chosen_model,
175
- duration=duration,
176
- est=est,
177
- )
178
-
179
-
180
- def _apply_advanced_overrides(
181
- arch_params: dict[str, Any],
182
- *,
183
- architecture: str,
184
- seg_size: int,
185
- override_seg: bool,
186
- overlap_rof: int,
187
- overlap_mdx: float,
188
- pitch_shift: int,
189
- hop_length: int,
190
- enable_denoise: bool,
191
- window_size: int,
192
- aggression: int,
193
- enable_tta: bool,
194
- enable_post: bool,
195
- post_thresh: float,
196
- enable_high_end: bool,
197
- shifts: int,
198
- segments_enabled: bool,
199
- ) -> None:
200
- """Mutate `arch_params` in place with the advanced-mode slider values."""
201
- if architecture in ("roformer", "mdx23c"):
202
- arch_params.update(
203
- segment_size=seg_size,
204
- override_segment_size=override_seg,
205
- pitch_shift=pitch_shift,
206
- overlap=overlap_rof,
207
- )
208
- elif architecture == "mdx":
209
- arch_params.update(
210
- hop_length=hop_length,
211
- segment_size=seg_size,
212
- overlap=overlap_mdx,
213
- enable_denoise=enable_denoise,
214
- )
215
- elif architecture == "vr":
216
- arch_params.update(
217
- window_size=window_size,
218
- aggression=aggression,
219
- enable_tta=enable_tta,
220
- enable_post_process=enable_post,
221
- post_process_threshold=post_thresh,
222
- high_end_process=enable_high_end,
223
- )
224
- elif architecture == "demucs":
225
- arch_params.update(
226
- segment_size=seg_size,
227
- shifts=shifts,
228
- overlap=overlap_mdx,
229
- segments_enabled=segments_enabled,
230
- )
231
-
232
-
233
- def _build_success_result(
234
- *,
235
- stems: dict[str, str],
236
- input_audio: str,
237
- use_case: str,
238
- chosen_model: str,
239
- duration: float,
240
- est: float,
241
- ):
242
- """Compose the 14-tuple return value for a successful separation."""
243
- # Stable ordering: prefer common stem names first.
244
- ordered_labels = [l for l in _PREFERRED_STEM_ORDER if l in stems]
245
- ordered_labels += [l for l in stems.keys() if l not in ordered_labels]
246
-
247
- out_paths: list[str | None] = [None] * N_STEMS
248
- out_labels: list[str | None] = [None] * N_STEMS
249
- for i, label in enumerate(ordered_labels[:N_STEMS]):
250
- out_paths[i] = stems[label]
251
- out_labels[i] = label
252
-
253
- zip_path = package_stems_as_zip(stems, Path(input_audio).stem)
254
-
255
- append_history({
256
- "time": now_timestamp(),
257
- "use_case": use_case,
258
- "model": chosen_model,
259
- "input": Path(input_audio).name,
260
- "duration": fmt_duration(duration),
261
- "stems": ordered_labels,
262
- })
263
-
264
- # Per-stem gr.update() with label-aware component updates.
265
- stem_updates = []
266
- for i in range(N_STEMS):
267
- if out_paths[i] is not None:
268
- stem_updates.append(
269
- gr.update(value=out_paths[i], visible=True,
270
- label=f"🎵 Stem: {out_labels[i]}")
271
- )
272
- else:
273
- stem_updates.append(gr.update(value=None, visible=False))
274
-
275
- n_stems = len(stems)
276
- n_rows_visible = sum([n_stems > 0, n_stems > 2, n_stems > 4])
277
-
278
- status_done = (
279
- f"✅ **Done!** {n_stems} stem(s) produced: {', '.join(ordered_labels)} • "
280
- f"Input duration {fmt_duration(duration)} • "
281
- f"Model `{chosen_model}` • Device {DEVICE.upper()}"
282
- )
283
-
284
- return (
285
- stem_updates[0], stem_updates[1], stem_updates[2],
286
- stem_updates[3], stem_updates[4], stem_updates[5],
287
- zip_path,
288
- status_done,
289
- gr.update(visible=n_rows_visible >= 1),
290
- gr.update(visible=n_rows_visible >= 2),
291
- gr.update(visible=n_rows_visible >= 3),
292
- fmt_duration(est),
293
- fmt_duration(duration),
294
- history_rows(),
295
- )
296
-
297
-
298
- def _empty_result(
299
- status: str = "Ready.",
300
- est: str = "—",
301
- dur: str = "—",
302
- ):
303
- """Compose the 14-tuple return value for an empty / cleared state."""
304
- return (
305
- gr.update(value=None, visible=False),
306
- gr.update(value=None, visible=False),
307
- gr.update(value=None, visible=False),
308
- gr.update(value=None, visible=False),
309
- gr.update(value=None, visible=False),
310
- gr.update(value=None, visible=False),
311
- None,
312
- status,
313
- gr.update(visible=False),
314
- gr.update(visible=False),
315
- gr.update(visible=False),
316
- est,
317
- dur,
318
- history_rows(),
319
- )
320
-
321
-
322
- # --------------------------------------------------------------------------- #
323
- # UI helper callbacks #
324
- # --------------------------------------------------------------------------- #
325
-
326
- def on_preset_change(use_case: str):
327
- """When the user picks a use-case preset, sync the model dropdown,
328
- show a friendly description, and reveal the matching advanced group.
329
- """
330
- preset = USE_CASE_PRESETS.get(use_case, {})
331
- arch = preset.get("architecture", "roformer")
332
- model = preset.get("model", "")
333
- desc = preset.get("description", "")
334
- return (
335
- gr.update(value=model, choices=model_choices_for_arch(arch)),
336
- f"ℹ️ {desc}" if desc else "",
337
- gr.update(visible=arch in ("roformer", "mdx23c")),
338
- gr.update(visible=arch == "mdx"),
339
- gr.update(visible=arch == "vr"),
340
- gr.update(visible=arch == "demucs"),
341
- )
342
-
343
-
344
- def on_model_change(model_name: str):
345
- """When the user manually picks a different model, infer its
346
- architecture and reveal the matching advanced group.
347
- """
348
- arch = infer_architecture_from_model(model_name)
349
- return (
350
- gr.update(visible=arch in ("roformer", "mdx23c")),
351
- gr.update(visible=arch == "mdx"),
352
- gr.update(visible=arch == "vr"),
353
- gr.update(visible=arch == "demucs"),
354
- )
355
-
356
-
357
- def clear_outputs():
358
- """Reset all output components to their initial empty state."""
359
- return _empty_result("Ready.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main/ui.py DELETED
@@ -1,832 +0,0 @@
1
- """Gradio UI for Audio Separator — Improved Demo.
2
-
3
- Theme philosophy (inspired by the Gradio Server blog's "bring your own
4
- frontend" aesthetic — dark canvas, expressive display type, glass surfaces,
5
- electric-lime accent matching the waveform).
6
- """
7
- from __future__ import annotations
8
-
9
- import os
10
-
11
- import gradio as gr
12
-
13
- from .config import DEFAULT_MODEL_DIR, DEFAULT_OUTPUT_DIR, DEVICE, HAS_SEPARATOR
14
- from .models import ROFORMER_MODELS, USE_CASE_PRESETS
15
- from .utils import history_rows
16
- from .callbacks import (
17
- clear_outputs,
18
- on_model_change,
19
- on_preset_change,
20
- run_separation,
21
- )
22
-
23
-
24
- # --------------------------------------------------------------------------- #
25
- # Theme #
26
- # --------------------------------------------------------------------------- #
27
- # `gr.themes.Base` is the only Gradio base that lets us push true black (#000)
28
- # through every layer. `Soft` and `Default` bake in subtle gray washes that
29
- # fight the look we want.
30
-
31
- theme = gr.themes.Base(
32
- primary_hue=gr.themes.Color(
33
- c50="#fafff0", c100="#f0ffcc", c200="#e0ff99", c300="#c6ff00",
34
- c400="#aee000", c500="#86b300", c600="#5f8000", c700="#3d5200",
35
- c800="#1f2b00", c900="#0e1500", c950="#070a00",
36
- ),
37
- secondary_hue=gr.themes.Color(
38
- c50="#f5f5f7", c100="#ebebef", c200="#d7d7df", c300="#b8b8c4",
39
- c400="#8f8fa0", c500="#6b6b7a", c600="#4f4f5c", c700="#363642",
40
- c800="#1f1f29", c900="#121218", c950="#08080c",
41
- ),
42
- neutral_hue=gr.themes.Color(
43
- c50="#fafafa", c100="#f0f0f0", c200="#d8d8d8", c300="#b4b4b4",
44
- c400="#888888", c500="#5e5e5e", c600="#3e3e3e", c700="#262626",
45
- c800="#161616", c900="#0a0a0a", c950="#000000",
46
- ),
47
- font=[gr.themes.GoogleFont("Syne"), "Arial", "sans-serif"],
48
- font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
49
- radius_size="lg",
50
- )
51
-
52
-
53
- # --------------------------------------------------------------------------- #
54
- # Custom CSS — the heart of the "cooler theme" #
55
- # --------------------------------------------------------------------------- #
56
- # Gradio emits its own CSS first; ours loads after via `Blocks(css=...)` and
57
- # wins on equal specificity. We use high-specificity selectors (id + class
58
- # or `:where(...)`) to avoid `!important` where possible, but a few rules
59
- # do need `!important` to override Gradio's inline styles.
60
-
61
- CUSTOM_CSS = """
62
- /* =========================================================== *
63
- * Root tokens — single source of truth for the look
64
- * =========================================================== */
65
- :root {
66
- --as-bg: #000000;
67
- --as-bg-elev-1: #0a0a0c;
68
- --as-bg-elev-2: #121217;
69
- --as-bg-elev-3: #1b1b22;
70
- --as-border: #232330;
71
- --as-border-soft: #16161c;
72
- --as-text: #f5f5f7;
73
- --as-text-dim: #9b9bab;
74
- --as-text-mute: #5e5e6a;
75
- --as-accent: #c6ff00;
76
- --as-accent-dim: #86b300;
77
- --as-accent-glow: rgba(198, 255, 0, 0.18);
78
- --as-danger: #ff4d6d;
79
- --as-success: #00d68f;
80
- --as-warning: #ffb800;
81
- --as-radius: 14px;
82
- --as-radius-sm: 8px;
83
- --as-shadow: 0 1px 0 rgba(255,255,255,0.04) inset,
84
- 0 8px 24px rgba(0,0,0,0.45);
85
- --as-blur: blur(14px) saturate(140%);
86
- --as-font-display: "Syne", "Arial", sans-serif;
87
- --as-font-body: "Inter", system-ui, -apple-system, sans-serif;
88
- --as-font-mono: "JetBrains Mono", ui-monospace, monospace;
89
- }
90
-
91
- /* =========================================================== *
92
- * Body / canvas
93
- * =========================================================== */
94
- html, body {
95
- background: var(--as-bg) !important;
96
- color: var(--as-text);
97
- font-family: var(--as-font-body);
98
- -webkit-font-smoothing: antialiased;
99
- -moz-osx-font-smoothing: grayscale;
100
- }
101
-
102
- /* Subtle radial glow that lives behind everything — gives the black some
103
- depth without being noisy. Pinned to the viewport so it doesn't scroll. */
104
- body::before {
105
- content: "";
106
- position: fixed;
107
- inset: 0;
108
- z-index: 0;
109
- pointer-events: none;
110
- background:
111
- radial-gradient(900px 600px at 12% -10%, rgba(198,255,0,0.08), transparent 60%),
112
- radial-gradient(700px 500px at 92% 8%, rgba(120,80,255,0.10), transparent 65%),
113
- radial-gradient(800px 800px at 50% 120%, rgba(0,140,255,0.06), transparent 70%);
114
- }
115
- .gradio-container, .gradio-container > * {
116
- position: relative;
117
- z-index: 1;
118
- }
119
-
120
- /* Constrain width and add breathing room. */
121
- .gradio-container {
122
- max-width: 1320px !important;
123
- padding: 32px 28px 80px !important;
124
- }
125
-
126
- /* =========================================================== *
127
- * Hero
128
- * =========================================================== */
129
- #hero {
130
- position: relative;
131
- padding: 40px 32px 32px;
132
- margin: -8px -8px 28px;
133
- border-radius: var(--as-radius);
134
- background:
135
- linear-gradient(135deg, rgba(198,255,0,0.06) 0%, transparent 40%),
136
- linear-gradient(180deg, rgba(255,255,255,0.025), rgba(0,0,0,0));
137
- border: 1px solid var(--as-border);
138
- overflow: hidden;
139
- }
140
- #hero::before {
141
- content: "";
142
- position: absolute;
143
- top: -1px; left: -1px; right: -1px;
144
- height: 1px;
145
- background: linear-gradient(90deg,
146
- transparent, var(--as-accent) 30%, var(--as-accent) 70%, transparent);
147
- opacity: 0.55;
148
- }
149
- #hero h1 {
150
- font-family: var(--as-font-display);
151
- font-weight: 800;
152
- font-size: clamp(2.4rem, 5vw, 3.6rem);
153
- line-height: 1.05;
154
- letter-spacing: -0.02em;
155
- margin: 0 0 14px;
156
- background: linear-gradient(180deg, #ffffff 0%, #b0b0b8 100%);
157
- -webkit-background-clip: text;
158
- background-clip: text;
159
- -webkit-text-fill-color: transparent;
160
- }
161
- #hero h1::after {
162
- content: "";
163
- display: inline-block;
164
- width: 10px; height: 10px;
165
- margin-left: 6px;
166
- border-radius: 50%;
167
- background: var(--as-accent);
168
- box-shadow: 0 0 12px var(--as-accent);
169
- vertical-align: middle;
170
- transform: translateY(-0.5em);
171
- animation: as-pulse 2.2s ease-in-out infinite;
172
- }
173
- #hero p {
174
- font-size: 1rem;
175
- line-height: 1.55;
176
- color: var(--as-text-dim);
177
- max-width: 720px;
178
- margin: 0;
179
- }
180
- #hero code {
181
- font-family: var(--as-font-mono);
182
- color: var(--as-accent);
183
- background: rgba(198,255,0,0.08);
184
- border: 1px solid rgba(198,255,0,0.15);
185
- padding: 1px 6px;
186
- border-radius: 4px;
187
- font-size: 0.86em;
188
- }
189
-
190
- @keyframes as-pulse {
191
- 0%, 100% { opacity: 1; transform: translateY(-0.5em) scale(1); }
192
- 50% { opacity: 0.4; transform: translateY(-0.5em) scale(0.75); }
193
- }
194
-
195
- /* =========================================================== *
196
- * Section labels — small caps divider
197
- * =========================================================== */
198
- .section-label {
199
- font-family: var(--as-font-mono);
200
- font-size: 0.7rem;
201
- font-weight: 600;
202
- letter-spacing: 0.18em;
203
- text-transform: uppercase;
204
- color: var(--as-accent);
205
- padding: 6px 0 14px;
206
- margin: 0 0 14px;
207
- border-bottom: 1px solid var(--as-border-soft);
208
- display: flex;
209
- align-items: center;
210
- gap: 8px;
211
- }
212
- .section-label::before {
213
- content: "";
214
- width: 6px; height: 6px;
215
- background: var(--as-accent);
216
- border-radius: 50%;
217
- box-shadow: 0 0 8px var(--as-accent);
218
- }
219
-
220
- /* =========================================================== *
221
- * Gradio panel / column surfaces — glassmorphism
222
- * =========================================================== */
223
- .gradio-container .form,
224
- .gradio-container .block,
225
- .gradio-container .gr-block,
226
- .gradio-container .gr-panel,
227
- .gradio-container .gap,
228
- .gradio-container .wrap {
229
- background: var(--as-bg-elev-1);
230
- border-color: var(--as-border) !important;
231
- }
232
-
233
- /* The two main columns. */
234
- .gradio-container > .containing > * {
235
- /* No-op: just a hook in case we want column-level tweaks. */
236
- }
237
-
238
- /* =========================================================== *
239
- * Metric tiles — duration / estimated time
240
- * =========================================================== */
241
- .metric-tile {
242
- background: linear-gradient(180deg, var(--as-bg-elev-2), var(--as-bg-elev-1)) !important;
243
- border: 1px solid var(--as-border) !important;
244
- border-left: 3px solid var(--as-accent) !important;
245
- border-radius: var(--as-radius-sm) !important;
246
- padding: 14px 16px !important;
247
- box-shadow: var(--as-shadow);
248
- }
249
- .metric-tile label {
250
- font-family: var(--as-font-mono) !important;
251
- font-size: 0.65rem !important;
252
- letter-spacing: 0.16em !important;
253
- text-transform: uppercase !important;
254
- color: var(--as-text-mute) !important;
255
- font-weight: 500 !important;
256
- }
257
- .metric-tile input,
258
- .metric-tile textarea {
259
- font-family: var(--as-font-display) !important;
260
- font-size: 1.5rem !important;
261
- font-weight: 700 !important;
262
- color: var(--as-text) !important;
263
- background: transparent !important;
264
- }
265
-
266
- /* =========================================================== *
267
- * Status box
268
- * =========================================================== */
269
- #status-box {
270
- background: var(--as-bg-elev-1) !important;
271
- border: 1px solid var(--as-border) !important;
272
- border-left: 3px solid var(--as-accent) !important;
273
- border-radius: var(--as-radius-sm) !important;
274
- padding: 14px 18px !important;
275
- min-height: 64px;
276
- font-family: var(--as-font-body) !important;
277
- color: var(--as-text) !important;
278
- }
279
- #status-box p { margin: 0; line-height: 1.55; }
280
- #status-box code {
281
- font-family: var(--as-font-mono);
282
- color: var(--as-accent);
283
- background: rgba(198,255,0,0.08);
284
- padding: 1px 5px;
285
- border-radius: 3px;
286
- font-size: 0.88em;
287
- }
288
-
289
- /* =========================================================== *
290
- * Buttons — primary glows electric-lime
291
- * =========================================================== */
292
- .gradio-container button.primary,
293
- .gradio-container button[data-testid="primary-button"],
294
- .gradio-container .primary {
295
- background: var(--as-accent) !important;
296
- color: #000 !important;
297
- border: 1px solid var(--as-accent) !important;
298
- font-family: var(--as-font-display) !important;
299
- font-weight: 700 !important;
300
- letter-spacing: 0.01em;
301
- box-shadow:
302
- 0 0 0 1px rgba(198,255,0,0.0) inset,
303
- 0 6px 18px rgba(198,255,0,0.20),
304
- 0 1px 0 rgba(255,255,255,0.18) inset !important;
305
- transition: transform 0.12s ease, box-shadow 0.18s ease, filter 0.18s ease !important;
306
- }
307
- .gradio-container button.primary:hover,
308
- .gradio-container .primary:hover {
309
- filter: brightness(1.08);
310
- transform: translateY(-1px);
311
- box-shadow:
312
- 0 0 0 1px rgba(198,255,0,0.30) inset,
313
- 0 10px 28px rgba(198,255,0,0.35),
314
- 0 1px 0 rgba(255,255,255,0.22) inset !important;
315
- }
316
- .gradio-container button.primary:active,
317
- .gradio-container .primary:active { transform: translateY(0); filter: brightness(0.95); }
318
-
319
- /* Secondary / default buttons — ghost style */
320
- .gradio-container button:not(.primary):not(.gradio-icon-button):not(.icon-button) {
321
- background: var(--as-bg-elev-2) !important;
322
- color: var(--as-text) !important;
323
- border: 1px solid var(--as-border) !important;
324
- font-family: var(--as-font-display) !important;
325
- font-weight: 600 !important;
326
- transition: all 0.15s ease !important;
327
- }
328
- .gradio-container button:not(.primary):not(.gradio-icon-button):not(.icon-button):hover {
329
- border-color: var(--as-accent) !important;
330
- color: var(--as-accent) !important;
331
- background: rgba(198,255,0,0.04) !important;
332
- }
333
-
334
- /* =========================================================== *
335
- * Inputs — textboxes, dropdowns, sliders
336
- * =========================================================== */
337
- .gradio-container input[type="text"],
338
- .gradio-container input[type="number"],
339
- .gradio-container textarea,
340
- .gradio-container select {
341
- background: var(--as-bg-elev-2) !important;
342
- border-color: var(--as-border) !important;
343
- color: var(--as-text) !important;
344
- border-radius: var(--as-radius-sm) !important;
345
- font-family: var(--as-font-body) !important;
346
- transition: border-color 0.15s ease, box-shadow 0.15s ease !important;
347
- }
348
- .gradio-container input[type="text"]:focus,
349
- .gradio-container input[type="number"]:focus,
350
- .gradio-container textarea:focus,
351
- .gradio-container select:focus {
352
- outline: none !important;
353
- border-color: var(--as-accent) !important;
354
- box-shadow: 0 0 0 3px var(--as-accent-glow) !important;
355
- }
356
-
357
- /* Labels above inputs */
358
- .gradio-container label,
359
- .gradio-container .label-wrap > label {
360
- color: var(--as-text-dim) !important;
361
- font-family: var(--as-font-mono) !important;
362
- font-size: 0.72rem !important;
363
- letter-spacing: 0.08em !important;
364
- text-transform: uppercase !important;
365
- font-weight: 500 !important;
366
- }
367
-
368
- /* Slider track + thumb — electric-lime */
369
- .gradio-container input[type="range"] {
370
- accent-color: var(--as-accent);
371
- }
372
- .gradio-container .range-wrap .range-info { color: var(--as-text-dim); }
373
-
374
- /* Checkboxes */
375
- .gradio-container input[type="checkbox"] {
376
- accent-color: var(--as-accent);
377
- }
378
-
379
- /* =========================================================== *
380
- * Accordion — clean dark surfaces, accent on hover
381
- * =========================================================== */
382
- .gradio-container .gr-accordion {
383
- background: var(--as-bg-elev-1) !important;
384
- border-color: var(--as-border) !important;
385
- border-radius: var(--as-radius) !important;
386
- overflow: hidden;
387
- }
388
- .gradio-container .gr-accordion > .label-wrap {
389
- background: var(--as-bg-elev-2) !important;
390
- border-bottom: 1px solid var(--as-border) !important;
391
- color: var(--as-text) !important;
392
- font-family: var(--as-font-display) !important;
393
- font-weight: 600 !important;
394
- padding: 14px 18px !important;
395
- transition: background 0.15s ease !important;
396
- }
397
- .gradio-container .gr-accordion > .label-wrap:hover {
398
- background: var(--as-bg-elev-3) !important;
399
- }
400
-
401
- /* =========================================================== *
402
- * Audio component — recolor the waveform area
403
- * =========================================================== */
404
- .gradio-container .gradio-audio,
405
- .gradio-container .audio-container,
406
- .gradio-container [data-testid="audio"] {
407
- background: var(--as-bg-elev-1) !important;
408
- border: 1px solid var(--as-border) !important;
409
- border-radius: var(--as-radius-sm) !important;
410
- padding: 6px !important;
411
- }
412
- .gradio-container .gradio-audio label,
413
- .gradio-container .audio-container label {
414
- color: var(--as-text-dim) !important;
415
- font-family: var(--as-font-mono) !important;
416
- font-size: 0.7rem !important;
417
- letter-spacing: 0.12em !important;
418
- text-transform: uppercase !important;
419
- }
420
-
421
- /* =========================================================== *
422
- * File download — accent border for ZIP
423
- * =========================================================== */
424
- .gradio-container .gradio-file,
425
- .gradio-container [data-testid="file"] {
426
- background: var(--as-bg-elev-1) !important;
427
- border: 1px solid var(--as-border) !important;
428
- border-left: 3px solid var(--as-accent) !important;
429
- border-radius: var(--as-radius-sm) !important;
430
- padding: 14px !important;
431
- }
432
-
433
- /* =========================================================== *
434
- * DataFrame (history table)
435
- * =========================================================== */
436
- .gradio-container .gradio-dataframe table {
437
- background: var(--as-bg-elev-1) !important;
438
- color: var(--as-text) !important;
439
- border-radius: var(--as-radius-sm) !important;
440
- overflow: hidden;
441
- }
442
- .gradio-container .gradio-dataframe th {
443
- background: var(--as-bg-elev-3) !important;
444
- color: var(--as-accent) !important;
445
- font-family: var(--as-font-mono) !important;
446
- font-size: 0.7rem !important;
447
- letter-spacing: 0.10em !important;
448
- text-transform: uppercase !important;
449
- font-weight: 600 !important;
450
- border-bottom: 1px solid var(--as-border) !important;
451
- }
452
- .gradio-container .gradio-dataframe td {
453
- border-color: var(--as-border-soft) !important;
454
- font-family: var(--as-font-body) !important;
455
- color: var(--as-text-dim) !important;
456
- }
457
- .gradio-container .gradio-dataframe tr:hover td {
458
- background: rgba(198,255,0,0.04) !important;
459
- color: var(--as-text) !important;
460
- }
461
-
462
- /* =========================================================== *
463
- * Markdown content
464
- * =========================================================== */
465
- .gradio-container .prose,
466
- .gradio-container .markdown {
467
- color: var(--as-text-dim) !important;
468
- font-family: var(--as-font-body) !important;
469
- line-height: 1.6;
470
- }
471
- .gradio-container .prose h1,
472
- .gradio-container .prose h2,
473
- .gradio-container .prose h3,
474
- .gradio-container .prose h4 {
475
- color: var(--as-text) !important;
476
- font-family: var(--as-font-display) !important;
477
- font-weight: 700 !important;
478
- letter-spacing: -0.01em;
479
- }
480
- .gradio-container .prose code {
481
- color: var(--as-accent);
482
- background: rgba(198,255,0,0.08);
483
- border: 1px solid rgba(198,255,0,0.15);
484
- padding: 1px 6px;
485
- border-radius: 4px;
486
- font-family: var(--as-font-mono);
487
- font-size: 0.88em;
488
- }
489
- .gradio-container .prose a {
490
- color: var(--as-accent) !important;
491
- text-decoration: none;
492
- border-bottom: 1px dashed rgba(198,255,0,0.4);
493
- }
494
- .gradio-container .prose a:hover { border-bottom-style: solid; }
495
-
496
- /* =========================================================== *
497
- * Footer
498
- * =========================================================== */
499
- .app-footer {
500
- margin-top: 36px;
501
- padding: 18px 22px;
502
- border-top: 1px solid var(--as-border);
503
- color: var(--as-text-mute);
504
- font-family: var(--as-font-mono);
505
- font-size: 0.72rem;
506
- letter-spacing: 0.10em;
507
- text-transform: uppercase;
508
- display: flex;
509
- flex-wrap: wrap;
510
- gap: 18px;
511
- align-items: center;
512
- }
513
- .app-footer b {
514
- color: var(--as-accent);
515
- font-weight: 700;
516
- margin-left: 6px;
517
- }
518
- .app-footer .dot {
519
- color: var(--as-text-mute);
520
- opacity: 0.4;
521
- }
522
-
523
- /* =========================================================== *
524
- * Scrollbar — thin, dark, accent on hover
525
- * =========================================================== */
526
- ::-webkit-scrollbar { width: 10px; height: 10px; }
527
- ::-webkit-scrollbar-track { background: var(--as-bg); }
528
- ::-webkit-scrollbar-thumb {
529
- background: var(--as-bg-elev-3);
530
- border-radius: 5px;
531
- border: 2px solid var(--as-bg);
532
- }
533
- ::-webkit-scrollbar-thumb:hover { background: #2a2a36; }
534
-
535
- /* =========================================================== *
536
- * Selection
537
- * =========================================================== */
538
- ::selection {
539
- background: var(--as-accent);
540
- color: #000;
541
- }
542
- """
543
-
544
-
545
- # --------------------------------------------------------------------------- #
546
- # App layout #
547
- # --------------------------------------------------------------------------- #
548
-
549
- def build_app() -> gr.Blocks:
550
- """Construct the Gradio Blocks UI. Does not launch."""
551
-
552
- with gr.Blocks(
553
- title="Audio Separator — Improved Demo",
554
- theme=theme,
555
- css=CUSTOM_CSS,
556
- analytics_enabled=False,
557
- ) as app:
558
- # ---- Hero ----
559
- gr.HTML(
560
- "<div id='hero'>"
561
- "<h1>Audio&nbsp;Separator</h1>"
562
- "<p>Upload → pick a use case → get labelled stems, ZIP download. "
563
- "Powered by <code>audio-separator</code> + Gradio.</p>"
564
- "</div>"
565
- )
566
-
567
- with gr.Row(equal_height=False):
568
- # ================ LEFT COLUMN: input + presets ================
569
- with gr.Column(scale=1, min_width=380):
570
- gr.HTML('<div class="section-label">Input & Use Case</div>')
571
-
572
- input_audio = gr.Audio(
573
- label="Input Audio",
574
- type="filepath",
575
- waveform_options=gr.WaveformOptions(
576
- show_recording_waveform=True,
577
- waveform_color="#c6ff00",
578
- waveform_progress_color="#ffffff",
579
- ),
580
- )
581
-
582
- use_case = gr.Dropdown(
583
- label="Use Case (one-click preset)",
584
- choices=list(USE_CASE_PRESETS.keys()),
585
- value="Karaoke (vocals out)",
586
- info="Pick a goal — we'll choose the best model + params.",
587
- )
588
- preset_desc = gr.Markdown(
589
- f"ℹ️ {USE_CASE_PRESETS['Karaoke (vocals out)']['description']}",
590
- )
591
-
592
- model_display_name = gr.Dropdown(
593
- label="Model (auto-selected, override anytime)",
594
- choices=list(ROFORMER_MODELS.keys()),
595
- value="MelBand Roformer | Karaoke V2 by Gabox",
596
- interactive=True,
597
- allow_custom_value=True,
598
- )
599
-
600
- advanced_toggle = gr.Checkbox(
601
- label="🔧 Advanced mode (show all parameters)",
602
- value=False,
603
- )
604
-
605
- with gr.Accordion("📂 Global settings", open=False):
606
- model_file_dir = gr.Textbox(
607
- value=DEFAULT_MODEL_DIR,
608
- label="Model cache directory",
609
- info="Where downloaded model weights are stored.",
610
- )
611
- output_dir = gr.Textbox(
612
- value=DEFAULT_OUTPUT_DIR,
613
- label="Output directory",
614
- info="Where separated stems are written.",
615
- )
616
- out_format = gr.Dropdown(
617
- value="wav",
618
- choices=["wav", "flac", "mp3"],
619
- label="Output format",
620
- info="MP3 is encoded at 320 kbps (not the library's 128 kbps default).",
621
- )
622
- norm_thresh = gr.Slider(
623
- 0.1, 1.0, 0.1, 0.9,
624
- label="Normalization threshold",
625
- )
626
- amp_thresh = gr.Slider(
627
- 0.1, 1.0, 0.1, 0.6,
628
- label="Amplification threshold",
629
- )
630
- batch_size = gr.Slider(
631
- 1, 16, 1, 1,
632
- label="Batch size (higher = more RAM, possibly faster)",
633
- )
634
-
635
- separate_btn = gr.Button(
636
- "🚀 Separate Audio",
637
- variant="primary",
638
- size="lg",
639
- )
640
- clear_btn = gr.Button("🧹 Clear outputs", variant="secondary")
641
-
642
- # ================ RIGHT COLUMN: outputs ================
643
- with gr.Column(scale=1, min_width=380):
644
- gr.HTML('<div class="section-label">Output Stems</div>')
645
-
646
- with gr.Row():
647
- dur_box = gr.Textbox(
648
- "—", label="Input duration", interactive=False,
649
- elem_classes=["metric-tile"],
650
- )
651
- est_box = gr.Textbox(
652
- "—", label="Estimated processing time",
653
- interactive=False,
654
- elem_classes=["metric-tile"],
655
- )
656
-
657
- status_box = gr.Markdown(
658
- "Ready. Upload an audio file and click **Separate Audio**.",
659
- elem_id="status-box",
660
- )
661
-
662
- # Up to 6 stems (Demucs 6s), arranged 2-per-row.
663
- with gr.Row(visible=False) as stem_row_1:
664
- stem_1 = gr.Audio(label="🎵 Stem: Vocals",
665
- type="filepath", interactive=False, visible=False)
666
- stem_2 = gr.Audio(label="🎵 Stem: Instrumental",
667
- type="filepath", interactive=False, visible=False)
668
- with gr.Row(visible=False) as stem_row_2:
669
- stem_3 = gr.Audio(label="🎵 Stem: Drums",
670
- type="filepath", interactive=False, visible=False)
671
- stem_4 = gr.Audio(label="🎵 Stem: Bass",
672
- type="filepath", interactive=False, visible=False)
673
- with gr.Row(visible=False) as stem_row_3:
674
- stem_5 = gr.Audio(label="🎵 Stem: Other",
675
- type="filepath", interactive=False, visible=False)
676
- stem_6 = gr.Audio(label="🎵 Stem: Guitar/Piano",
677
- type="filepath", interactive=False, visible=False)
678
-
679
- zip_file = gr.File(label="📦 Download all stems (.zip)")
680
-
681
- # ================ Advanced parameters (per-architecture) ================
682
- with gr.Accordion("⚙️ Advanced parameters (architecture-specific)",
683
- open=False):
684
- with gr.Group(visible=True) as adv_rof:
685
- gr.HTML('<div class="section-label">Roformer / MDX23C</div>')
686
- with gr.Row():
687
- seg_size = gr.Slider(32, 4000, 32, 256, label="Segment size")
688
- override_seg = gr.Checkbox(False, label="Override model seg size")
689
- overlap_rof = gr.Slider(2, 50, 1, 8, label="Overlap")
690
- pitch_shift = gr.Slider(-12, 12, 1, 0, label="Pitch shift (semitones)")
691
-
692
- with gr.Group(visible=False) as adv_mdx:
693
- gr.HTML('<div class="section-label">MDX-NET</div>')
694
- with gr.Row():
695
- hop_length = gr.Slider(32, 2048, 32, 1024, label="Hop length")
696
- overlap_mdx = gr.Slider(0.001, 0.999, 0.001, 0.25,
697
- label="Overlap (MDX/Demucs)")
698
- enable_denoise = gr.Checkbox(False, label="Enable denoise")
699
-
700
- with gr.Group(visible=False) as adv_vr:
701
- gr.HTML('<div class="section-label">VR Arch</div>')
702
- with gr.Row():
703
- window_size = gr.Slider(320, 1024, 32, 512, label="Window size")
704
- aggression = gr.Slider(1, 50, 1, 5, label="Aggression")
705
- enable_tta = gr.Checkbox(False, label="Enable TTA")
706
- enable_post = gr.Checkbox(False, label="Enable post-process")
707
- post_thresh = gr.Slider(0.1, 0.3, 0.1, 0.2,
708
- label="Post-process threshold")
709
- enable_high_end = gr.Checkbox(False, label="High-end processing")
710
-
711
- with gr.Group(visible=False) as adv_demucs:
712
- gr.HTML('<div class="section-label">Demucs</div>')
713
- with gr.Row():
714
- shifts = gr.Slider(0, 20, 1, 2, label="Shifts")
715
- segments_enabled = gr.Checkbox(True, label="Enable segments")
716
-
717
- # ================ History ================
718
- with gr.Accordion("🕘 Recent separations", open=False):
719
- history_table = gr.Dataframe(
720
- headers=["Time", "Use Case", "Model", "Input", "Stems"],
721
- datatype=["str", "str", "str", "str", "str"],
722
- value=history_rows(),
723
- wrap=True,
724
- interactive=False,
725
- )
726
-
727
- # ================ FAQ ================
728
- with gr.Accordion("📖 How to use / FAQ", open=False):
729
- gr.Markdown(_FAQ_MARKDOWN)
730
-
731
- # ================ Footer ================
732
- sep_status = ("installed" if HAS_SEPARATOR
733
- else "MISSING — pip install 'audio-separator[gpu]'")
734
- gr.HTML(
735
- f"<div class='app-footer'>"
736
- f"<span>DEVICE <b>{DEVICE.upper()}</b></span>"
737
- f"<span class='dot'>·</span>"
738
- f"<span>ENGINE <b>{sep_status}</b></span>"
739
- f"<span class='dot'>·</span>"
740
- f"<span>THEME <b>BLACK / SYNE</b></span>"
741
- f"</div>"
742
- )
743
-
744
- # ---- Event wiring ----
745
- use_case.change(
746
- on_preset_change,
747
- inputs=[use_case],
748
- outputs=[
749
- model_display_name, preset_desc,
750
- adv_rof, adv_mdx, adv_vr, adv_demucs,
751
- ],
752
- )
753
- model_display_name.change(
754
- on_model_change,
755
- inputs=[model_display_name],
756
- outputs=[adv_rof, adv_mdx, adv_vr, adv_demucs],
757
- )
758
-
759
- separate_btn.click(
760
- run_separation,
761
- inputs=[
762
- input_audio, use_case, model_display_name, advanced_toggle,
763
- seg_size, override_seg, overlap_rof, overlap_mdx, pitch_shift,
764
- hop_length, enable_denoise,
765
- window_size, aggression, enable_tta, enable_post, post_thresh,
766
- enable_high_end,
767
- shifts, segments_enabled,
768
- model_file_dir, output_dir, out_format,
769
- norm_thresh, amp_thresh, batch_size,
770
- ],
771
- outputs=[
772
- stem_1, stem_2, stem_3, stem_4, stem_5, stem_6,
773
- zip_file, status_box,
774
- stem_row_1, stem_row_2, stem_row_3,
775
- est_box, dur_box, history_table,
776
- ],
777
- )
778
-
779
- clear_btn.click(
780
- clear_outputs,
781
- outputs=[
782
- stem_1, stem_2, stem_3, stem_4, stem_5, stem_6,
783
- zip_file, status_box,
784
- stem_row_1, stem_row_2, stem_row_3,
785
- est_box, dur_box, history_table,
786
- ],
787
- )
788
-
789
- return app
790
-
791
-
792
- # --------------------------------------------------------------------------- #
793
- # FAQ content (kept here so it lives next to the UI it renders into) #
794
- # --------------------------------------------------------------------------- #
795
-
796
- _FAQ_MARKDOWN = """
797
- #### Quick start
798
- 1. **Upload or record** audio in the input box on the left.
799
- 2. **Pick a use case** from the dropdown — the right model is auto-selected.
800
- 3. Click **🚀 Separate Audio**. The first run downloads the model (can be hundreds of MB).
801
- 4. Listen to each stem, A/B against the input, and download a ZIP of everything.
802
-
803
- #### When to override the model
804
- - **Karaoke** → `MelBand Roformer | Karaoke V2 by Gabox` is a great default.
805
- - **Clean a cappella** → `BS-Roformer-Viperx-1297` (highest reported SDR).
806
- - **De-reverb** → `MelBand Roformer | De-Reverb by anvuew`.
807
- - **De-noise** → `Mel-Roformer-Denoise-Aufr33`.
808
- - **Multi-stem remix** → `htdemucs_ft.yaml` (4 stems) or `htdemucs_6s.yaml` (6 stems).
809
- - **CPU-only / speed** → `UVR-MDX-NET-Inst_HQ_3.onnx`.
810
-
811
- #### Advanced parameters cheat sheet
812
- | Param | Effect |
813
- |---|---|
814
- | `segment_size` | Larger = better quality, more RAM. |
815
- | `overlap` | Higher = better quality, slower. |
816
- | `pitch_shift` | Shifts pitch ±12 semitones during processing (Roformer/MDX23C). |
817
- | `enable_denoise` | MDX-NET only — removes residual noise on stems. |
818
- | `enable_tta` | VR Arch — Test-Time Augmentation, slower but cleaner. |
819
- | `shifts` | Demucs — number of random-shift passes (higher = better, slower). |
820
-
821
- #### Tips
822
- - For best results use lossless inputs (WAV/FLAC).
823
- - Models auto-cache in `Model cache directory` — first run is slow, later runs are fast.
824
- - Use **Advanced mode** to expose every parameter the upstream HF Space exposes.
825
- - MP3 output is encoded at **320 kbps** (the upstream library's 128 kbps default is overridden).
826
- - Transient `librosa.ParameterError` failures are automatically retried once.
827
-
828
- #### Credits
829
- - Upstream: [nomadkaraoke/audio-separator](https://huggingface.co/spaces/nomadkaraoke/audio-separator) HF Space.
830
- - Engine: [`audio-separator`](https://github.com/nomadkaraoke/python-audio-separator) by @berevadb.
831
- - Models: various community contributors (UVR, Gabox, Aufr33, Viperx, Sucial, anvuew, Unwa, …).
832
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,23 +1,26 @@
1
- # Audio Separator — Improved Demo: dependencies
2
  # =================================================
3
  #
 
 
 
4
  # Recommended install (CPU, ~1.5 GB on disk):
5
  #
6
  # python3 -m venv venv
7
  # source venv/bin/activate
8
  #
9
  # # 1) Install CPU-only torch FIRST (skips CUDA libs, saves ~4 GB).
10
- # pip install --index-url https://download.pytorch.org/whl/cpu torch torchaudio torchvision
11
  #
12
  # # 2) Then install the rest. `--no-deps` for audio-separator prevents
13
  # # pip from re-installing the CUDA-enabled torch.
14
- # pip install "gradio>=5.0,<6.0"
15
  # pip install --no-deps "audio-separator[cpu]>=0.44.0"
16
  # pip install --no-deps \
17
  # librosa ml_collections einops julius rotary-embedding-torch \
18
- # onnx2torch-py313 onnx-weekly resampy samplerate diffq \
19
  # beartype pydub soundfile lazy_loader numba llvmlite pooch \
20
- # ml_dtypes cffi
21
  #
22
  # # 3) System dependency:
23
  # # sudo apt-get install -y ffmpeg # Debian/Ubuntu
@@ -25,15 +28,16 @@
25
  #
26
  # Simpler one-liner that works if disk space is not a concern:
27
  #
28
- # pip install "audio-separator[cpu]>=0.44.0" "gradio>=5.0,<6.0"
29
  #
30
  # (This pulls in the default CUDA-enabled torch on Linux, ~5 GB.)
31
  #
32
  # For NVIDIA GPU acceleration (CUDA 12.x):
33
  #
34
- # pip install "audio-separator[gpu]>=0.44.0" "gradio>=5.0,<6.0"
35
  #
36
  # =================================================
37
 
38
- gradio>=5.0,<6.0
39
  audio-separator[cpu]>=0.44.0
 
 
1
+ # Audio Separator — Gradio Server Edition: dependencies
2
  # =================================================
3
  #
4
+ # This version uses `gradio.Server` (introduced in gradio 6.x) — see
5
+ # https://huggingface.co/blog/introducing-gradio-server
6
+ #
7
  # Recommended install (CPU, ~1.5 GB on disk):
8
  #
9
  # python3 -m venv venv
10
  # source venv/bin/activate
11
  #
12
  # # 1) Install CPU-only torch FIRST (skips CUDA libs, saves ~4 GB).
13
+ # pip install --index-url https://download.pytorch.org/whl/cpu torch torchaudio
14
  #
15
  # # 2) Then install the rest. `--no-deps` for audio-separator prevents
16
  # # pip from re-installing the CUDA-enabled torch.
17
+ # pip install "gradio>=6.0"
18
  # pip install --no-deps "audio-separator[cpu]>=0.44.0"
19
  # pip install --no-deps \
20
  # librosa ml_collections einops julius rotary-embedding-torch \
21
+ # onnx2torch-py313 resampy samplerate diffq \
22
  # beartype pydub soundfile lazy_loader numba llvmlite pooch \
23
+ # ml_dtypes cffi onnxruntime
24
  #
25
  # # 3) System dependency:
26
  # # sudo apt-get install -y ffmpeg # Debian/Ubuntu
 
28
  #
29
  # Simpler one-liner that works if disk space is not a concern:
30
  #
31
+ # pip install "audio-separator[cpu]>=0.44.0" "gradio>=6.0"
32
  #
33
  # (This pulls in the default CUDA-enabled torch on Linux, ~5 GB.)
34
  #
35
  # For NVIDIA GPU acceleration (CUDA 12.x):
36
  #
37
+ # pip install "audio-separator[gpu]>=0.44.0" "gradio>=6.0"
38
  #
39
  # =================================================
40
 
41
+ gradio>=6.0
42
  audio-separator[cpu]>=0.44.0
43
+
static/app.js ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================ *
2
+ * Audio Separator — Gradio Server Edition
3
+ * Vanilla JS — talks to the backend via the Gradio JS client.
4
+ *
5
+ * Pattern (straight from the blog):
6
+ * import { Client, handle_file } from "@gradio/client";
7
+ * const client = await Client.connect(window.location.origin);
8
+ * const result = await client.predict("/api_name", { ...payload });
9
+ * const url = result.data[0].url; // FileData URL
10
+ * ============================================================ */
11
+
12
+ (function () {
13
+ "use strict";
14
+
15
+ // The Gradio JS client is loaded as a global from CDN (see index.html).
16
+ // It exposes `window.gradio` with `Client` and `handle_file`.
17
+ const { Client, handle_file } = window.gradio;
18
+
19
+ // ------------------------------------------------------------------ //
20
+ // State //
21
+ // ------------------------------------------------------------------ //
22
+ const state = {
23
+ presets: null,
24
+ selectedFile: null,
25
+ isWorking: false,
26
+ client: null,
27
+ };
28
+
29
+ // ------------------------------------------------------------------ //
30
+ // DOM helpers //
31
+ // ------------------------------------------------------------------ //
32
+ const $ = (sel) => document.querySelector(sel);
33
+ const $$ = (sel) => document.querySelectorAll(sel);
34
+
35
+ function el(tag, attrs = {}, ...children) {
36
+ const node = document.createElement(tag);
37
+ for (const [k, v] of Object.entries(attrs)) {
38
+ if (k === "class") node.className = v;
39
+ else if (k === "html") node.innerHTML = v;
40
+ else if (k.startsWith("on") && typeof v === "function") {
41
+ node.addEventListener(k.slice(2).toLowerCase(), v);
42
+ } else if (v !== null && v !== undefined) {
43
+ node.setAttribute(k, v);
44
+ }
45
+ }
46
+ for (const child of children) {
47
+ if (child === null || child === undefined) continue;
48
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
49
+ }
50
+ return node;
51
+ }
52
+
53
+ // ------------------------------------------------------------------ //
54
+ // Status / metrics //
55
+ // ------------------------------------------------------------------ //
56
+ function setStatus(text, kind = "ready") {
57
+ const box = $("#status");
58
+ box.className = `status ${kind}`;
59
+ box.innerHTML = text;
60
+ }
61
+
62
+ function setMetrics({ dur = "—", est = "—", stems = "—" } = {}) {
63
+ $("#metric-dur").textContent = dur;
64
+ $("#metric-est").textContent = est;
65
+ $("#metric-stems").textContent = stems;
66
+ }
67
+
68
+ // ------------------------------------------------------------------ //
69
+ // Slider value display //
70
+ // ------------------------------------------------------------------ //
71
+ function bindSliderDisplay(sliderId, displayId, formatter = (v) => v) {
72
+ const slider = document.getElementById(sliderId);
73
+ const display = document.getElementById(displayId);
74
+ if (!slider || !display) return;
75
+ const update = () => { display.textContent = formatter(slider.value); };
76
+ slider.addEventListener("input", update);
77
+ update();
78
+ }
79
+
80
+ // ------------------------------------------------------------------ //
81
+ // Dropzone & file input //
82
+ // ------------------------------------------------------------------ //
83
+ function initDropzone() {
84
+ const dz = $("#dropzone");
85
+ const input = $("#file-input");
86
+ const preview = $("#preview");
87
+ const previewWrap = $("#preview-wrap");
88
+ const previewName = $("#preview-name");
89
+ const previewDur = $("#preview-dur");
90
+ const clearBtn = $("#clear-input");
91
+ const separateBtn = $("#separate-btn");
92
+
93
+ function handleFile(file) {
94
+ if (!file) return;
95
+ if (!file.type.startsWith("audio/") && !/\.(wav|mp3|flac|m4a|ogg|aac|opus)$/i.test(file.name)) {
96
+ setStatus(`⚠️ "${file.name}" doesn't look like an audio file. Try anyway?`, "error");
97
+ }
98
+ state.selectedFile = file;
99
+ const url = URL.createObjectURL(file);
100
+ preview.src = url;
101
+ previewName.textContent = file.name;
102
+ previewDur.textContent = `${(file.size / 1024 / 1024).toFixed(2)} MB`;
103
+ previewWrap.hidden = false;
104
+ dz.style.display = "none";
105
+ separateBtn.disabled = false;
106
+ setStatus(`Ready. Click <strong>Separate audio</strong> to process <code>${file.name}</code>.`);
107
+ }
108
+
109
+ function clearFile() {
110
+ state.selectedFile = null;
111
+ preview.src = "";
112
+ previewWrap.hidden = true;
113
+ dz.style.display = "";
114
+ separateBtn.disabled = true;
115
+ setStatus("Ready. Upload audio and click <strong>Separate</strong>.");
116
+ setMetrics({});
117
+ }
118
+
119
+ // Click to browse
120
+ dz.addEventListener("click", () => input.click());
121
+ dz.addEventListener("keydown", (e) => {
122
+ if (e.key === "Enter" || e.key === " ") {
123
+ e.preventDefault();
124
+ input.click();
125
+ }
126
+ });
127
+
128
+ // File picker
129
+ input.addEventListener("change", (e) => {
130
+ const file = e.target.files && e.target.files[0];
131
+ if (file) handleFile(file);
132
+ });
133
+
134
+ // Drag & drop
135
+ ["dragenter", "dragover"].forEach((ev) => {
136
+ dz.addEventListener(ev, (e) => {
137
+ e.preventDefault();
138
+ dz.classList.add("dragging");
139
+ });
140
+ });
141
+ ["dragleave", "drop"].forEach((ev) => {
142
+ dz.addEventListener(ev, (e) => {
143
+ e.preventDefault();
144
+ dz.classList.remove("dragging");
145
+ });
146
+ });
147
+ dz.addEventListener("drop", (e) => {
148
+ const file = e.dataTransfer.files && e.dataTransfer.files[0];
149
+ if (file) handleFile(file);
150
+ });
151
+
152
+ clearBtn.addEventListener("click", clearFile);
153
+
154
+ // Compute duration once metadata loads
155
+ preview.addEventListener("loadedmetadata", () => {
156
+ const s = preview.duration;
157
+ if (isFinite(s)) {
158
+ const mm = Math.floor(s / 60).toString().padStart(2, "0");
159
+ const ss = Math.floor(s % 60).toString().padStart(2, "0");
160
+ previewDur.textContent = `${mm}:${ss} · ${(state.selectedFile.size / 1024 / 1024).toFixed(2)} MB`;
161
+ }
162
+ });
163
+ }
164
+
165
+ // ------------------------------------------------------------------ //
166
+ // Preset / model dropdowns //
167
+ // ------------------------------------------------------------------ //
168
+ async function loadPresets() {
169
+ try {
170
+ const result = await state.client.predict("/presets", {});
171
+ const data = result.data[0];
172
+ state.presets = data;
173
+
174
+ const useCaseSel = $("#use-case");
175
+ useCaseSel.innerHTML = "";
176
+ Object.keys(data.presets).forEach((name) => {
177
+ const opt = el("option", { value: name }, name);
178
+ if (name === data.default_use_case) opt.selected = true;
179
+ useCaseSel.appendChild(opt);
180
+ });
181
+
182
+ // Load model list for the default architecture
183
+ await refreshModelDropdown(data.default_use_case);
184
+
185
+ // Trigger initial preset description
186
+ useCaseSel.dispatchEvent(new Event("change"));
187
+ } catch (err) {
188
+ console.error("Failed to load presets:", err);
189
+ setStatus(`⚠️ Failed to load presets: ${err.message || err}`, "error");
190
+ }
191
+ }
192
+
193
+ async function refreshModelDropdown(useCaseName) {
194
+ if (!state.presets) return;
195
+ const preset = state.presets.presets[useCaseName];
196
+ if (!preset) return;
197
+
198
+ const arch = preset.architecture;
199
+ try {
200
+ const result = await state.client.predict("/models", { architecture: arch });
201
+ const models = result.data[0];
202
+ const sel = $("#model");
203
+ sel.innerHTML = "";
204
+ models.forEach((name) => {
205
+ const opt = el("option", { value: name }, name);
206
+ if (name === preset.model) opt.selected = true;
207
+ sel.appendChild(opt);
208
+ });
209
+ } catch (err) {
210
+ console.error("Failed to load models:", err);
211
+ }
212
+ }
213
+
214
+ function onUseCaseChange() {
215
+ const useCaseName = $("#use-case").value;
216
+ if (!state.presets) return;
217
+ const preset = state.presets.presets[useCaseName];
218
+ if (!preset) return;
219
+
220
+ $("#preset-desc").textContent = `ℹ️ ${preset.description}`;
221
+ refreshModelDropdown(useCaseName);
222
+ }
223
+
224
+ function onModelChange() {
225
+ // Architecture groups visibility is auto-managed by the backend's
226
+ // response, but since we don't get the architecture back from /models,
227
+ // we re-derive it client-side from the preset.
228
+ const modelName = $("#model").value;
229
+ const useCaseName = $("#use-case").value;
230
+ if (!state.presets) return;
231
+ const preset = state.presets.presets[useCaseName];
232
+ if (!preset) return;
233
+ const arch = (modelName === preset.model) ? preset.architecture : inferArchFromModel(modelName);
234
+ updateAdvancedGroups(arch);
235
+ }
236
+
237
+ function inferArchFromModel(name) {
238
+ if (!name) return "roformer";
239
+ if (name.endsWith(".yaml")) return "demucs";
240
+ if (name.endsWith(".pth")) return "vr";
241
+ if (name.endsWith(".onnx")) return "mdx";
242
+ if (name.endsWith(".ckpt")) return "roformer";
243
+ return "roformer";
244
+ }
245
+
246
+ function updateAdvancedGroups(arch) {
247
+ $("#adv-rof").hidden = !(arch === "roformer" || arch === "mdx23c");
248
+ $("#adv-mdx").hidden = !(arch === "mdx");
249
+ $("#adv-vr").hidden = !(arch === "vr");
250
+ $("#adv-demucs").hidden = !(arch === "demucs");
251
+ }
252
+
253
+ // ------------------------------------------------------------------ //
254
+ // Advanced toggle //
255
+ // ------------------------------------------------------------------ //
256
+ function initAdvancedToggle() {
257
+ const cb = $("#advanced");
258
+ const acc = $("#adv-accordion");
259
+ cb.addEventListener("change", () => {
260
+ acc.hidden = !cb.checked;
261
+ if (cb.checked) acc.open = true;
262
+ });
263
+ }
264
+
265
+ // ------------------------------------------------------------------ //
266
+ // History //
267
+ // ------------------------------------------------------------------ //
268
+ async function loadHistory() {
269
+ try {
270
+ const result = await state.client.predict("/history", {});
271
+ const rows = result.data[0] || [];
272
+ const body = $("#history-body");
273
+ body.innerHTML = "";
274
+ if (!rows.length) {
275
+ body.appendChild(el("tr", { class: "empty-row" }, el("td", { colspan: "5" }, "No history yet.")));
276
+ return;
277
+ }
278
+ rows.forEach((r) => {
279
+ body.appendChild(el("tr",
280
+ null,
281
+ el("td", null, r[0] || ""),
282
+ el("td", null, r[1] || ""),
283
+ el("td", null, r[2] || ""),
284
+ el("td", null, r[3] || ""),
285
+ el("td", null, r[4] || "")
286
+ ));
287
+ });
288
+ } catch (err) {
289
+ // Silent — history is best-effort
290
+ console.warn("History load failed:", err);
291
+ }
292
+ }
293
+
294
+ // ------------------------------------------------------------------ //
295
+ // Separation //
296
+ // ------------------------------------------------------------------ //
297
+ async function runSeparation() {
298
+ if (state.isWorking) return;
299
+ if (!state.selectedFile) {
300
+ setStatus("⚠️ Please upload an audio file first.", "error");
301
+ return;
302
+ }
303
+
304
+ state.isWorking = true;
305
+ const btn = $("#separate-btn");
306
+ btn.disabled = true;
307
+ $(".btn-label", btn).textContent = "Working…";
308
+ $(".btn-spinner", btn).hidden = false;
309
+
310
+ setStatus(`Working on <code>${state.selectedFile.name}</code>… First run downloads the model, so this can take a while.`, "working");
311
+ setMetrics({ stems: "…" });
312
+
313
+ // Build payload
314
+ const payload = {
315
+ audio_file: handle_file(state.selectedFile),
316
+ use_case: $("#use-case").value,
317
+ model_display_name: $("#model").value,
318
+ output_format: $("#output-format").value,
319
+ advanced_mode: $("#advanced").checked,
320
+ seg_size: parseInt($("#seg-size").value, 10),
321
+ overlap_rof: parseInt($("#overlap-rof").value, 10),
322
+ overlap_mdx: parseFloat($("#overlap-mdx").value),
323
+ pitch_shift: parseInt($("#pitch-shift").value, 10),
324
+ hop_length: parseInt($("#hop-length").value, 10),
325
+ enable_denoise: $("#enable-denoise").checked,
326
+ window_size: parseInt($("#window-size").value, 10),
327
+ aggression: parseInt($("#aggression").value, 10),
328
+ enable_tta: $("#enable-tta").checked,
329
+ enable_post: $("#enable-post").checked,
330
+ post_thresh: parseFloat($("#post-thresh").value),
331
+ enable_high_end: $("#enable-high-end").checked,
332
+ shifts: parseInt($("#shifts").value, 10),
333
+ segments_enabled: $("#segments-enabled").checked,
334
+ norm_thresh: parseFloat($("#norm-thresh").value),
335
+ amp_thresh: parseFloat($("#amp-thresh").value),
336
+ batch_size: parseInt($("#batch-size").value, 10),
337
+ };
338
+
339
+ try {
340
+ const result = await state.client.predict("/separate", payload);
341
+ const data = result.data[0];
342
+
343
+ if (data.error) {
344
+ setStatus(`❌ ${data.error}`, "error");
345
+ setMetrics({
346
+ dur: data.duration || "—",
347
+ est: data.est || "—",
348
+ stems: "0",
349
+ });
350
+ return;
351
+ }
352
+
353
+ renderStems(data.stems || []);
354
+ renderZip(data.zip);
355
+
356
+ const stemNames = (data.stems || []).map((s) => s.label).join(", ");
357
+ setStatus(
358
+ `✅ <strong>Done.</strong> ${data.stems.length} stem(s): ${stemNames} · ` +
359
+ `Model <code>${data.model}</code> · Architecture <code>${data.architecture}</code> · ` +
360
+ `Device <code>${data.device}</code>`,
361
+ "success"
362
+ );
363
+ setMetrics({
364
+ dur: data.duration || "—",
365
+ est: data.est || "—",
366
+ stems: String(data.stems.length),
367
+ });
368
+
369
+ loadHistory();
370
+ } catch (err) {
371
+ console.error("Separation failed:", err);
372
+ setStatus(`❌ <strong>Error:</strong> ${err.message || err}`, "error");
373
+ } finally {
374
+ state.isWorking = false;
375
+ btn.disabled = false;
376
+ $(".btn-label", btn).textContent = "Separate audio";
377
+ $(".btn-spinner", btn).hidden = true;
378
+ }
379
+ }
380
+
381
+ function renderStems(stems) {
382
+ const container = $("#stems");
383
+ container.innerHTML = "";
384
+
385
+ if (!stems.length) {
386
+ $("#empty-state").classList.remove("hidden");
387
+ return;
388
+ }
389
+ $("#empty-state").classList.add("hidden");
390
+
391
+ stems.forEach((stem) => {
392
+ // stem.file is a FileData — its `.url` is the playable URL.
393
+ // Some serialization paths leave `url` null and only populate `path`;
394
+ // in that case we build the URL ourselves via Gradio's /file= endpoint.
395
+ const url = fileDataUrl(stem.file);
396
+ if (!url) return;
397
+
398
+ const card = el("div", { class: "stem-card" },
399
+ el("div", { class: "stem-header" },
400
+ el("div", { class: "stem-label" }, `🎵 ${stem.label}`),
401
+ el("a", { class: "stem-download", href: url, download: "" }, "download ↓")
402
+ ),
403
+ el("audio", { controls: "", preload: "metadata", src: url })
404
+ );
405
+ container.appendChild(card);
406
+ });
407
+ }
408
+
409
+ function renderZip(zipFileData) {
410
+ const link = $("#zip-link");
411
+ if (!zipFileData) {
412
+ link.hidden = true;
413
+ return;
414
+ }
415
+ const url = fileDataUrl(zipFileData);
416
+ if (!url) {
417
+ link.hidden = true;
418
+ return;
419
+ }
420
+ link.href = url;
421
+ link.hidden = false;
422
+ }
423
+
424
+ /**
425
+ * Resolve a FileData (or string path) to a playable/downloadable URL.
426
+ * The Gradio wire protocol gives us either:
427
+ * - { url: "https://...", path: "/abs/path" } — use url directly
428
+ * - { url: null, path: "/abs/path" } — build via /gradio_api/file=
429
+ * - "/abs/path" — build via /gradio_api/file=
430
+ * - "https://..." — use directly
431
+ */
432
+ function fileDataUrl(fileData) {
433
+ if (!fileData) return null;
434
+ if (typeof fileData === "string") {
435
+ if (/^https?:\/\//.test(fileData)) return fileData;
436
+ // Gradio expects the path UNencoded after "file=" — it does its own
437
+ // path parsing. Encoding with encodeURIComponent would break it.
438
+ return `${window.location.origin}/gradio_api/file=${fileData}`;
439
+ }
440
+ if (fileData.url) return fileData.url;
441
+ if (fileData.path) {
442
+ return `${window.location.origin}/gradio_api/file=${fileData.path}`;
443
+ }
444
+ return null;
445
+ }
446
+
447
+ // ------------------------------------------------------------------ //
448
+ // Clear //
449
+ // ------------------------------------------------------------------ //
450
+ function clearOutputs() {
451
+ $("#stems").innerHTML = "";
452
+ $("#zip-link").hidden = true;
453
+ $("#empty-state").classList.remove("hidden");
454
+ setMetrics({});
455
+ setStatus("Ready. Upload audio and click <strong>Separate</strong>.", "ready");
456
+ }
457
+
458
+ // ------------------------------------------------------------------ //
459
+ // Health badge //
460
+ // ------------------------------------------------------------------ //
461
+ async function loadHealth() {
462
+ try {
463
+ const res = await fetch("/healthz");
464
+ const data = await res.json();
465
+ $("#device-badge").textContent = `device: ${data.device || "—"}`;
466
+ $("#engine-badge").textContent = `engine: ${data.engine_installed ? "ok" : "missing"}`;
467
+ $("#footer-device").textContent = data.device || "—";
468
+ $("#footer-engine").textContent = data.engine_installed ? "installed" : "missing";
469
+ } catch (err) {
470
+ $("#device-badge").textContent = "device: ?";
471
+ $("#engine-badge").textContent = "engine: ?";
472
+ }
473
+ }
474
+
475
+ // ------------------------------------------------------------------ //
476
+ // Init //
477
+ // ------------------------------------------------------------------ //
478
+ async function init() {
479
+ // Bind slider value displays
480
+ bindSliderDisplay("norm-thresh", "norm-thresh-val");
481
+ bindSliderDisplay("amp-thresh", "amp-thresh-val");
482
+ bindSliderDisplay("batch-size", "batch-size-val");
483
+ bindSliderDisplay("seg-size", "seg-size-val");
484
+ bindSliderDisplay("overlap-rof", "overlap-rof-val");
485
+ bindSliderDisplay("overlap-mdx", "overlap-mdx-val", (v) => parseFloat(v).toFixed(3));
486
+ bindSliderDisplay("pitch-shift", "pitch-shift-val");
487
+ bindSliderDisplay("hop-length", "hop-length-val");
488
+ bindSliderDisplay("window-size", "window-size-val");
489
+ bindSliderDisplay("aggression", "aggression-val");
490
+ bindSliderDisplay("post-thresh", "post-thresh-val");
491
+ bindSliderDisplay("shifts", "shifts-val");
492
+
493
+ initDropzone();
494
+ initAdvancedToggle();
495
+
496
+ // Wire up events
497
+ $("#use-case").addEventListener("change", onUseCaseChange);
498
+ $("#model").addEventListener("change", onModelChange);
499
+ $("#separate-btn").addEventListener("click", runSeparation);
500
+ $("#clear-btn").addEventListener("click", clearOutputs);
501
+
502
+ // Connect Gradio client to the current origin (as the blog shows)
503
+ try {
504
+ state.client = await Client.connect(window.location.origin);
505
+ } catch (err) {
506
+ console.error("Failed to connect Gradio client:", err);
507
+ setStatus(`Failed to connect to backend: ${err.message || err}`, "error");
508
+ return;
509
+ }
510
+
511
+ await Promise.all([loadHealth(), loadPresets(), loadHistory()]);
512
+
513
+ // Initial advanced-group visibility (matches default preset's arch)
514
+ onModelChange();
515
+ }
516
+
517
+ // The Gradio client is loaded as an ESM module via a separate <script type="module">
518
+ // tag, which executes asynchronously. Wait for it to expose `window.gradio`
519
+ // before initializing.
520
+ function waitForClient() {
521
+ return new Promise((resolve) => {
522
+ if (window.gradio) return resolve();
523
+ window.addEventListener("gradio-client-ready", () => resolve(), { once: true });
524
+ // Safety timeout — if the CDN fails, surface an error after 15s.
525
+ setTimeout(() => resolve(), 15000);
526
+ });
527
+ }
528
+
529
+ async function bootstrap() {
530
+ await waitForClient();
531
+ if (!window.gradio) {
532
+ setStatus("Failed to load the Gradio JS client from CDN. Check your network connection.", "error");
533
+ return;
534
+ }
535
+ await init();
536
+ }
537
+
538
+ // The Gradio client ESM loads async; app.js is `defer` so DOM is ready.
539
+ if (document.readyState === "loading") {
540
+ document.addEventListener("DOMContentLoaded", bootstrap);
541
+ } else {
542
+ bootstrap();
543
+ }
544
+ })();
static/index.html ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Audio Separator — Gradio Server Edition</title>
7
+ <meta name="description" content="Separate any audio into stems (vocals, instrumental, drums, bass…). Powered by audio-separator + Gradio Server." />
8
+
9
+ <!-- Fonts: Syne (display) + JetBrains Mono (code/labels) + Inter (body) -->
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
13
+
14
+ <!-- App styles -->
15
+ <link rel="stylesheet" href="/static/style.css" />
16
+ </head>
17
+ <body>
18
+ <!-- Background gradient layer -->
19
+ <div class="bg-glow" aria-hidden="true"></div>
20
+
21
+ <!-- =========================================================== -->
22
+ <!-- Top nav (slim) -->
23
+ <!-- =========================================================== -->
24
+ <nav class="topbar">
25
+ <div class="topbar-brand">
26
+ <span class="logo-dot"></span>
27
+ <span class="brand-name">audio<span class="accent">·</span>separator</span>
28
+ </div>
29
+ <div class="topbar-meta">
30
+ <span id="device-badge" class="badge">device: —</span>
31
+ <span id="engine-badge" class="badge">engine: —</span>
32
+ <a class="topbar-link" href="https://huggingface.co/blog/introducing-gradio-server" target="_blank" rel="noopener">built on gradio.server ↗</a>
33
+ </div>
34
+ </nav>
35
+
36
+ <!-- =========================================================== -->
37
+ <!-- Hero -->
38
+ <!-- =========================================================== -->
39
+ <header class="hero">
40
+ <h1>Separate any track into <span class="accent-text">stems</span>.</h1>
41
+ <p>
42
+ Upload a song, pick a use case, and the right model loads automatically.
43
+ Powered by <a href="https://github.com/nomadkaraoke/python-audio-separator" target="_blank" rel="noopener"><code>audio-separator</code></a>
44
+ on the backend and a hand-built vanilla frontend talking to a
45
+ <code>gradio.Server</code> API. No build step. No framework on the client.
46
+ </p>
47
+ </header>
48
+
49
+ <!-- =========================================================== -->
50
+ <!-- Main grid -->
51
+ <!-- =========================================================== -->
52
+ <main class="grid">
53
+
54
+ <!-- ============ LEFT: input + controls ============ -->
55
+ <section class="panel panel-input">
56
+ <div class="section-label">Input &amp; use case</div>
57
+
58
+ <!-- Dropzone (click or drag) -->
59
+ <div id="dropzone" class="dropzone" tabindex="0" role="button" aria-label="Upload audio">
60
+ <input id="file-input" type="file" accept="audio/*" hidden />
61
+ <div class="dropzone-inner">
62
+ <svg class="dropzone-icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true">
63
+ <path d="M12 3v12m0-12l-4 4m4-4l4 4M5 21h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
64
+ </svg>
65
+ <div class="dropzone-text">
66
+ <strong>Drop audio here</strong>
67
+ <span>or click to browse · wav, mp3, flac, m4a, ogg</span>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Audio preview + duration -->
73
+ <div id="preview-wrap" class="preview-wrap" hidden>
74
+ <audio id="preview" controls preload="metadata"></audio>
75
+ <div class="preview-meta">
76
+ <span id="preview-name" class="preview-name">—</span>
77
+ <span id="preview-dur" class="preview-dur">—</span>
78
+ <button id="clear-input" type="button" class="link-btn">remove</button>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Use case dropdown -->
83
+ <div class="field">
84
+ <label for="use-case">Use case <span class="hint">— picks the right model for you</span></label>
85
+ <select id="use-case"></select>
86
+ <p id="preset-desc" class="preset-desc"></p>
87
+ </div>
88
+
89
+ <!-- Model dropdown -->
90
+ <div class="field">
91
+ <label for="model">Model <span class="hint">— auto-selected, override anytime</span></label>
92
+ <select id="model"></select>
93
+ </div>
94
+
95
+ <!-- Advanced toggle -->
96
+ <label class="toggle-row">
97
+ <input id="advanced" type="checkbox" />
98
+ <span>Advanced mode <span class="hint">(show all parameters)</span></span>
99
+ </label>
100
+
101
+ <!-- Global settings (always visible — small) -->
102
+ <details class="sub-accordion">
103
+ <summary>Global settings</summary>
104
+ <div class="details-body">
105
+ <div class="field">
106
+ <label for="output-format">Output format</label>
107
+ <select id="output-format">
108
+ <option value="wav" selected>WAV (lossless)</option>
109
+ <option value="flac">FLAC (lossless, compressed)</option>
110
+ <option value="mp3">MP3 (320 kbps)</option>
111
+ </select>
112
+ </div>
113
+ <div class="field">
114
+ <label for="norm-thresh">Normalization threshold: <span class="val" id="norm-thresh-val">0.9</span></label>
115
+ <input id="norm-thresh" type="range" min="0.1" max="1.0" step="0.1" value="0.9" />
116
+ </div>
117
+ <div class="field">
118
+ <label for="amp-thresh">Amplification threshold: <span class="val" id="amp-thresh-val">0.6</span></label>
119
+ <input id="amp-thresh" type="range" min="0.1" max="1.0" step="0.1" value="0.6" />
120
+ </div>
121
+ <div class="field">
122
+ <label for="batch-size">Batch size: <span class="val" id="batch-size-val">1</span></label>
123
+ <input id="batch-size" type="range" min="1" max="16" step="1" value="1" />
124
+ </div>
125
+ </div>
126
+ </details>
127
+
128
+ <!-- Advanced parameters (per-architecture, hidden by default) -->
129
+ <details id="adv-accordion" class="sub-accordion" hidden>
130
+ <summary>Advanced parameters <span class="hint">(architecture-specific)</span></summary>
131
+ <div class="details-body">
132
+
133
+ <div id="adv-rof" class="adv-group" hidden>
134
+ <div class="section-label small">Roformer / MDX23C</div>
135
+ <div class="field">
136
+ <label for="seg-size">Segment size: <span class="val" id="seg-size-val">256</span></label>
137
+ <input id="seg-size" type="range" min="32" max="4000" step="32" value="256" />
138
+ </div>
139
+ <div class="field">
140
+ <label for="overlap-rof">Overlap: <span class="val" id="overlap-rof-val">8</span></label>
141
+ <input id="overlap-rof" type="range" min="2" max="50" step="1" value="8" />
142
+ </div>
143
+ <div class="field">
144
+ <label for="pitch-shift">Pitch shift (semitones): <span class="val" id="pitch-shift-val">0</span></label>
145
+ <input id="pitch-shift" type="range" min="-12" max="12" step="1" value="0" />
146
+ </div>
147
+ </div>
148
+
149
+ <div id="adv-mdx" class="adv-group" hidden>
150
+ <div class="section-label small">MDX-NET</div>
151
+ <div class="field">
152
+ <label for="hop-length">Hop length: <span class="val" id="hop-length-val">1024</span></label>
153
+ <input id="hop-length" type="range" min="32" max="2048" step="32" value="1024" />
154
+ </div>
155
+ <div class="field">
156
+ <label for="overlap-mdx">Overlap (MDX/Demucs): <span class="val" id="overlap-mdx-val">0.25</span></label>
157
+ <input id="overlap-mdx" type="range" min="0.001" max="0.999" step="0.001" value="0.25" />
158
+ </div>
159
+ <label class="toggle-row">
160
+ <input id="enable-denoise" type="checkbox" />
161
+ <span>Enable denoise</span>
162
+ </label>
163
+ </div>
164
+
165
+ <div id="adv-vr" class="adv-group" hidden>
166
+ <div class="section-label small">VR Arch</div>
167
+ <div class="field">
168
+ <label for="window-size">Window size: <span class="val" id="window-size-val">512</span></label>
169
+ <input id="window-size" type="range" min="320" max="1024" step="32" value="512" />
170
+ </div>
171
+ <div class="field">
172
+ <label for="aggression">Aggression: <span class="val" id="aggression-val">5</span></label>
173
+ <input id="aggression" type="range" min="1" max="50" step="1" value="5" />
174
+ </div>
175
+ <label class="toggle-row">
176
+ <input id="enable-tta" type="checkbox" />
177
+ <span>Enable TTA</span>
178
+ </label>
179
+ <label class="toggle-row">
180
+ <input id="enable-post" type="checkbox" />
181
+ <span>Enable post-process</span>
182
+ </label>
183
+ <div class="field">
184
+ <label for="post-thresh">Post-process threshold: <span class="val" id="post-thresh-val">0.2</span></label>
185
+ <input id="post-thresh" type="range" min="0.1" max="0.3" step="0.1" value="0.2" />
186
+ </div>
187
+ <label class="toggle-row">
188
+ <input id="enable-high-end" type="checkbox" />
189
+ <span>High-end processing</span>
190
+ </label>
191
+ </div>
192
+
193
+ <div id="adv-demucs" class="adv-group" hidden>
194
+ <div class="section-label small">Demucs</div>
195
+ <div class="field">
196
+ <label for="shifts">Shifts: <span class="val" id="shifts-val">2</span></label>
197
+ <input id="shifts" type="range" min="0" max="20" step="1" value="2" />
198
+ </div>
199
+ <label class="toggle-row">
200
+ <input id="segments-enabled" type="checkbox" checked />
201
+ <span>Enable segments</span>
202
+ </label>
203
+ </div>
204
+
205
+ </div>
206
+ </details>
207
+
208
+ <!-- Action buttons -->
209
+ <div class="actions">
210
+ <button id="separate-btn" class="btn btn-primary" disabled>
211
+ <span class="btn-label">Separate audio</span>
212
+ <span class="btn-spinner" hidden></span>
213
+ </button>
214
+ <button id="clear-btn" class="btn btn-secondary" type="button">Clear</button>
215
+ </div>
216
+ </section>
217
+
218
+ <!-- ============ RIGHT: output ============ -->
219
+ <section class="panel panel-output">
220
+ <div class="section-label">Output stems</div>
221
+
222
+ <!-- Metric tiles -->
223
+ <div class="metrics">
224
+ <div class="metric-tile">
225
+ <div class="metric-label">Input duration</div>
226
+ <div class="metric-value" id="metric-dur">—</div>
227
+ </div>
228
+ <div class="metric-tile">
229
+ <div class="metric-label">Estimated time</div>
230
+ <div class="metric-value" id="metric-est">—</div>
231
+ </div>
232
+ <div class="metric-tile">
233
+ <div class="metric-label">Stems</div>
234
+ <div class="metric-value" id="metric-stems">—</div>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Status -->
239
+ <div id="status" class="status ready">
240
+ Ready. Upload audio and click <strong>Separate</strong>.
241
+ </div>
242
+
243
+ <!-- Stem cards (rendered dynamically) -->
244
+ <div id="stems" class="stems"></div>
245
+
246
+ <!-- ZIP download -->
247
+ <a id="zip-link" class="zip-card" hidden download>
248
+ <div class="zip-icon">📦</div>
249
+ <div class="zip-text">
250
+ <strong>Download all stems</strong>
251
+ <span>as a .zip archive</span>
252
+ </div>
253
+ <div class="zip-arrow">↓</div>
254
+ </a>
255
+
256
+ <!-- Empty-state illustration when no stems yet -->
257
+ <div id="empty-state" class="empty-state">
258
+ <svg viewBox="0 0 80 80" width="64" height="64" aria-hidden="true">
259
+ <circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="2 6" opacity="0.5"/>
260
+ <path d="M20 40 L20 40 M28 40 L28 40 M36 40 L36 40 M44 40 L44 40 M52 40 L52 40 M60 40 L60 40" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
261
+ </svg>
262
+ <p>Your stems will appear here.</p>
263
+ </div>
264
+ </section>
265
+ </main>
266
+
267
+ <!-- =========================================================== -->
268
+ <!-- History -->
269
+ <!-- =========================================================== -->
270
+ <section class="panel panel-history">
271
+ <details>
272
+ <summary>Recent separations</summary>
273
+ <div class="details-body">
274
+ <table class="history-table">
275
+ <thead>
276
+ <tr>
277
+ <th>Time</th><th>Use case</th><th>Model</th><th>Input</th><th>Stems</th>
278
+ </tr>
279
+ </thead>
280
+ <tbody id="history-body">
281
+ <tr class="empty-row"><td colspan="5">No history yet.</td></tr>
282
+ </tbody>
283
+ </table>
284
+ </div>
285
+ </details>
286
+ </section>
287
+
288
+ <!-- =========================================================== -->
289
+ <!-- FAQ -->
290
+ <!-- =========================================================== -->
291
+ <section class="panel panel-faq">
292
+ <details>
293
+ <summary>How to use / FAQ</summary>
294
+ <div class="details-body prose">
295
+ <h4>Quick start</h4>
296
+ <ol>
297
+ <li><strong>Upload</strong> an audio file (or drag it onto the dropzone).</li>
298
+ <li><strong>Pick a use case</strong> — the right model auto-selects.</li>
299
+ <li>Click <strong>Separate audio</strong>. First run downloads the model (can be hundreds of MB).</li>
300
+ <li>Listen to each stem and download the ZIP if you want them all.</li>
301
+ </ol>
302
+
303
+ <h4>When to override the model</h4>
304
+ <ul>
305
+ <li><strong>Karaoke</strong> → <code>MelBand Roformer | Karaoke V2 by Gabox</code></li>
306
+ <li><strong>Clean a cappella</strong> → <code>BS-Roformer-Viperx-1297</code> (highest reported SDR)</li>
307
+ <li><strong>De-reverb</strong> → <code>MelBand Roformer | De-Reverb by anvuew</code></li>
308
+ <li><strong>De-noise</strong> → <code>Mel-Roformer-Denoise-Aufr33</code></li>
309
+ <li><strong>Multi-stem remix</strong> → <code>htdemucs_ft.yaml</code> (4 stems) or <code>htdemucs_6s.yaml</code> (6 stems)</li>
310
+ <li><strong>CPU-only / speed</strong> → <code>UVR-MDX-NET-Inst_HQ_3.onnx</code></li>
311
+ </ul>
312
+
313
+ <h4>Tips</h4>
314
+ <ul>
315
+ <li>For best results use lossless inputs (WAV/FLAC).</li>
316
+ <li>Models auto-cache — first run is slow, later runs are fast.</li>
317
+ <li>MP3 output is encoded at <strong>320 kbps</strong> (not the library's 128 kbps default).</li>
318
+ <li>Transient <code>librosa.ParameterError</code> failures are retried automatically.</li>
319
+ </ul>
320
+
321
+ <h4>Credits</h4>
322
+ <ul>
323
+ <li>Engine: <a href="https://github.com/nomadkaraoke/python-audio-separator" target="_blank" rel="noopener"><code>audio-separator</code></a> by @berevadb</li>
324
+ <li>Models: UVR community contributors (Gabox, Aufr33, Viperx, Sucial, anvuew, Unwa, …)</li>
325
+ <li>Architecture: <a href="https://huggingface.co/blog/introducing-gradio-server" target="_blank" rel="noopener">Gradio Server</a> + vanilla HTML/CSS/JS frontend</li>
326
+ </ul>
327
+ </div>
328
+ </details>
329
+ </section>
330
+
331
+ <!-- =========================================================== -->
332
+ <!-- Footer -->
333
+ <!-- =========================================================== -->
334
+ <footer class="app-footer">
335
+ <span>DEVICE <b id="footer-device">—</b></span>
336
+ <span class="dot">·</span>
337
+ <span>ENGINE <b id="footer-engine">—</b></span>
338
+ <span class="dot">·</span>
339
+ <span>FRONTEND <b>VANILLA JS</b></span>
340
+ <span class="dot">·</span>
341
+ <span>BACKEND <b>GRADIO.SERVER</b></span>
342
+ </footer>
343
+
344
+ <!-- =========================================================== -->
345
+ <!-- Gradio JS client (loaded as ESM from CDN, as the blog -->
346
+ <!-- describes — no build step, no bundler on the client) -->
347
+ <!-- -->
348
+ <!-- We pin to @gradio/client@2.3.0 — the version that ships the -->
349
+ <!-- `Client.connect()` API the blog uses. Newer versions may -->
350
+ <!-- change the surface; older versions (<2.0) use a different -->
351
+ <!-- factory API (`client()` lowercase). -->
352
+ <!-- =========================================================== -->
353
+ <script type="module">
354
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.3.0/dist/index.min.js";
355
+ window.gradio = { Client, handle_file };
356
+ // Signal to app.js that the client is ready
357
+ window.dispatchEvent(new Event("gradio-client-ready"));
358
+ </script>
359
+
360
+ <!-- App logic (defer so it runs after DOM parse; waits for the client-ready event before init) -->
361
+ <script src="/static/app.js" defer></script>
362
+ </body>
363
+ </html>
static/style.css ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================ *
2
+ * Audio Separator — Gradio Server Edition
3
+ * Vanilla CSS — no framework, no build step.
4
+ *
5
+ * Design philosophy (mirrors the Gradio Server blog post):
6
+ * "Bring your own frontend" — we ship a self-contained design
7
+ * system. No Gradio theme tokens on the client.
8
+ * ============================================================ */
9
+
10
+ /* ---------------------------------------------------------------- *
11
+ * Design tokens *
12
+ * ---------------------------------------------------------------- */
13
+ :root {
14
+ /* Palette — pure black canvas, electric-lime accent */
15
+ --bg: #000000;
16
+ --bg-elev-1: #0a0a0c;
17
+ --bg-elev-2: #121217;
18
+ --bg-elev-3: #1b1b22;
19
+ --bg-elev-4: #24242d;
20
+
21
+ --border: #232330;
22
+ --border-soft: #16161c;
23
+
24
+ --text: #f5f5f7;
25
+ --text-dim: #9b9bab;
26
+ --text-mute: #5e5e6a;
27
+
28
+ --accent: #c6ff00;
29
+ --accent-dim: #86b300;
30
+ --accent-glow: rgba(198, 255, 0, 0.18);
31
+ --accent-soft: rgba(198, 255, 0, 0.06);
32
+
33
+ --danger: #ff4d6d;
34
+ --success: #00d68f;
35
+ --warning: #ffb800;
36
+
37
+ /* Typography */
38
+ --font-display: "Syne", "Arial", sans-serif;
39
+ --font-body: "Inter", system-ui, -apple-system, sans-serif;
40
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
41
+
42
+ /* Geometry */
43
+ --radius: 14px;
44
+ --radius-sm: 8px;
45
+ --radius-xs: 4px;
46
+
47
+ /* Effects */
48
+ --shadow: 0 1px 0 rgba(255,255,255,0.04) inset,
49
+ 0 8px 24px rgba(0,0,0,0.45);
50
+ --shadow-lg: 0 1px 0 rgba(255,255,255,0.06) inset,
51
+ 0 16px 48px rgba(0,0,0,0.55);
52
+ --blur: blur(14px) saturate(140%);
53
+
54
+ /* Layout */
55
+ --max-width: 1320px;
56
+ --gutter: 28px;
57
+ }
58
+
59
+ /* ---------------------------------------------------------------- *
60
+ * Reset & base *
61
+ * ---------------------------------------------------------------- */
62
+ *, *::before, *::after { box-sizing: border-box; }
63
+
64
+ html, body {
65
+ margin: 0;
66
+ padding: 0;
67
+ background: var(--bg);
68
+ color: var(--text);
69
+ font-family: var(--font-body);
70
+ font-size: 15px;
71
+ line-height: 1.55;
72
+ -webkit-font-smoothing: antialiased;
73
+ -moz-osx-font-smoothing: grayscale;
74
+ min-height: 100vh;
75
+ }
76
+
77
+ body {
78
+ position: relative;
79
+ overflow-x: hidden;
80
+ }
81
+
82
+ /* Fixed radial-gradient backdrop for depth on pure black */
83
+ .bg-glow {
84
+ position: fixed;
85
+ inset: 0;
86
+ z-index: 0;
87
+ pointer-events: none;
88
+ background:
89
+ radial-gradient(900px 600px at 12% -10%, rgba(198,255,0,0.08), transparent 60%),
90
+ radial-gradient(700px 500px at 92% 8%, rgba(120,80,255,0.10), transparent 65%),
91
+ radial-gradient(800px 800px at 50% 120%, rgba(0,140,255,0.06), transparent 70%);
92
+ }
93
+
94
+ /* All content sits above the backdrop */
95
+ .topbar, .hero, main, .panel-history, .panel-faq, .app-footer {
96
+ position: relative;
97
+ z-index: 1;
98
+ }
99
+
100
+ /* Constrain content width */
101
+ .topbar, .hero, main, .panel-history, .panel-faq, .app-footer {
102
+ max-width: var(--max-width);
103
+ margin-left: auto;
104
+ margin-right: auto;
105
+ padding-left: var(--gutter);
106
+ padding-right: var(--gutter);
107
+ }
108
+
109
+ a {
110
+ color: var(--accent);
111
+ text-decoration: none;
112
+ border-bottom: 1px dashed rgba(198,255,0,0.4);
113
+ transition: border-style 0.15s ease;
114
+ }
115
+ a:hover { border-bottom-style: solid; }
116
+
117
+ code {
118
+ font-family: var(--font-mono);
119
+ color: var(--accent);
120
+ background: var(--accent-soft);
121
+ border: 1px solid rgba(198,255,0,0.15);
122
+ padding: 1px 6px;
123
+ border-radius: var(--radius-xs);
124
+ font-size: 0.86em;
125
+ }
126
+
127
+ ::selection { background: var(--accent); color: #000; }
128
+
129
+ /* ---------------------------------------------------------------- *
130
+ * Scrollbar *
131
+ * ---------------------------------------------------------------- */
132
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
133
+ ::-webkit-scrollbar-track { background: var(--bg); }
134
+ ::-webkit-scrollbar-thumb {
135
+ background: var(--bg-elev-3);
136
+ border-radius: 5px;
137
+ border: 2px solid var(--bg);
138
+ }
139
+ ::-webkit-scrollbar-thumb:hover { background: #2a2a36; }
140
+
141
+ /* ---------------------------------------------------------------- *
142
+ * Topbar *
143
+ * ---------------------------------------------------------------- */
144
+ .topbar {
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ padding-top: 18px;
149
+ padding-bottom: 18px;
150
+ border-bottom: 1px solid var(--border-soft);
151
+ }
152
+ .topbar-brand {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 10px;
156
+ font-family: var(--font-display);
157
+ font-weight: 700;
158
+ font-size: 1.05rem;
159
+ letter-spacing: -0.01em;
160
+ }
161
+ .topbar-brand .accent { color: var(--accent); }
162
+ .logo-dot {
163
+ width: 10px; height: 10px;
164
+ background: var(--accent);
165
+ border-radius: 50%;
166
+ box-shadow: 0 0 12px var(--accent);
167
+ animation: pulse 2.2s ease-in-out infinite;
168
+ }
169
+ @keyframes pulse {
170
+ 0%, 100% { opacity: 1; transform: scale(1); }
171
+ 50% { opacity: 0.4; transform: scale(0.75); }
172
+ }
173
+ .topbar-meta {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 14px;
177
+ font-family: var(--font-mono);
178
+ font-size: 0.72rem;
179
+ }
180
+ .badge {
181
+ padding: 4px 10px;
182
+ background: var(--bg-elev-2);
183
+ border: 1px solid var(--border);
184
+ border-radius: 999px;
185
+ color: var(--text-dim);
186
+ letter-spacing: 0.05em;
187
+ }
188
+ .topbar-link {
189
+ border: none;
190
+ color: var(--text-dim);
191
+ font-family: var(--font-mono);
192
+ font-size: 0.72rem;
193
+ }
194
+ .topbar-link:hover { color: var(--accent); }
195
+
196
+ @media (max-width: 720px) {
197
+ .topbar { flex-direction: column; gap: 12px; align-items: flex-start; }
198
+ .topbar-meta { flex-wrap: wrap; gap: 8px; }
199
+ }
200
+
201
+ /* ---------------------------------------------------------------- *
202
+ * Hero *
203
+ * ---------------------------------------------------------------- */
204
+ .hero {
205
+ padding: 56px 0 40px;
206
+ }
207
+ .hero h1 {
208
+ font-family: var(--font-display);
209
+ font-weight: 800;
210
+ font-size: clamp(2.4rem, 5.5vw, 4rem);
211
+ line-height: 1.05;
212
+ letter-spacing: -0.02em;
213
+ margin: 0 0 18px;
214
+ background: linear-gradient(180deg, #ffffff 0%, #b0b0b8 100%);
215
+ -webkit-background-clip: text;
216
+ background-clip: text;
217
+ -webkit-text-fill-color: transparent;
218
+ }
219
+ .hero .accent-text {
220
+ background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dim) 100%);
221
+ -webkit-background-clip: text;
222
+ background-clip: text;
223
+ -webkit-text-fill-color: transparent;
224
+ }
225
+ .hero p {
226
+ font-size: 1.05rem;
227
+ color: var(--text-dim);
228
+ max-width: 720px;
229
+ margin: 0;
230
+ }
231
+
232
+ /* ---------------------------------------------------------------- *
233
+ * Main grid — two columns on desktop, stacked on mobile *
234
+ * ---------------------------------------------------------------- */
235
+ .grid {
236
+ display: grid;
237
+ grid-template-columns: 1fr 1fr;
238
+ gap: 24px;
239
+ padding-top: 8px;
240
+ padding-bottom: 32px;
241
+ }
242
+ @media (max-width: 980px) {
243
+ .grid { grid-template-columns: 1fr; }
244
+ }
245
+
246
+ /* ---------------------------------------------------------------- *
247
+ * Panel — generic surface *
248
+ * ---------------------------------------------------------------- */
249
+ .panel {
250
+ background: var(--bg-elev-1);
251
+ border: 1px solid var(--border);
252
+ border-radius: var(--radius);
253
+ padding: 24px;
254
+ box-shadow: var(--shadow);
255
+ }
256
+
257
+ .section-label {
258
+ font-family: var(--font-mono);
259
+ font-size: 0.7rem;
260
+ font-weight: 600;
261
+ letter-spacing: 0.18em;
262
+ text-transform: uppercase;
263
+ color: var(--accent);
264
+ padding-bottom: 14px;
265
+ margin-bottom: 18px;
266
+ border-bottom: 1px solid var(--border-soft);
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 8px;
270
+ }
271
+ .section-label::before {
272
+ content: "";
273
+ width: 6px; height: 6px;
274
+ background: var(--accent);
275
+ border-radius: 50%;
276
+ box-shadow: 0 0 8px var(--accent);
277
+ }
278
+ .section-label.small {
279
+ font-size: 0.62rem;
280
+ letter-spacing: 0.14em;
281
+ padding-bottom: 8px;
282
+ margin-bottom: 12px;
283
+ }
284
+
285
+ /* ---------------------------------------------------------------- *
286
+ * Dropzone *
287
+ * ---------------------------------------------------------------- */
288
+ .dropzone {
289
+ border: 2px dashed var(--border);
290
+ border-radius: var(--radius);
291
+ padding: 32px 24px;
292
+ text-align: center;
293
+ cursor: pointer;
294
+ transition: all 0.2s ease;
295
+ background: var(--bg-elev-2);
296
+ margin-bottom: 18px;
297
+ }
298
+ .dropzone:hover, .dropzone:focus-visible {
299
+ border-color: var(--accent);
300
+ background: var(--accent-soft);
301
+ outline: none;
302
+ }
303
+ .dropzone.dragging {
304
+ border-color: var(--accent);
305
+ background: var(--accent-soft);
306
+ transform: scale(1.01);
307
+ }
308
+ .dropzone-inner {
309
+ display: flex;
310
+ flex-direction: column;
311
+ align-items: center;
312
+ gap: 10px;
313
+ color: var(--text-dim);
314
+ }
315
+ .dropzone-icon { color: var(--accent); }
316
+ .dropzone-text { display: flex; flex-direction: column; gap: 2px; }
317
+ .dropzone-text strong {
318
+ font-family: var(--font-display);
319
+ color: var(--text);
320
+ font-weight: 700;
321
+ font-size: 1rem;
322
+ }
323
+ .dropzone-text span { font-size: 0.82rem; color: var(--text-mute); }
324
+
325
+ /* ---------------------------------------------------------------- *
326
+ * Preview *
327
+ * ---------------------------------------------------------------- */
328
+ .preview-wrap {
329
+ margin-bottom: 18px;
330
+ padding: 14px;
331
+ background: var(--bg-elev-2);
332
+ border: 1px solid var(--border);
333
+ border-left: 3px solid var(--accent);
334
+ border-radius: var(--radius-sm);
335
+ }
336
+ .preview-wrap audio {
337
+ width: 100%;
338
+ margin-bottom: 8px;
339
+ }
340
+ .preview-meta {
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 12px;
344
+ font-family: var(--font-mono);
345
+ font-size: 0.78rem;
346
+ color: var(--text-dim);
347
+ }
348
+ .preview-name {
349
+ flex: 1;
350
+ color: var(--text);
351
+ overflow: hidden;
352
+ text-overflow: ellipsis;
353
+ white-space: nowrap;
354
+ }
355
+ .link-btn {
356
+ background: none;
357
+ border: none;
358
+ color: var(--text-mute);
359
+ font-family: var(--font-mono);
360
+ font-size: 0.74rem;
361
+ cursor: pointer;
362
+ padding: 2px 6px;
363
+ border-bottom: 1px dashed var(--text-mute);
364
+ }
365
+ .link-btn:hover { color: var(--accent); border-bottom-color: var(--accent); }
366
+
367
+ /* ---------------------------------------------------------------- *
368
+ * Fields, inputs, sliders *
369
+ * ---------------------------------------------------------------- */
370
+ .field {
371
+ margin-bottom: 16px;
372
+ }
373
+ .field label {
374
+ display: block;
375
+ font-family: var(--font-mono);
376
+ font-size: 0.72rem;
377
+ letter-spacing: 0.08em;
378
+ text-transform: uppercase;
379
+ color: var(--text-dim);
380
+ font-weight: 500;
381
+ margin-bottom: 6px;
382
+ }
383
+ .field label .hint {
384
+ text-transform: none;
385
+ letter-spacing: 0;
386
+ color: var(--text-mute);
387
+ font-weight: 400;
388
+ }
389
+ .field label .val {
390
+ color: var(--accent);
391
+ font-weight: 600;
392
+ }
393
+
394
+ select, input[type="text"], input[type="number"], textarea {
395
+ width: 100%;
396
+ padding: 10px 12px;
397
+ background: var(--bg-elev-2);
398
+ border: 1px solid var(--border);
399
+ border-radius: var(--radius-sm);
400
+ color: var(--text);
401
+ font-family: var(--font-body);
402
+ font-size: 0.92rem;
403
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
404
+ appearance: none;
405
+ -webkit-appearance: none;
406
+ }
407
+ select {
408
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239b9bab' d='M6 9L1 3h10z'/%3E%3C/svg%3E");
409
+ background-repeat: no-repeat;
410
+ background-position: right 12px center;
411
+ padding-right: 32px;
412
+ }
413
+ select:focus, input:focus, textarea:focus {
414
+ outline: none;
415
+ border-color: var(--accent);
416
+ box-shadow: 0 0 0 3px var(--accent-glow);
417
+ }
418
+
419
+ /* Range slider — electric-lime */
420
+ input[type="range"] {
421
+ -webkit-appearance: none;
422
+ appearance: none;
423
+ width: 100%;
424
+ height: 4px;
425
+ background: var(--bg-elev-3);
426
+ border-radius: 2px;
427
+ outline: none;
428
+ }
429
+ input[type="range"]::-webkit-slider-thumb {
430
+ -webkit-appearance: none;
431
+ appearance: none;
432
+ width: 16px;
433
+ height: 16px;
434
+ background: var(--accent);
435
+ border-radius: 50%;
436
+ cursor: pointer;
437
+ border: 2px solid var(--bg);
438
+ box-shadow: 0 0 0 1px var(--accent-dim), 0 0 8px var(--accent-glow);
439
+ transition: transform 0.1s ease;
440
+ }
441
+ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); }
442
+ input[type="range"]::-moz-range-thumb {
443
+ width: 16px;
444
+ height: 16px;
445
+ background: var(--accent);
446
+ border-radius: 50%;
447
+ cursor: pointer;
448
+ border: 2px solid var(--bg);
449
+ box-shadow: 0 0 0 1px var(--accent-dim);
450
+ }
451
+
452
+ /* Toggle row (checkbox + label) */
453
+ .toggle-row {
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 10px;
457
+ padding: 8px 0;
458
+ cursor: pointer;
459
+ user-select: none;
460
+ }
461
+ .toggle-row input[type="checkbox"] {
462
+ appearance: none;
463
+ -webkit-appearance: none;
464
+ width: 18px;
465
+ height: 18px;
466
+ background: var(--bg-elev-3);
467
+ border: 1px solid var(--border);
468
+ border-radius: 4px;
469
+ cursor: pointer;
470
+ position: relative;
471
+ flex-shrink: 0;
472
+ transition: all 0.15s ease;
473
+ }
474
+ .toggle-row input[type="checkbox"]:checked {
475
+ background: var(--accent);
476
+ border-color: var(--accent);
477
+ }
478
+ .toggle-row input[type="checkbox"]:checked::after {
479
+ content: "";
480
+ position: absolute;
481
+ top: 2px; left: 5px;
482
+ width: 5px; height: 9px;
483
+ border: solid #000;
484
+ border-width: 0 2px 2px 0;
485
+ transform: rotate(45deg);
486
+ }
487
+ .toggle-row span { color: var(--text); font-size: 0.92rem; }
488
+ .toggle-row .hint { color: var(--text-mute); font-size: 0.82rem; }
489
+
490
+ /* ---------------------------------------------------------------- *
491
+ * Sub-accordion (details/summary) *
492
+ * ---------------------------------------------------------------- */
493
+ .sub-accordion, .panel-history details, .panel-faq details {
494
+ margin-top: 12px;
495
+ border: 1px solid var(--border);
496
+ border-radius: var(--radius-sm);
497
+ background: var(--bg-elev-2);
498
+ overflow: hidden;
499
+ }
500
+ .sub-accordion summary,
501
+ .panel-history details summary,
502
+ .panel-faq details summary {
503
+ padding: 12px 14px;
504
+ cursor: pointer;
505
+ font-family: var(--font-display);
506
+ font-weight: 600;
507
+ font-size: 0.92rem;
508
+ color: var(--text);
509
+ background: var(--bg-elev-3);
510
+ border-bottom: 1px solid transparent;
511
+ list-style: none;
512
+ transition: background 0.15s ease;
513
+ }
514
+ .sub-accordion summary::-webkit-details-marker,
515
+ .panel-history details summary::-webkit-details-marker,
516
+ .panel-faq details summary::-webkit-details-marker { display: none; }
517
+ .sub-accordion summary::before,
518
+ .panel-history details summary::before,
519
+ .panel-faq details summary::before {
520
+ content: "›";
521
+ display: inline-block;
522
+ margin-right: 8px;
523
+ color: var(--accent);
524
+ transform: rotate(0deg);
525
+ transition: transform 0.15s ease;
526
+ }
527
+ .sub-accordion[open] summary::before,
528
+ .panel-history details[open] summary::before,
529
+ .panel-faq details[open] summary::before {
530
+ transform: rotate(90deg);
531
+ }
532
+ .sub-accordion summary:hover,
533
+ .panel-history details summary:hover,
534
+ .panel-faq details summary:hover {
535
+ background: var(--bg-elev-4);
536
+ }
537
+ .details-body {
538
+ padding: 16px;
539
+ }
540
+ .adv-group {
541
+ margin-bottom: 16px;
542
+ }
543
+ .adv-group:last-child { margin-bottom: 0; }
544
+
545
+ /* ---------------------------------------------------------------- *
546
+ * Action buttons *
547
+ * ---------------------------------------------------------------- */
548
+ .actions {
549
+ display: flex;
550
+ gap: 10px;
551
+ margin-top: 24px;
552
+ }
553
+ .btn {
554
+ flex: 1;
555
+ padding: 14px 20px;
556
+ border-radius: var(--radius-sm);
557
+ font-family: var(--font-display);
558
+ font-weight: 700;
559
+ font-size: 0.95rem;
560
+ cursor: pointer;
561
+ border: 1px solid transparent;
562
+ transition: all 0.15s ease;
563
+ display: inline-flex;
564
+ align-items: center;
565
+ justify-content: center;
566
+ gap: 10px;
567
+ }
568
+ .btn-primary {
569
+ background: var(--accent);
570
+ color: #000;
571
+ border-color: var(--accent);
572
+ box-shadow:
573
+ 0 6px 18px rgba(198,255,0,0.20),
574
+ 0 1px 0 rgba(255,255,255,0.18) inset;
575
+ }
576
+ .btn-primary:hover:not(:disabled) {
577
+ filter: brightness(1.08);
578
+ transform: translateY(-1px);
579
+ box-shadow:
580
+ 0 10px 28px rgba(198,255,0,0.35),
581
+ 0 1px 0 rgba(255,255,255,0.22) inset;
582
+ }
583
+ .btn-primary:active:not(:disabled) { transform: translateY(0); filter: brightness(0.95); }
584
+ .btn-primary:disabled {
585
+ background: var(--bg-elev-3);
586
+ color: var(--text-mute);
587
+ border-color: var(--border);
588
+ box-shadow: none;
589
+ cursor: not-allowed;
590
+ }
591
+ .btn-secondary {
592
+ flex: 0 0 auto;
593
+ background: var(--bg-elev-2);
594
+ color: var(--text);
595
+ border-color: var(--border);
596
+ padding: 14px 18px;
597
+ }
598
+ .btn-secondary:hover {
599
+ border-color: var(--accent);
600
+ color: var(--accent);
601
+ background: var(--accent-soft);
602
+ }
603
+
604
+ /* Spinner */
605
+ .btn-spinner {
606
+ width: 14px;
607
+ height: 14px;
608
+ border: 2px solid rgba(0,0,0,0.25);
609
+ border-top-color: #000;
610
+ border-radius: 50%;
611
+ animation: spin 0.7s linear infinite;
612
+ }
613
+ @keyframes spin { to { transform: rotate(360deg); } }
614
+
615
+ /* ---------------------------------------------------------------- *
616
+ * Metrics *
617
+ * ---------------------------------------------------------------- */
618
+ .metrics {
619
+ display: grid;
620
+ grid-template-columns: repeat(3, 1fr);
621
+ gap: 10px;
622
+ margin-bottom: 16px;
623
+ }
624
+ @media (max-width: 540px) {
625
+ .metrics { grid-template-columns: 1fr; }
626
+ }
627
+ .metric-tile {
628
+ background: linear-gradient(180deg, var(--bg-elev-2), var(--bg-elev-1));
629
+ border: 1px solid var(--border);
630
+ border-left: 3px solid var(--accent);
631
+ border-radius: var(--radius-sm);
632
+ padding: 14px 16px;
633
+ box-shadow: var(--shadow);
634
+ }
635
+ .metric-label {
636
+ font-family: var(--font-mono);
637
+ font-size: 0.62rem;
638
+ letter-spacing: 0.16em;
639
+ text-transform: uppercase;
640
+ color: var(--text-mute);
641
+ font-weight: 500;
642
+ margin-bottom: 4px;
643
+ }
644
+ .metric-value {
645
+ font-family: var(--font-display);
646
+ font-size: 1.5rem;
647
+ font-weight: 700;
648
+ color: var(--text);
649
+ }
650
+
651
+ /* ---------------------------------------------------------------- *
652
+ * Status box *
653
+ * ---------------------------------------------------------------- */
654
+ .status {
655
+ padding: 14px 18px;
656
+ background: var(--bg-elev-1);
657
+ border: 1px solid var(--border);
658
+ border-left: 3px solid var(--accent);
659
+ border-radius: var(--radius-sm);
660
+ margin-bottom: 18px;
661
+ font-size: 0.92rem;
662
+ color: var(--text);
663
+ transition: all 0.2s ease;
664
+ }
665
+ .status.ready { border-left-color: var(--text-mute); }
666
+ .status.working { border-left-color: var(--warning); }
667
+ .status.success { border-left-color: var(--success); }
668
+ .status.error { border-left-color: var(--danger); }
669
+ .status code {
670
+ color: var(--accent);
671
+ background: var(--accent-soft);
672
+ padding: 1px 5px;
673
+ border-radius: 3px;
674
+ font-size: 0.88em;
675
+ }
676
+
677
+ /* ---------------------------------------------------------------- *
678
+ * Stem cards *
679
+ * ---------------------------------------------------------------- */
680
+ .stems {
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: 12px;
684
+ margin-bottom: 16px;
685
+ }
686
+ .stem-card {
687
+ background: var(--bg-elev-2);
688
+ border: 1px solid var(--border);
689
+ border-left: 3px solid var(--accent);
690
+ border-radius: var(--radius-sm);
691
+ padding: 14px;
692
+ animation: stem-in 0.3s ease;
693
+ }
694
+ @keyframes stem-in {
695
+ from { opacity: 0; transform: translateY(8px); }
696
+ to { opacity: 1; transform: translateY(0); }
697
+ }
698
+ .stem-header {
699
+ display: flex;
700
+ align-items: center;
701
+ justify-content: space-between;
702
+ margin-bottom: 8px;
703
+ }
704
+ .stem-label {
705
+ font-family: var(--font-display);
706
+ font-weight: 700;
707
+ font-size: 1rem;
708
+ color: var(--text);
709
+ }
710
+ .stem-download {
711
+ font-family: var(--font-mono);
712
+ font-size: 0.74rem;
713
+ color: var(--text-dim);
714
+ background: var(--bg-elev-3);
715
+ padding: 4px 8px;
716
+ border-radius: 4px;
717
+ border: 1px solid var(--border);
718
+ transition: all 0.15s ease;
719
+ }
720
+ .stem-download:hover {
721
+ color: var(--accent);
722
+ border-color: var(--accent);
723
+ }
724
+ .stem-card audio { width: 100%; }
725
+
726
+ /* ---------------------------------------------------------------- *
727
+ * ZIP download card *
728
+ * ---------------------------------------------------------------- */
729
+ .zip-card {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 14px;
733
+ padding: 16px 18px;
734
+ background: linear-gradient(135deg, var(--accent-soft), transparent 60%);
735
+ border: 1px solid var(--border);
736
+ border-left: 3px solid var(--accent);
737
+ border-radius: var(--radius-sm);
738
+ color: var(--text);
739
+ text-decoration: none;
740
+ transition: all 0.15s ease;
741
+ }
742
+ .zip-card:hover {
743
+ border-color: var(--accent);
744
+ transform: translateY(-1px);
745
+ box-shadow: 0 8px 24px rgba(198,255,0,0.15);
746
+ }
747
+ .zip-card .zip-icon { font-size: 1.6rem; }
748
+ .zip-card .zip-text {
749
+ flex: 1;
750
+ display: flex;
751
+ flex-direction: column;
752
+ }
753
+ .zip-card .zip-text strong {
754
+ font-family: var(--font-display);
755
+ font-weight: 700;
756
+ }
757
+ .zip-card .zip-text span {
758
+ font-size: 0.82rem;
759
+ color: var(--text-dim);
760
+ }
761
+ .zip-card .zip-arrow {
762
+ font-size: 1.4rem;
763
+ color: var(--accent);
764
+ font-weight: 700;
765
+ }
766
+
767
+ /* ---------------------------------------------------------------- *
768
+ * Empty state *
769
+ * ---------------------------------------------------------------- */
770
+ .empty-state {
771
+ text-align: center;
772
+ padding: 40px 20px;
773
+ color: var(--text-mute);
774
+ display: flex;
775
+ flex-direction: column;
776
+ align-items: center;
777
+ gap: 12px;
778
+ }
779
+ .empty-state svg { color: var(--text-mute); opacity: 0.6; }
780
+ .empty-state p {
781
+ margin: 0;
782
+ font-family: var(--font-mono);
783
+ font-size: 0.82rem;
784
+ letter-spacing: 0.08em;
785
+ }
786
+ .empty-state.hidden { display: none; }
787
+
788
+ /* ---------------------------------------------------------------- *
789
+ * History table *
790
+ * ---------------------------------------------------------------- */
791
+ .history-table {
792
+ width: 100%;
793
+ border-collapse: collapse;
794
+ font-size: 0.86rem;
795
+ }
796
+ .history-table th {
797
+ text-align: left;
798
+ background: var(--bg-elev-3);
799
+ color: var(--accent);
800
+ font-family: var(--font-mono);
801
+ font-size: 0.68rem;
802
+ letter-spacing: 0.10em;
803
+ text-transform: uppercase;
804
+ font-weight: 600;
805
+ padding: 10px 12px;
806
+ border-bottom: 1px solid var(--border);
807
+ }
808
+ .history-table td {
809
+ padding: 10px 12px;
810
+ border-bottom: 1px solid var(--border-soft);
811
+ color: var(--text-dim);
812
+ }
813
+ .history-table tr:hover td {
814
+ background: var(--accent-soft);
815
+ color: var(--text);
816
+ }
817
+ .history-table .empty-row td {
818
+ text-align: center;
819
+ color: var(--text-mute);
820
+ font-family: var(--font-mono);
821
+ font-size: 0.78rem;
822
+ }
823
+
824
+ /* ---------------------------------------------------------------- *
825
+ * FAQ prose *
826
+ * ---------------------------------------------------------------- */
827
+ .prose h4 {
828
+ font-family: var(--font-display);
829
+ font-weight: 700;
830
+ color: var(--text);
831
+ margin: 18px 0 8px;
832
+ font-size: 1.05rem;
833
+ }
834
+ .prose h4:first-child { margin-top: 0; }
835
+ .prose ul, .prose ol { padding-left: 22px; }
836
+ .prose li { margin: 4px 0; color: var(--text-dim); }
837
+
838
+ /* ---------------------------------------------------------------- *
839
+ * Footer *
840
+ * ---------------------------------------------------------------- */
841
+ .app-footer {
842
+ margin-top: 40px;
843
+ padding: 20px 0 60px;
844
+ border-top: 1px solid var(--border);
845
+ display: flex;
846
+ flex-wrap: wrap;
847
+ gap: 14px;
848
+ align-items: center;
849
+ font-family: var(--font-mono);
850
+ font-size: 0.72rem;
851
+ letter-spacing: 0.08em;
852
+ text-transform: uppercase;
853
+ color: var(--text-mute);
854
+ }
855
+ .app-footer b {
856
+ color: var(--accent);
857
+ font-weight: 700;
858
+ margin-left: 6px;
859
+ }
860
+ .app-footer .dot { opacity: 0.4; }
861
+
862
+ /* ---------------------------------------------------------------- *
863
+ * Reduce motion *
864
+ * ---------------------------------------------------------------- */
865
+ @media (prefers-reduced-motion: reduce) {
866
+ *, *::before, *::after {
867
+ animation-duration: 0.01ms !important;
868
+ animation-iteration-count: 1 !important;
869
+ transition-duration: 0.01ms !important;
870
+ }
871
+ .logo-dot { animation: none; opacity: 1; }
872
+ }