splitscore 0.1.0__py3-none-any.whl
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.
- app/__init__.py +3 -0
- app/__main__.py +4 -0
- app/cli.py +87 -0
- app/main.py +162 -0
- app/pipeline.py +170 -0
- app/separator.py +187 -0
- app/settings.py +49 -0
- app/static/app.js +443 -0
- app/static/index.html +187 -0
- app/static/style.css +851 -0
- app/transcribe.py +96 -0
- splitscore-0.1.0.dist-info/METADATA +92 -0
- splitscore-0.1.0.dist-info/RECORD +17 -0
- splitscore-0.1.0.dist-info/WHEEL +5 -0
- splitscore-0.1.0.dist-info/entry_points.txt +2 -0
- splitscore-0.1.0.dist-info/licenses/LICENSE +21 -0
- splitscore-0.1.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
app/__main__.py
ADDED
app/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI entry point for `uvx splitscore`.
|
|
2
|
+
|
|
3
|
+
Detects NVIDIA GPU, installs the correct torch CUDA backend if missing,
|
|
4
|
+
then starts the server.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
CUDA_TAG_MAP: dict[str, str] = {
|
|
14
|
+
"11.8": "cu118",
|
|
15
|
+
"12.1": "cu121",
|
|
16
|
+
"12.4": "cu124",
|
|
17
|
+
"12.6": "cu126",
|
|
18
|
+
"12.8": "cu128",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_cuda() -> str | None:
|
|
23
|
+
"""Return best-matching CUDA wheel tag (e.g. 'cu126') or None."""
|
|
24
|
+
nvidia_smi = shutil.which("nvidia-smi")
|
|
25
|
+
if not nvidia_smi:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
out = subprocess.check_output(
|
|
29
|
+
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"],
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=5,
|
|
32
|
+
)
|
|
33
|
+
driver = out.strip().splitlines()[0].strip()
|
|
34
|
+
major = int(driver.split(".")[0])
|
|
35
|
+
if major >= 570:
|
|
36
|
+
version = "12.8"
|
|
37
|
+
elif major >= 560:
|
|
38
|
+
version = "12.6"
|
|
39
|
+
elif major >= 550:
|
|
40
|
+
version = "12.4"
|
|
41
|
+
elif major >= 535:
|
|
42
|
+
version = "12.2"
|
|
43
|
+
else:
|
|
44
|
+
return None
|
|
45
|
+
return CUDA_TAG_MAP.get(version)
|
|
46
|
+
except (subprocess.SubprocessError, IndexError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _install_torch(tag: str) -> None:
|
|
51
|
+
"""Install the correct torch CUDA wheel into the current environment."""
|
|
52
|
+
index_url = f"https://download.pytorch.org/whl/{tag}"
|
|
53
|
+
print(f"Installing torch ({tag}) into current environment ...")
|
|
54
|
+
subprocess.check_call(
|
|
55
|
+
[
|
|
56
|
+
sys.executable, "-m", "pip", "install",
|
|
57
|
+
"--index-url", index_url,
|
|
58
|
+
"torch>=2.7",
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ensure_torch() -> None:
|
|
64
|
+
"""Make sure torch is importable; auto-install if missing."""
|
|
65
|
+
try:
|
|
66
|
+
import torch # noqa: F401
|
|
67
|
+
return
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
tag = _detect_cuda()
|
|
72
|
+
if tag:
|
|
73
|
+
print(f"NVIDIA GPU detected — installing torch {tag}")
|
|
74
|
+
_install_torch(tag)
|
|
75
|
+
else:
|
|
76
|
+
print("No NVIDIA GPU detected — installing CPU-only torch")
|
|
77
|
+
subprocess.check_call(
|
|
78
|
+
[sys.executable, "-m", "pip", "install", "torch>=2.7"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
_ensure_torch()
|
|
84
|
+
import uvicorn
|
|
85
|
+
url = "http://127.0.0.1:8000"
|
|
86
|
+
webbrowser.open(url)
|
|
87
|
+
uvicorn.run("app.main:app", host="127.0.0.1", port=8000)
|
app/main.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""FastAPI backend: routes, SSE event stream, static frontend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import webbrowser
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
|
12
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from app.pipeline import STATUS_CREATED, STATUS_DONE, STATUS_FAILED, STATUS_READY, Pipeline
|
|
17
|
+
from app.separator import STEMS
|
|
18
|
+
from app.settings import Settings, load_settings, save_settings
|
|
19
|
+
from app.transcribe import list_instruments
|
|
20
|
+
|
|
21
|
+
ALLOWED_EXTENSIONS = {"wav", "mp3", "flac", "ogg", "m4a", "aiff"}
|
|
22
|
+
|
|
23
|
+
app = FastAPI(title="SplitScore")
|
|
24
|
+
PIPELINE = Pipeline(load_settings())
|
|
25
|
+
|
|
26
|
+
_HEARTBEAT = ":" + " " * 15 + "\n\n" # SSE comment keeps the connection alive
|
|
27
|
+
|
|
28
|
+
TERMINAL_EVENTS = {"done", "failed", "cancelled"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TranscribeBody(BaseModel):
|
|
32
|
+
stems: list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.post("/api/jobs")
|
|
36
|
+
async def create_job(file: UploadFile = File(...)):
|
|
37
|
+
name = Path(file.filename or "").name # basename only, strips any ../ or drive segments
|
|
38
|
+
if not name:
|
|
39
|
+
raise HTTPException(400, "Invalid filename")
|
|
40
|
+
if "/" in name or "\\" in name:
|
|
41
|
+
raise HTTPException(400, "Invalid filename")
|
|
42
|
+
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
43
|
+
if ext not in ALLOWED_EXTENSIONS:
|
|
44
|
+
raise HTTPException(400, f"Unsupported extension '.{ext}'; allowed: {sorted(ALLOWED_EXTENSIONS)}")
|
|
45
|
+
data = await file.read()
|
|
46
|
+
if not data:
|
|
47
|
+
raise HTTPException(400, "Empty file upload")
|
|
48
|
+
job = PIPELINE.create_job(Path(name).stem, "unused")
|
|
49
|
+
in_dir = job.output_dir / "input"
|
|
50
|
+
in_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
job.input_path = in_dir / name
|
|
52
|
+
job.input_path.write_bytes(data)
|
|
53
|
+
asyncio.create_task(PIPELINE.separate(job))
|
|
54
|
+
return {"job_id": job.id}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/api/jobs/{job_id}")
|
|
58
|
+
async def get_job(job_id: str):
|
|
59
|
+
job = PIPELINE.jobs.get(job_id)
|
|
60
|
+
if not job:
|
|
61
|
+
raise HTTPException(404, "Unknown job")
|
|
62
|
+
midi = [p.name for p in (job.output_dir / "midi").glob("*.mid")]
|
|
63
|
+
return {"job_id": job.id, "status": job.status, "error": job.error,
|
|
64
|
+
"song_name": job.song_name, "midi": midi}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.post("/api/jobs/{job_id}/transcribe")
|
|
68
|
+
async def transcribe(job_id: str, body: TranscribeBody):
|
|
69
|
+
job = PIPELINE.jobs.get(job_id)
|
|
70
|
+
if not job:
|
|
71
|
+
raise HTTPException(404, "Unknown job")
|
|
72
|
+
if job.status not in (STATUS_READY, STATUS_DONE, STATUS_FAILED):
|
|
73
|
+
raise HTTPException(409, f"Job not ready (status={job.status})")
|
|
74
|
+
bad = [s for s in body.stems if s not in STEMS]
|
|
75
|
+
if bad:
|
|
76
|
+
raise HTTPException(400, f"Unknown stems: {bad}")
|
|
77
|
+
s = PIPELINE.settings
|
|
78
|
+
asyncio.create_task(PIPELINE.transcribe(
|
|
79
|
+
job, body.stems, s.instrument_by_stem, s.temperature, s.beam_size, s.batch_size))
|
|
80
|
+
return {"job_id": job.id}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.post("/api/jobs/{job_id}/cancel")
|
|
84
|
+
async def cancel(job_id: str):
|
|
85
|
+
job = PIPELINE.jobs.get(job_id)
|
|
86
|
+
if not job:
|
|
87
|
+
raise HTTPException(404, "Unknown job")
|
|
88
|
+
job.cancel.set()
|
|
89
|
+
# Idle job (post-separation, waiting to transcribe): no worker coroutine
|
|
90
|
+
# observes the flag, so finish the cancel here and discard the stems.
|
|
91
|
+
if job.status in (STATUS_CREATED, STATUS_READY):
|
|
92
|
+
PIPELINE._finish_cancelled(job)
|
|
93
|
+
return {"ok": True}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/api/jobs/{job_id}/events")
|
|
97
|
+
async def events(job_id: str):
|
|
98
|
+
job = PIPELINE.jobs.get(job_id)
|
|
99
|
+
if not job:
|
|
100
|
+
raise HTTPException(404, "Unknown job")
|
|
101
|
+
|
|
102
|
+
async def gen():
|
|
103
|
+
while True:
|
|
104
|
+
try:
|
|
105
|
+
event = await asyncio.wait_for(job.events.get(), timeout=15.0)
|
|
106
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
107
|
+
if event["type"] in TERMINAL_EVENTS:
|
|
108
|
+
return
|
|
109
|
+
except asyncio.TimeoutError:
|
|
110
|
+
yield _HEARTBEAT
|
|
111
|
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.get("/api/instruments")
|
|
115
|
+
async def get_instruments():
|
|
116
|
+
# First call shells out to `muscriptor list-instruments` (~seconds); run it
|
|
117
|
+
# off the event loop. Result is cached in transcribe.list_instruments.
|
|
118
|
+
instruments = await asyncio.to_thread(list_instruments)
|
|
119
|
+
return {"instruments": instruments}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.get("/api/settings")
|
|
123
|
+
async def get_settings():
|
|
124
|
+
return load_settings()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.put("/api/settings")
|
|
128
|
+
async def put_settings(body: dict):
|
|
129
|
+
merged = asdict(load_settings())
|
|
130
|
+
merged.update({k: v for k, v in body.items() if k in merged})
|
|
131
|
+
updated = Settings(**merged)
|
|
132
|
+
save_settings(updated)
|
|
133
|
+
PIPELINE.settings = updated
|
|
134
|
+
return merged
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.get("/output/{job_id}/midi/{filename}")
|
|
138
|
+
async def download_midi(job_id: str, filename: str):
|
|
139
|
+
base = Path(PIPELINE.settings.output_folder).resolve()
|
|
140
|
+
path = (base / job_id / "midi" / filename).resolve()
|
|
141
|
+
if not path.is_relative_to(base) or not path.is_file():
|
|
142
|
+
raise HTTPException(404, "MIDI not found")
|
|
143
|
+
return FileResponse(path, media_type="audio/midi", filename=filename)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.get("/output/{job_id}/stems/{filename}")
|
|
147
|
+
async def download_stem(job_id: str, filename: str):
|
|
148
|
+
base = Path(PIPELINE.settings.output_folder).resolve()
|
|
149
|
+
path = (base / job_id / "stems" / filename).resolve()
|
|
150
|
+
if not path.is_relative_to(base) or not path.is_file():
|
|
151
|
+
raise HTTPException(404, "Stem not found")
|
|
152
|
+
return FileResponse(path, media_type="audio/wav")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
app.mount("/", StaticFiles(directory=Path(__file__).parent / "static", html=True), name="static")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
import uvicorn
|
|
160
|
+
url = "http://127.0.0.1:8000"
|
|
161
|
+
webbrowser.open(url)
|
|
162
|
+
uvicorn.run(app, host="127.0.0.1", port=8000)
|
app/pipeline.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Job orchestration: separate then transcribe, with SSE event stream + cancel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from app.separator import STEMS, Separator
|
|
12
|
+
from app.settings import Settings
|
|
13
|
+
from app.transcribe import Transcriber
|
|
14
|
+
|
|
15
|
+
STATUS_CREATED = "created"
|
|
16
|
+
STATUS_SEPARATING = "separating"
|
|
17
|
+
STATUS_READY = "ready"
|
|
18
|
+
STATUS_TRANSCRIBING = "transcribing"
|
|
19
|
+
STATUS_DONE = "done"
|
|
20
|
+
STATUS_FAILED = "failed"
|
|
21
|
+
STATUS_CANCELLED = "cancelled"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Job:
|
|
26
|
+
id: str
|
|
27
|
+
song_name: str = ""
|
|
28
|
+
status: str = STATUS_CREATED
|
|
29
|
+
input_path: Path | None = None
|
|
30
|
+
output_dir: Path | None = None
|
|
31
|
+
error: str | None = None
|
|
32
|
+
cancel: asyncio.Event = field(default_factory=asyncio.Event)
|
|
33
|
+
events: asyncio.Queue = field(default_factory=asyncio.Queue)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hf_setup_hint(exc: Exception) -> str:
|
|
37
|
+
"""Append a setup hint when the error looks like a missing/rejected HF token."""
|
|
38
|
+
text = str(exc).lower()
|
|
39
|
+
if any(s in text for s in ("401", "gated", "authentication", "forbidden", "hf_token", "huggingface")):
|
|
40
|
+
return f"{exc}\n\nSetup hint: run `uv run hf auth login` with a Hugging Face token to download the models."
|
|
41
|
+
return str(exc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_cuda_oom(exc: Exception) -> bool:
|
|
45
|
+
text = f"{type(exc).__name__}: {exc}".lower()
|
|
46
|
+
return "out of memory" in text or "cuda error" in text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_OOM_MESSAGE = (
|
|
50
|
+
"CUDA ran out of memory while transcribing. Lower Beam size or Batch size in "
|
|
51
|
+
"Settings (or pick a smaller model), then try again.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Pipeline:
|
|
55
|
+
def __init__(self, settings: Settings, separator_factory=None, transcriber_factory=None):
|
|
56
|
+
self.settings = settings
|
|
57
|
+
self._sep_factory = separator_factory or Separator
|
|
58
|
+
self._tr_factory = transcriber_factory or Transcriber
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
self.jobs: dict[str, Job] = {}
|
|
61
|
+
|
|
62
|
+
def create_job(self, song_name: str, input_path: str | Path) -> Job:
|
|
63
|
+
job = Job(id=uuid.uuid4().hex[:12], song_name=song_name, input_path=Path(input_path))
|
|
64
|
+
out = Path(self.settings.output_folder) / job.id
|
|
65
|
+
job.output_dir = out
|
|
66
|
+
(out / "stems").mkdir(parents=True, exist_ok=True)
|
|
67
|
+
(out / "midi").mkdir(parents=True, exist_ok=True)
|
|
68
|
+
self.jobs[job.id] = job
|
|
69
|
+
return job
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _emit(job: Job, event: dict) -> None:
|
|
73
|
+
"""Push an event from the event loop thread (plain put_nowait)."""
|
|
74
|
+
job.events.put_nowait(event)
|
|
75
|
+
|
|
76
|
+
def _finish_cancelled(self, job: Job) -> None:
|
|
77
|
+
"""Idempotent cancel: mark cancelled, discard this job's output, notify SSE."""
|
|
78
|
+
if job.status == STATUS_CANCELLED:
|
|
79
|
+
return
|
|
80
|
+
job.status = STATUS_CANCELLED
|
|
81
|
+
job.error = "Cancelled"
|
|
82
|
+
if job.output_dir:
|
|
83
|
+
shutil.rmtree(job.output_dir, ignore_errors=True)
|
|
84
|
+
self._emit(job, {"type": "cancelled", "message": "Cancelled"})
|
|
85
|
+
|
|
86
|
+
async def separate(self, job: Job) -> None:
|
|
87
|
+
if job.cancel.is_set():
|
|
88
|
+
self._finish_cancelled(job)
|
|
89
|
+
return
|
|
90
|
+
job.status = STATUS_SEPARATING
|
|
91
|
+
async with self._lock:
|
|
92
|
+
try:
|
|
93
|
+
if job.cancel.is_set():
|
|
94
|
+
self._finish_cancelled(job)
|
|
95
|
+
return
|
|
96
|
+
loop = asyncio.get_running_loop()
|
|
97
|
+
|
|
98
|
+
def emit(event: dict) -> None:
|
|
99
|
+
# Called from the separator's worker thread -> thread-safe push.
|
|
100
|
+
loop.call_soon_threadsafe(job.events.put_nowait, event)
|
|
101
|
+
|
|
102
|
+
sep = await asyncio.to_thread(
|
|
103
|
+
self._sep_factory, precision=self.settings.separation_precision,
|
|
104
|
+
device=self.settings.separation_device)
|
|
105
|
+
await asyncio.to_thread(
|
|
106
|
+
sep.separate, str(job.input_path), job.output_dir / "stems",
|
|
107
|
+
lambda pct: emit({"type": "progress", "phase": "separating", "pct": pct}))
|
|
108
|
+
if job.cancel.is_set():
|
|
109
|
+
self._finish_cancelled(job)
|
|
110
|
+
return
|
|
111
|
+
job.status = STATUS_READY
|
|
112
|
+
emit({"type": "stems", "stems": STEMS})
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
self._finish_cancelled(job)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
msg = _hf_setup_hint(exc)
|
|
117
|
+
job.status = STATUS_FAILED
|
|
118
|
+
job.error = msg
|
|
119
|
+
self._emit(job, {"type": "failed", "message": msg})
|
|
120
|
+
|
|
121
|
+
async def transcribe(self, job: Job, stems: list[str], instrument_by_stem: dict[str, str],
|
|
122
|
+
temperature: float, beam_size: int, batch_size: int) -> None:
|
|
123
|
+
job.status = STATUS_TRANSCRIBING
|
|
124
|
+
async with self._lock:
|
|
125
|
+
try:
|
|
126
|
+
tr = await asyncio.to_thread(
|
|
127
|
+
self._tr_factory, model_size=self.settings.model_size,
|
|
128
|
+
device=self.settings.transcription_device)
|
|
129
|
+
loop = asyncio.get_running_loop()
|
|
130
|
+
for stem in stems:
|
|
131
|
+
if job.cancel.is_set():
|
|
132
|
+
self._finish_cancelled(job)
|
|
133
|
+
return
|
|
134
|
+
self._emit(job, {"type": "progress", "phase": "transcribing",
|
|
135
|
+
"stem": stem, "pct": 0})
|
|
136
|
+
try:
|
|
137
|
+
def on_chunk(completed, total, stem=stem):
|
|
138
|
+
# Called from the worker thread -> thread-safe push.
|
|
139
|
+
loop.call_soon_threadsafe(
|
|
140
|
+
job.events.put_nowait,
|
|
141
|
+
{"type": "progress", "phase": "transcribing",
|
|
142
|
+
"stem": stem, "pct": round(100.0 * completed / total)})
|
|
143
|
+
midi_bytes = await asyncio.to_thread(
|
|
144
|
+
tr.transcribe, job.output_dir / "stems" / f"{stem}.wav", stem,
|
|
145
|
+
instrument_by_stem.get(stem) or None, temperature, beam_size,
|
|
146
|
+
batch_size, on_chunk)
|
|
147
|
+
out = job.output_dir / "midi" / f"{job.song_name}_{stem}.mid"
|
|
148
|
+
out.write_bytes(midi_bytes)
|
|
149
|
+
self._emit(job, {"type": "midi", "stem": stem, "file": out.name})
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
if _is_cuda_oom(exc):
|
|
152
|
+
# VRAM exhausted at the KV-cache stage: continuing to
|
|
153
|
+
# the next stem would OOM again, so fail the job.
|
|
154
|
+
job.status = STATUS_FAILED
|
|
155
|
+
job.error = _OOM_MESSAGE
|
|
156
|
+
self._emit(job, {"type": "failed", "message": _OOM_MESSAGE})
|
|
157
|
+
return
|
|
158
|
+
# One stem failing must not stop the others (non-terminal).
|
|
159
|
+
self._emit(job, {"type": "error", "message": f"{stem}: {exc}"})
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
msg = _hf_setup_hint(exc)
|
|
162
|
+
job.status = STATUS_FAILED
|
|
163
|
+
job.error = msg
|
|
164
|
+
self._emit(job, {"type": "failed", "message": msg})
|
|
165
|
+
return
|
|
166
|
+
if job.cancel.is_set():
|
|
167
|
+
self._finish_cancelled(job)
|
|
168
|
+
else:
|
|
169
|
+
job.status = STATUS_DONE
|
|
170
|
+
self._emit(job, {"type": "done"})
|
app/separator.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""BS-RoFormer-SW separation: torch DSP around the ONNX model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
SR = 44100
|
|
8
|
+
N_FFT = 2048
|
|
9
|
+
HOP_LENGTH = 512
|
|
10
|
+
WIN_LENGTH = 2048
|
|
11
|
+
CHUNK_FRAMES = 345 # model traced at 4 s @ 44.1 kHz (176400 samples)
|
|
12
|
+
CHUNK_HOP_FRAMES = 220 # 125-frame overlap (~1.45 s) for crossfading
|
|
13
|
+
OVERLAP_FRAMES = CHUNK_FRAMES - CHUNK_HOP_FRAMES # 125
|
|
14
|
+
|
|
15
|
+
# Order must match the ONNX output channels.
|
|
16
|
+
STEMS = ["bass", "drums", "other", "vocals", "guitar", "piano"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _window():
|
|
20
|
+
return torch.hann_window(WIN_LENGTH)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stft(audio: torch.Tensor) -> torch.Tensor:
|
|
24
|
+
"""[2, N] float32 @44.1kHz -> [2, 1025, F] complex spectrogram."""
|
|
25
|
+
return torch.stft(
|
|
26
|
+
audio, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
|
27
|
+
window=_window(), center=True, return_complex=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def istft(spec: torch.Tensor, length: int | None = None) -> torch.Tensor:
|
|
32
|
+
"""[2, 1025, F] -> [2, N] audio. Pass length to reconstruct the exact N."""
|
|
33
|
+
return torch.istft(
|
|
34
|
+
spec, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
|
35
|
+
window=_window(), center=True, length=length,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def split_into_chunks(spec: torch.Tensor) -> list[torch.Tensor]:
|
|
40
|
+
"""Split [2,1025,F] into [2,1025,345] chunks, stride CHUNK_HOP_FRAMES, pad last."""
|
|
41
|
+
total = spec.shape[-1]
|
|
42
|
+
if total <= CHUNK_FRAMES:
|
|
43
|
+
return [torch.nn.functional.pad(spec, (0, CHUNK_FRAMES - total))]
|
|
44
|
+
n_chunks = (total - CHUNK_FRAMES + CHUNK_HOP_FRAMES - 1) // CHUNK_HOP_FRAMES + 1
|
|
45
|
+
chunks = []
|
|
46
|
+
for i in range(n_chunks):
|
|
47
|
+
start = i * CHUNK_HOP_FRAMES
|
|
48
|
+
piece = spec[..., start:start + CHUNK_FRAMES]
|
|
49
|
+
if piece.shape[-1] < CHUNK_FRAMES:
|
|
50
|
+
piece = torch.nn.functional.pad(piece, (0, CHUNK_FRAMES - piece.shape[-1]))
|
|
51
|
+
chunks.append(piece)
|
|
52
|
+
return chunks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def overlap_add(chunks: list[torch.Tensor], total_frames: int) -> torch.Tensor:
|
|
56
|
+
"""Reverse of split_into_chunks with a linear crossfade over the overlap.
|
|
57
|
+
|
|
58
|
+
Chunk i starts at frame i*CHUNK_HOP_FRAMES. The overlap region gets a
|
|
59
|
+
linear ramp 0->1 on the incoming chunk and 1->0 on existing content, which
|
|
60
|
+
sums to exactly 1 for identical overlap content (exact reconstruction).
|
|
61
|
+
"""
|
|
62
|
+
if not chunks:
|
|
63
|
+
raise ValueError("no chunks to overlap-add")
|
|
64
|
+
out = chunks[0][..., :total_frames].clone()
|
|
65
|
+
for i in range(1, len(chunks)):
|
|
66
|
+
chunk = chunks[i]
|
|
67
|
+
start = i * CHUNK_HOP_FRAMES
|
|
68
|
+
if start >= total_frames:
|
|
69
|
+
break
|
|
70
|
+
end = min(start + CHUNK_FRAMES, total_frames)
|
|
71
|
+
over = min(OVERLAP_FRAMES, end - start)
|
|
72
|
+
ramp = torch.linspace(0.0, 1.0, over).reshape(1, 1, over) # float32
|
|
73
|
+
out[..., start:start + over] = (
|
|
74
|
+
out[..., start:start + over] * (1 - ramp) + chunk[..., :over] * ramp)
|
|
75
|
+
if end > out.shape[-1]:
|
|
76
|
+
tail = end - out.shape[-1]
|
|
77
|
+
out = torch.cat([out, chunk[..., over:over + tail]], dim=-1)
|
|
78
|
+
return out[..., :total_frames]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mask_and_synthesize(input_spec: torch.Tensor, mask: torch.Tensor,
|
|
82
|
+
length: int | None = None) -> torch.Tensor:
|
|
83
|
+
"""Apply a per-stem mask [2,1025,F] to input_spec, ISTFT back to audio [2,N]."""
|
|
84
|
+
return istft(input_spec * mask, length=length)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
|
|
89
|
+
import numpy as np
|
|
90
|
+
import onnxruntime as ort
|
|
91
|
+
import soundfile as sf
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
MODEL_ID = "elicwhite/bs-roformer-sw-6stem-onnx"
|
|
95
|
+
MODEL_FILES = {"fp16": "bs_roformer_sw_6stem_fp16.onnx", "fp32": "bs_roformer_sw_6stem_fp32.onnx"}
|
|
96
|
+
MODEL_CACHE = Path.home() / ".cache" / "audio-to-midi" / "separator"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_audio(path: str | Path) -> tuple[torch.Tensor, int]:
|
|
100
|
+
"""Decode any soundfile-readable file to stereo float32 @ 44.1 kHz."""
|
|
101
|
+
data, sr = sf.read(str(path), always_2d=True, dtype="float32") # [N, ch]
|
|
102
|
+
if data.shape[1] == 1:
|
|
103
|
+
data = np.repeat(data, 2, axis=1)
|
|
104
|
+
elif data.shape[1] > 2:
|
|
105
|
+
data = data[:, :2]
|
|
106
|
+
audio = torch.from_numpy(data.T).float() # [ch, N]
|
|
107
|
+
if sr != SR:
|
|
108
|
+
target = int(audio.shape[1] * SR / sr)
|
|
109
|
+
audio = torch.stack([
|
|
110
|
+
torch.nn.functional.interpolate(
|
|
111
|
+
audio[c:c+1, None, :], size=target, mode="linear", align_corners=False)[0, 0]
|
|
112
|
+
for c in range(2)])
|
|
113
|
+
return audio, SR
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _download_model(precision: str) -> Path:
|
|
117
|
+
"""Return the ONNX path from MODEL_CACHE, downloading only if absent.
|
|
118
|
+
|
|
119
|
+
local_files_only first: the plain call does a Hub HEAD check on every
|
|
120
|
+
invocation, which retries ~25s (x2, the CPU fallback re-calls this) before
|
|
121
|
+
any session is created when the machine's Python TLS stack is broken.
|
|
122
|
+
"""
|
|
123
|
+
from huggingface_hub import hf_hub_download
|
|
124
|
+
MODEL_CACHE.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
kw = dict(repo_id=MODEL_ID, filename=MODEL_FILES[precision], cache_dir=MODEL_CACHE)
|
|
126
|
+
try:
|
|
127
|
+
return Path(hf_hub_download(**kw, local_files_only=True))
|
|
128
|
+
except Exception:
|
|
129
|
+
# Not cached yet (fresh machine) -> normal download.
|
|
130
|
+
return Path(hf_hub_download(**kw))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _default_session_factory(precision: str, device: str) -> ort.InferenceSession:
|
|
134
|
+
model_path = _download_model(precision)
|
|
135
|
+
providers = (["CUDAExecutionProvider", "CPUExecutionProvider"]
|
|
136
|
+
if device == "cuda" else ["CPUExecutionProvider"])
|
|
137
|
+
return ort.InferenceSession(str(model_path), providers=providers)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Separator:
|
|
141
|
+
def __init__(self, precision: str = "fp16", device: str = "auto", session_factory=None):
|
|
142
|
+
self.precision = precision if precision in MODEL_FILES else "fp16"
|
|
143
|
+
from app.settings import resolve_device
|
|
144
|
+
self.device = resolve_device(device)
|
|
145
|
+
factory = session_factory or _default_session_factory
|
|
146
|
+
try:
|
|
147
|
+
self.session = factory(self.precision, self.device)
|
|
148
|
+
except Exception:
|
|
149
|
+
# GPU runtime failed (missing provider, download hiccup) -> CPU.
|
|
150
|
+
self.session = _default_session_factory(self.precision, "cpu")
|
|
151
|
+
self.device = "cpu"
|
|
152
|
+
|
|
153
|
+
def _run_chunk(self, spec_chunk: torch.Tensor):
|
|
154
|
+
"""[2,1025,345] complex -> ([6,2,1025,345] real, [6,2,1025,345] imag) as tensors.
|
|
155
|
+
|
|
156
|
+
The ONNX model emits the ALREADY-MASKED (separated) spectrograms per stem.
|
|
157
|
+
See: https://huggingface.co/elicwhite/bs-roformer-sw-6stem-onnx
|
|
158
|
+
Wrapper docstring: "Do not multiply them by the input again."
|
|
159
|
+
"""
|
|
160
|
+
r = spec_chunk[None, ...].real.float().numpy()
|
|
161
|
+
i = spec_chunk[None, ...].imag.float().numpy()
|
|
162
|
+
out_r, out_i = self.session.run(None, {"spec_real": r, "spec_imag": i})
|
|
163
|
+
return torch.from_numpy(out_r), torch.from_numpy(out_i)
|
|
164
|
+
|
|
165
|
+
def separate(self, in_path, out_dir, on_progress=None) -> list[Path]:
|
|
166
|
+
audio, _ = load_audio(in_path)
|
|
167
|
+
spec = stft(audio.to("cpu"))
|
|
168
|
+
chunks = split_into_chunks(spec)
|
|
169
|
+
out_dir = Path(out_dir)
|
|
170
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
acc = [None] * len(STEMS) # per-stem separated chunk lists
|
|
172
|
+
for idx, chunk in enumerate(chunks):
|
|
173
|
+
sep_r, sep_i = self._run_chunk(chunk)
|
|
174
|
+
for s in range(len(STEMS)):
|
|
175
|
+
separated_spec = sep_r[0, s] + 1j * sep_i[0, s] # [2,1025,345]
|
|
176
|
+
acc[s] = acc[s] or []
|
|
177
|
+
acc[s].append(separated_spec)
|
|
178
|
+
if on_progress:
|
|
179
|
+
on_progress(100.0 * (idx + 1) / len(chunks))
|
|
180
|
+
results = []
|
|
181
|
+
for s, name in enumerate(STEMS):
|
|
182
|
+
recon = overlap_add(acc[s], spec.shape[-1])
|
|
183
|
+
audio_s = istft(recon, length=audio.shape[1]).clamp(-1.0, 1.0)
|
|
184
|
+
path = out_dir / f"{name}.wav"
|
|
185
|
+
sf.write(str(path), audio_s.T.numpy(), SR)
|
|
186
|
+
results.append(path)
|
|
187
|
+
return results
|
app/settings.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Persistent app settings."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
# Order must match the BS-RoFormer-SW ONNX output channels (0-5).
|
|
10
|
+
STEMS = ["bass", "drums", "other", "vocals", "guitar", "piano"]
|
|
11
|
+
|
|
12
|
+
SETTINGS_FILE = Path(__file__).parent / "settings.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Settings:
|
|
17
|
+
separation_device: str = "auto" # auto | cuda | cpu
|
|
18
|
+
separation_precision: str = "fp16" # fp16 | fp32
|
|
19
|
+
model_size: str = "large" # small | medium | large
|
|
20
|
+
instrument_by_stem: dict = field(default_factory=dict) # stem -> instrument name or "" (auto)
|
|
21
|
+
temperature: float = 0.0 # 0 = deterministic
|
|
22
|
+
beam_size: int = 4 # 1 = greedy
|
|
23
|
+
batch_size: int = 1 # >1 disables prelude forcing & multiplies KV-cache VRAM
|
|
24
|
+
transcription_device: str = "auto" # auto | cuda | cpu
|
|
25
|
+
output_folder: str = "./output"
|
|
26
|
+
keep_stems: bool = True
|
|
27
|
+
remember_selection: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_SETTINGS = Settings()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_settings() -> Settings:
|
|
34
|
+
if not SETTINGS_FILE.exists():
|
|
35
|
+
return DEFAULT_SETTINGS
|
|
36
|
+
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
|
37
|
+
merged = asdict(DEFAULT_SETTINGS)
|
|
38
|
+
merged.update({k: v for k, v in data.items() if k in merged})
|
|
39
|
+
return Settings(**merged)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_settings(settings: Settings) -> None:
|
|
43
|
+
SETTINGS_FILE.write_text(json.dumps(asdict(settings), indent=2), encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_device(requested: str) -> str:
|
|
47
|
+
if requested in ("cuda", "cpu"):
|
|
48
|
+
return requested
|
|
49
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|