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 ADDED
@@ -0,0 +1,3 @@
1
+ """SplitScore — stem separation + MIDI transcription."""
2
+
3
+ __version__ = "0.1.0"
app/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from app.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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"