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.
- splitscore-0.1.1/LICENSE +21 -0
- splitscore-0.1.1/PKG-INFO +92 -0
- splitscore-0.1.1/README.md +67 -0
- splitscore-0.1.1/app/__init__.py +3 -0
- splitscore-0.1.1/app/__main__.py +4 -0
- splitscore-0.1.1/app/cli.py +121 -0
- splitscore-0.1.1/app/gpu.py +119 -0
- splitscore-0.1.1/app/main.py +232 -0
- splitscore-0.1.1/app/pipeline.py +174 -0
- splitscore-0.1.1/app/separator.py +213 -0
- splitscore-0.1.1/app/settings.py +49 -0
- splitscore-0.1.1/app/static/app.js +612 -0
- splitscore-0.1.1/app/static/index.html +267 -0
- splitscore-0.1.1/app/static/style.css +1099 -0
- splitscore-0.1.1/app/transcribe.py +92 -0
- splitscore-0.1.1/pyproject.toml +73 -0
- splitscore-0.1.1/setup.cfg +4 -0
- splitscore-0.1.1/splitscore.egg-info/PKG-INFO +92 -0
- splitscore-0.1.1/splitscore.egg-info/SOURCES.txt +26 -0
- splitscore-0.1.1/splitscore.egg-info/dependency_links.txt +1 -0
- splitscore-0.1.1/splitscore.egg-info/entry_points.txt +2 -0
- splitscore-0.1.1/splitscore.egg-info/requires.txt +7 -0
- splitscore-0.1.1/splitscore.egg-info/top_level.txt +1 -0
- splitscore-0.1.1/tests/test_main.py +189 -0
- splitscore-0.1.1/tests/test_pipeline.py +167 -0
- splitscore-0.1.1/tests/test_separator.py +156 -0
- splitscore-0.1.1/tests/test_settings.py +38 -0
- splitscore-0.1.1/tests/test_transcribe.py +94 -0
splitscore-0.1.1/LICENSE
ADDED
|
@@ -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,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)
|