splitscore 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: splitscore
3
+ Version: 0.1.1
4
+ Summary: Separate audio into stems with BS-RoFormer-SW, then transcribe to MIDI with MuScriptor
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/SeanDolan0/splitscore
7
+ Project-URL: Repository, https://github.com/SeanDolan0/splitscore
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Requires-Python: <3.14,>=3.13
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: torch>=2.7
18
+ Requires-Dist: fastapi>=0.115
19
+ Requires-Dist: uvicorn[standard]>=0.30
20
+ Requires-Dist: onnxruntime-gpu<1.27,>=1.21
21
+ Requires-Dist: soundfile>=0.12
22
+ Requires-Dist: huggingface-hub>=0.24
23
+ Requires-Dist: muscriptor==0.3.0
24
+ Dynamic: license-file
25
+
26
+ # SplitScore
27
+
28
+ Separate an audio file into 6 stems with **BS-RoFormer-SW**, then transcribe the stems
29
+ you choose to **MIDI** with **MuScriptor**. A local web console (FastAPI + vanilla JS).
30
+
31
+ ```
32
+ audio ──▶ BS-RoFormer-SW ──▶ 6 stems ──▶ MuScriptor ──▶ per-stem .mid
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ uvx splitscore
39
+ ```
40
+
41
+ This auto-detects your NVIDIA GPU and installs the correct CUDA torch backend.
42
+ First run downloads models (~3 GB total) into `~/.cache/`.
43
+
44
+ ## Requirements
45
+
46
+ - Windows 11 / Linux, Python 3.13, [uv](https://docs.astral.sh/uv/)
47
+ - NVIDIA GPU recommended — requires [CUDA 12.x Toolkit](https://developer.nvidia.com/cuda-downloads) installed for GPU acceleration (auto-detected; falls back to CPU if missing)
48
+ - Apple Silicon (MPS) and AMD (ROCm) GPUs are also supported
49
+
50
+ ## Setup (development)
51
+
52
+ ```bash
53
+ uv run python sync.py # auto-detect GPU, install correct CUDA torch
54
+ uv run python -m app # start server
55
+ ```
56
+
57
+ MuScriptor weights are **gated**: you need a free Hugging Face account.
58
+
59
+ 1. Open https://huggingface.co/muscriptor/muscriptor and accept the **CC BY-NC 4.0**
60
+ (non-commercial) license.
61
+ 2. Log in from the terminal:
62
+ ```bash
63
+ uv run hf auth login
64
+ ```
65
+ (or export `HF_TOKEN=hf_...` in your shell).
66
+
67
+ ## Usage
68
+
69
+ 1. Drop an audio file (wav / mp3 / flac / ogg / m4a / aiff).
70
+ 2. Separation runs automatically — wait for the 6 stems to appear.
71
+ 3. Tick the stems you want, adjust per-stem instrument (empty = auto), hit **Transcribe selected to MIDI**.
72
+ 4. Download the `.mid` files. Each stem is named `<song>_<stem>.mid`.
73
+
74
+ Every setting (model size, device, precision, temperature, beam/batch size, output folder)
75
+ is adjustable in the Settings panel and persists across runs.
76
+
77
+ ## Notes & limitations
78
+
79
+ - MuScriptor does not preserve velocity — output is timing, pitch, and instrument.
80
+ - Drums and "other" stems tend to transcribe poorly to readable MIDI; melodic stems
81
+ (vocals, piano, guitar, bass) give the best results.
82
+ - MIDI + stem WAVs are written under the configured output folder
83
+ (`./output/<job_id>/stems|midi/`).
84
+ - One job at a time by design.
85
+
86
+ ## License
87
+
88
+ - **Code:** MIT — see [LICENSE](LICENSE).
89
+ - **Models:** the separation model and the MuScriptor weights are separate and
90
+ distributed under their own licenses. In particular, MuScriptor's weights are
91
+ **CC BY-NC 4.0** (non-commercial) and gated on Hugging Face; that restriction
92
+ applies to the models regardless of this project's MIT license.
@@ -0,0 +1,67 @@
1
+ # SplitScore
2
+
3
+ Separate an audio file into 6 stems with **BS-RoFormer-SW**, then transcribe the stems
4
+ you choose to **MIDI** with **MuScriptor**. A local web console (FastAPI + vanilla JS).
5
+
6
+ ```
7
+ audio ──▶ BS-RoFormer-SW ──▶ 6 stems ──▶ MuScriptor ──▶ per-stem .mid
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ uvx splitscore
14
+ ```
15
+
16
+ This auto-detects your NVIDIA GPU and installs the correct CUDA torch backend.
17
+ First run downloads models (~3 GB total) into `~/.cache/`.
18
+
19
+ ## Requirements
20
+
21
+ - Windows 11 / Linux, Python 3.13, [uv](https://docs.astral.sh/uv/)
22
+ - NVIDIA GPU recommended — requires [CUDA 12.x Toolkit](https://developer.nvidia.com/cuda-downloads) installed for GPU acceleration (auto-detected; falls back to CPU if missing)
23
+ - Apple Silicon (MPS) and AMD (ROCm) GPUs are also supported
24
+
25
+ ## Setup (development)
26
+
27
+ ```bash
28
+ uv run python sync.py # auto-detect GPU, install correct CUDA torch
29
+ uv run python -m app # start server
30
+ ```
31
+
32
+ MuScriptor weights are **gated**: you need a free Hugging Face account.
33
+
34
+ 1. Open https://huggingface.co/muscriptor/muscriptor and accept the **CC BY-NC 4.0**
35
+ (non-commercial) license.
36
+ 2. Log in from the terminal:
37
+ ```bash
38
+ uv run hf auth login
39
+ ```
40
+ (or export `HF_TOKEN=hf_...` in your shell).
41
+
42
+ ## Usage
43
+
44
+ 1. Drop an audio file (wav / mp3 / flac / ogg / m4a / aiff).
45
+ 2. Separation runs automatically — wait for the 6 stems to appear.
46
+ 3. Tick the stems you want, adjust per-stem instrument (empty = auto), hit **Transcribe selected to MIDI**.
47
+ 4. Download the `.mid` files. Each stem is named `<song>_<stem>.mid`.
48
+
49
+ Every setting (model size, device, precision, temperature, beam/batch size, output folder)
50
+ is adjustable in the Settings panel and persists across runs.
51
+
52
+ ## Notes & limitations
53
+
54
+ - MuScriptor does not preserve velocity — output is timing, pitch, and instrument.
55
+ - Drums and "other" stems tend to transcribe poorly to readable MIDI; melodic stems
56
+ (vocals, piano, guitar, bass) give the best results.
57
+ - MIDI + stem WAVs are written under the configured output folder
58
+ (`./output/<job_id>/stems|midi/`).
59
+ - One job at a time by design.
60
+
61
+ ## License
62
+
63
+ - **Code:** MIT — see [LICENSE](LICENSE).
64
+ - **Models:** the separation model and the MuScriptor weights are separate and
65
+ distributed under their own licenses. In particular, MuScriptor's weights are
66
+ **CC BY-NC 4.0** (non-commercial) and gated on Hugging Face; that restriction
67
+ applies to the models regardless of this project's MIT license.
@@ -0,0 +1,3 @@
1
+ """SplitScore — stem separation + MIDI transcription."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,4 @@
1
+ from app.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,121 @@
1
+ """CLI entry point for `uvx splitscore`.
2
+
3
+ Detects GPU vendor, installs the correct torch + onnxruntime backends,
4
+ then starts the server.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import webbrowser
13
+
14
+
15
+ def _detect_vendor() -> str:
16
+ """Return gpu vendor: nvidia | amd | intel | apple | none."""
17
+ system = platform.system()
18
+
19
+ # NVIDIA
20
+ if shutil.which("nvidia-smi"):
21
+ return "nvidia"
22
+
23
+ # Apple Silicon
24
+ if system == "Darwin" and platform.machine() == "arm64":
25
+ return "apple"
26
+
27
+ # AMD (Linux only — ROCm)
28
+ if system == "Linux":
29
+ try:
30
+ lspci = subprocess.check_output(["lspci"], text=True, timeout=5)
31
+ if "amd" in lspci.lower():
32
+ return "amd"
33
+ except (subprocess.SubprocessError, FileNotFoundError):
34
+ pass
35
+
36
+ # Intel (Windows — DirectML)
37
+ if system == "Windows":
38
+ try:
39
+ lspci = subprocess.check_output(["lspci"], text=True, timeout=5)
40
+ if "intel" in lspci.lower():
41
+ return "intel"
42
+ except (subprocess.SubprocessError, FileNotFoundError):
43
+ pass
44
+
45
+ return "none"
46
+
47
+
48
+ def _install_onnxruntime(vendor: str) -> None:
49
+ """Install the correct onnxruntime variant for the GPU vendor."""
50
+ pkgs = {
51
+ "nvidia": "onnxruntime-gpu>=1.21,<1.27",
52
+ "amd": "onnxruntime-rocm",
53
+ "intel": "onnxruntime-directml",
54
+ "apple": "onnxruntime",
55
+ "none": "onnxruntime",
56
+ }
57
+ pkg = pkgs[vendor]
58
+ print(f"Installing {pkg} ...")
59
+ subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
60
+
61
+
62
+ def _install_torch(vendor: str) -> None:
63
+ """Install the correct torch variant for the GPU vendor."""
64
+ try:
65
+ import torch
66
+ # Check if the correct backend is actually available
67
+ if vendor == "nvidia" and torch.cuda.is_available():
68
+ return
69
+ if vendor == "amd" and torch.cuda.is_available(): # ROCm uses cuda device
70
+ return
71
+ if vendor in ("apple", "none"):
72
+ return # default torch is fine
73
+ except ImportError:
74
+ pass
75
+
76
+ index_urls = {
77
+ "nvidia": "https://download.pytorch.org/whl/cu130",
78
+ "amd": "https://download.pytorch.org/whl/rocm6.2",
79
+ }
80
+ if vendor in index_urls:
81
+ print(f"Installing torch ({vendor}) ...")
82
+ subprocess.check_call([
83
+ sys.executable, "-m", "pip", "install",
84
+ "--index-url", index_urls[vendor],
85
+ "--trusted-host", "download.pytorch.org",
86
+ "torch>=2.7",
87
+ ])
88
+ else:
89
+ print("Installing torch (default) ...")
90
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "torch>=2.7"])
91
+
92
+
93
+ def _ensure_backends() -> None:
94
+ """Make sure torch and onnxruntime are importable with correct backends."""
95
+ # Check torch
96
+ try:
97
+ import torch # noqa: F401
98
+ except ImportError:
99
+ vendor = _detect_vendor()
100
+ print(f"GPU detected: {vendor}")
101
+ _install_torch(vendor)
102
+ else:
103
+ vendor = _detect_vendor()
104
+
105
+ # Check onnxruntime GPU provider
106
+ try:
107
+ import onnxruntime as ort
108
+ providers = ort.get_available_providers()
109
+ has_gpu = any(p not in ("CPUExecutionProvider",) for p in providers)
110
+ if not has_gpu and vendor not in ("apple", "none"):
111
+ _install_onnxruntime(vendor)
112
+ except ImportError:
113
+ _install_onnxruntime(vendor)
114
+
115
+
116
+ def main() -> None:
117
+ _ensure_backends()
118
+ import uvicorn
119
+ url = "http://127.0.0.1:8000"
120
+ webbrowser.open(url)
121
+ uvicorn.run("app.main:app", host="127.0.0.1", port=8000)
@@ -0,0 +1,119 @@
1
+ """GPU detection and backend resolution for onnxruntime and torch."""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class GpuInfo:
12
+ vendor: str # nvidia | amd | intel | apple | none
13
+ name: str # human-readable name, e.g. "NVIDIA GeForce RTX 4060"
14
+ preferred_device: str # cuda | mps | xpu | cpu
15
+
16
+
17
+ def _run(cmd: list[str], timeout: float = 5) -> str | None:
18
+ try:
19
+ return subprocess.check_output(cmd, text=True, timeout=timeout).strip()
20
+ except (subprocess.SubprocessError, FileNotFoundError):
21
+ return None
22
+
23
+
24
+ def _detect_nvidia() -> GpuInfo | None:
25
+ if not shutil.which("nvidia-smi"):
26
+ return None
27
+ out = _run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"])
28
+ if not out:
29
+ return None
30
+ name = out.splitlines()[0].strip()
31
+ return GpuInfo(vendor="nvidia", name=name, preferred_device="cuda")
32
+
33
+
34
+ def _detect_amd() -> GpuInfo | None:
35
+ if platform.system() != "Linux":
36
+ return None
37
+ lspci = _run(["lspci"])
38
+ if not lspci or "amd" not in lspci.lower():
39
+ return None
40
+ # Check for ROCm runtime
41
+ if not shutil.which("rocminfo"):
42
+ return GpuInfo(vendor="amd", name="AMD GPU (no ROCm)", preferred_device="cpu")
43
+ return GpuInfo(vendor="amd", name="AMD GPU (ROCm)", preferred_device="cuda")
44
+
45
+
46
+ def _detect_intel() -> GpuInfo | None:
47
+ if platform.system() != "Windows":
48
+ return None # DirectML is Windows-only
49
+ lspci = _run(["lspci"]) # may not exist on Windows
50
+ if not lspci:
51
+ return None
52
+ if "intel" not in lspci.lower():
53
+ return None
54
+ return GpuInfo(vendor="intel", name="Intel GPU (DirectML)", preferred_device="cpu")
55
+
56
+
57
+ def _detect_apple_silicon() -> GpuInfo | None:
58
+ if platform.system() != "Darwin":
59
+ return None
60
+ if platform.machine() != "arm64":
61
+ return None
62
+ return GpuInfo(vendor="apple", name="Apple Silicon (MPS)", preferred_device="mps")
63
+
64
+
65
+ def detect_gpu() -> GpuInfo:
66
+ """Detect the best available GPU and return device info.
67
+
68
+ Detection order: NVIDIA → AMD → Intel → Apple Silicon → CPU fallback.
69
+ """
70
+ for detector in (_detect_nvidia, _detect_amd, _detect_intel, _detect_apple_silicon):
71
+ result = detector()
72
+ if result:
73
+ return result
74
+ return GpuInfo(vendor="none", name="No GPU detected", preferred_device="cpu")
75
+
76
+
77
+ # --- onnxruntime provider resolution ---
78
+
79
+ # Maps device string → ordered list of onnxruntime execution providers to try.
80
+ _PROVIDER_MAP: dict[str, list[str]] = {
81
+ "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
82
+ "rocm": ["ROCMExecutionProvider", "CPUExecutionProvider"],
83
+ "directml": ["DmlExecutionProvider", "CPUExecutionProvider"],
84
+ "mps": ["CPUExecutionProvider"], # ONNX has no Apple GPU provider
85
+ "xpu": ["CPUExecutionProvider"], # ONNX has no Intel XPU provider
86
+ "cpu": ["CPUExecutionProvider"],
87
+ }
88
+
89
+
90
+ def resolve_onnx_provider(device: str) -> list[str]:
91
+ """Return ordered list of onnxruntime providers for the given device."""
92
+ return _PROVIDER_MAP.get(device, ["CPUExecutionProvider"])
93
+
94
+
95
+ # --- torch device resolution ---
96
+
97
+ def resolve_torch_device(device: str) -> str:
98
+ """Resolve a device string ('auto', 'cuda', 'mps', 'xpu', 'cpu').
99
+
100
+ Returns a torch-compatible device string. Falls back to 'cpu' if the
101
+ requested backend is not available.
102
+ """
103
+ import torch
104
+
105
+ if device == "auto":
106
+ gpu = detect_gpu()
107
+ device = gpu.preferred_device
108
+
109
+ if device == "cuda" and torch.cuda.is_available():
110
+ return "cuda"
111
+ if device == "mps" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
112
+ return "mps"
113
+ if device == "xpu" and hasattr(torch, "xpu") and torch.xpu.is_available():
114
+ return "xpu"
115
+ if device in ("cuda", "mps", "xpu"):
116
+ # Requested but unavailable — fall back to CPU with a warning
117
+ import warnings
118
+ warnings.warn(f"{device} requested but not available, falling back to CPU")
119
+ return "cpu"
@@ -0,0 +1,232 @@
1
+ """FastAPI backend: routes, SSE event stream, static frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import io
7
+ import json
8
+ import webbrowser
9
+ import zipfile
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+
13
+ from fastapi import FastAPI, File, HTTPException, UploadFile
14
+ from fastapi.responses import FileResponse, Response, StreamingResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel
17
+
18
+ from app.pipeline import STATUS_CREATED, STATUS_DONE, STATUS_FAILED, STATUS_READY, Pipeline
19
+ from app.separator import STEMS
20
+ from app.settings import Settings, load_settings, save_settings
21
+ from app.transcribe import list_instruments
22
+ from app.gpu import detect_gpu
23
+
24
+ ALLOWED_EXTENSIONS = {"wav", "mp3", "flac", "ogg", "m4a", "aiff"}
25
+
26
+ app = FastAPI(title="SplitScore")
27
+ PIPELINE = Pipeline(load_settings())
28
+
29
+ _HEARTBEAT = ":" + " " * 15 + "\n\n" # SSE comment keeps the connection alive
30
+
31
+ TERMINAL_EVENTS = {"done", "failed", "cancelled"}
32
+
33
+
34
+ class TranscribeBody(BaseModel):
35
+ stems: list[str]
36
+
37
+
38
+ @app.post("/api/jobs")
39
+ async def create_job(file: UploadFile = File(...)):
40
+ name = Path(file.filename or "").name # basename only, strips any ../ or drive segments
41
+ if not name:
42
+ raise HTTPException(400, "Invalid filename")
43
+ if "/" in name or "\\" in name:
44
+ raise HTTPException(400, "Invalid filename")
45
+ ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
46
+ if ext not in ALLOWED_EXTENSIONS:
47
+ raise HTTPException(400, f"Unsupported extension '.{ext}'; allowed: {sorted(ALLOWED_EXTENSIONS)}")
48
+ data = await file.read()
49
+ if not data:
50
+ raise HTTPException(400, "Empty file upload")
51
+ job = PIPELINE.create_job(Path(name).stem, "unused")
52
+ in_dir = job.output_dir / "input"
53
+ in_dir.mkdir(parents=True, exist_ok=True)
54
+ job.input_path = in_dir / name
55
+ job.input_path.write_bytes(data)
56
+ asyncio.create_task(PIPELINE.separate(job))
57
+ return {"job_id": job.id}
58
+
59
+
60
+ @app.get("/api/jobs/{job_id}")
61
+ async def get_job(job_id: str):
62
+ job = PIPELINE.jobs.get(job_id)
63
+ if not job:
64
+ raise HTTPException(404, "Unknown job")
65
+ midi = [p.name for p in (job.output_dir / "midi").glob("*.mid")]
66
+ return {"job_id": job.id, "status": job.status, "error": job.error,
67
+ "song_name": job.song_name, "midi": midi}
68
+
69
+
70
+ @app.post("/api/jobs/{job_id}/transcribe")
71
+ async def transcribe(job_id: str, body: TranscribeBody):
72
+ job = PIPELINE.jobs.get(job_id)
73
+ if not job:
74
+ raise HTTPException(404, "Unknown job")
75
+ if job.status not in (STATUS_READY, STATUS_DONE, STATUS_FAILED):
76
+ raise HTTPException(409, f"Job not ready (status={job.status})")
77
+ bad = [s for s in body.stems if s not in STEMS]
78
+ if bad:
79
+ raise HTTPException(400, f"Unknown stems: {bad}")
80
+ s = PIPELINE.settings
81
+ asyncio.create_task(PIPELINE.transcribe(
82
+ job, body.stems, s.instrument_by_stem, s.temperature, s.beam_size, s.batch_size))
83
+ return {"job_id": job.id}
84
+
85
+
86
+ @app.post("/api/jobs/{job_id}/cancel")
87
+ async def cancel(job_id: str):
88
+ job = PIPELINE.jobs.get(job_id)
89
+ if not job:
90
+ raise HTTPException(404, "Unknown job")
91
+ job.cancel.set()
92
+ # Idle job (post-separation, waiting to transcribe): no worker coroutine
93
+ # observes the flag, so finish the cancel here and discard the stems.
94
+ if job.status in (STATUS_CREATED, STATUS_READY):
95
+ PIPELINE._finish_cancelled(job)
96
+ return {"ok": True}
97
+
98
+
99
+ @app.get("/api/jobs/{job_id}/events")
100
+ async def events(job_id: str):
101
+ job = PIPELINE.jobs.get(job_id)
102
+ if not job:
103
+ raise HTTPException(404, "Unknown job")
104
+
105
+ async def gen():
106
+ while True:
107
+ try:
108
+ event = await asyncio.wait_for(job.events.get(), timeout=15.0)
109
+ yield f"data: {json.dumps(event)}\n\n"
110
+ if event["type"] in TERMINAL_EVENTS:
111
+ return
112
+ except asyncio.TimeoutError:
113
+ yield _HEARTBEAT
114
+ return StreamingResponse(gen(), media_type="text/event-stream")
115
+
116
+
117
+ @app.get("/api/instruments")
118
+ async def get_instruments():
119
+ # First call shells out to `muscriptor list-instruments` (~seconds); run it
120
+ # off the event loop. Result is cached in transcribe.list_instruments.
121
+ instruments = await asyncio.to_thread(list_instruments)
122
+ return {"instruments": instruments}
123
+
124
+
125
+ @app.get("/api/hardware")
126
+ async def get_hardware():
127
+ """Report what compute hardware is available and which provider the models use."""
128
+ import importlib
129
+ info = {"torch": None, "onnxruntime": None, "gpu": None,
130
+ "settings_device": PIPELINE.settings.separation_device,
131
+ "separator_actual": None}
132
+
133
+ # torch
134
+ try:
135
+ torch = importlib.import_module("torch")
136
+ info["torch"] = {
137
+ "version": torch.__version__,
138
+ "cuda_available": torch.cuda.is_available(),
139
+ "cuda_version": torch.version.cuda or None,
140
+ "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
141
+ "gpu_memory_gb": round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if torch.cuda.is_available() else None,
142
+ }
143
+ except Exception:
144
+ info["torch"] = {"version": "not installed", "cuda_available": False}
145
+
146
+ # onnxruntime
147
+ try:
148
+ ort = importlib.import_module("onnxruntime")
149
+ info["onnxruntime"] = {
150
+ "version": ort.__version__,
151
+ "providers": ort.get_available_providers(),
152
+ }
153
+ except Exception:
154
+ info["onnxruntime"] = {"version": "not installed", "providers": []}
155
+
156
+ # gpu detection (runtime)
157
+ gpu = detect_gpu()
158
+ info["gpu"] = {"vendor": gpu.vendor, "name": gpu.name, "preferred_device": gpu.preferred_device}
159
+
160
+ # actual device used by the separator (if any job has run)
161
+ for job in PIPELINE.jobs.values():
162
+ if hasattr(job, "_separator_device"):
163
+ info["separator_actual"] = job._separator_device
164
+ break
165
+
166
+ return info
167
+
168
+
169
+ @app.get("/api/settings")
170
+ async def get_settings():
171
+ return load_settings()
172
+
173
+
174
+ @app.put("/api/settings")
175
+ async def put_settings(body: dict):
176
+ merged = asdict(load_settings())
177
+ merged.update({k: v for k, v in body.items() if k in merged})
178
+ updated = Settings(**merged)
179
+ save_settings(updated)
180
+ PIPELINE.settings = updated
181
+ return merged
182
+
183
+
184
+ @app.get("/output/{job_id}/midi/{filename}")
185
+ async def download_midi(job_id: str, filename: str):
186
+ base = Path(PIPELINE.settings.output_folder).resolve()
187
+ path = (base / job_id / "midi" / filename).resolve()
188
+ if not path.is_relative_to(base) or not path.is_file():
189
+ raise HTTPException(404, "MIDI not found")
190
+ return FileResponse(path, media_type="audio/midi", filename=filename)
191
+
192
+
193
+ @app.get("/output/{job_id}/stems/{filename}")
194
+ async def download_stem(job_id: str, filename: str):
195
+ base = Path(PIPELINE.settings.output_folder).resolve()
196
+ path = (base / job_id / "stems" / filename).resolve()
197
+ if not path.is_relative_to(base) or not path.is_file():
198
+ raise HTTPException(404, "Stem not found")
199
+ return FileResponse(path, media_type="audio/wav")
200
+
201
+
202
+ @app.get("/output/{job_id}/stems")
203
+ async def download_stems_zip(job_id: str):
204
+ """Return all stems as a zip archive for bulk download."""
205
+ base = Path(PIPELINE.settings.output_folder).resolve()
206
+ stems_dir = (base / job_id / "stems").resolve()
207
+ if not stems_dir.is_relative_to(base) or not stems_dir.is_dir():
208
+ raise HTTPException(404, "Stems not found")
209
+ wav_files = sorted(stems_dir.glob("*.wav"))
210
+ if not wav_files:
211
+ raise HTTPException(404, "No stems found")
212
+ buf = io.BytesIO()
213
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
214
+ for wav in wav_files:
215
+ zf.write(wav, wav.name)
216
+ buf.seek(0)
217
+ zip_name = f"{stems_dir.parent.name}_stems.zip"
218
+ return Response(
219
+ content=buf.read(),
220
+ media_type="application/zip",
221
+ headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
222
+ )
223
+
224
+
225
+ app.mount("/", StaticFiles(directory=Path(__file__).parent / "static", html=True), name="static")
226
+
227
+
228
+ def main() -> None:
229
+ import uvicorn
230
+ url = "http://127.0.0.1:8000"
231
+ webbrowser.open(url)
232
+ uvicorn.run(app, host="127.0.0.1", port=8000)