voxsplit 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.
backend/__init__.py ADDED
File without changes
backend/engine.py ADDED
@@ -0,0 +1,119 @@
1
+ """Engine wrapper around python-audio-separator.
2
+
3
+ The heavy audio_separator import happens lazily so the FastAPI app,
4
+ tests, and CI can run without torch installed.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ MODELS = [
13
+ {"id": "UVR-MDX-NET-Inst_HQ_3.onnx", "family": "MDX-Net", "stems": 2, "note": "HQ instrumental/vocals"},
14
+ {"id": "Kim_Vocal_2.onnx", "family": "MDX-Net", "stems": 2, "note": "Vocals, stronger on male vocals"},
15
+ {"id": "UVR_MDXNET_KARA_2.onnx", "family": "MDX-Net", "stems": 2, "note": "Karaoke-optimized"},
16
+ {
17
+ "id": "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt",
18
+ "family": "Mel-Band RoFormer",
19
+ "stems": 2,
20
+ "note": "SOTA karaoke (~1GB download)",
21
+ },
22
+ {
23
+ "id": "mel_band_roformer_karaoke_becruily.ckpt",
24
+ "family": "Mel-Band RoFormer",
25
+ "stems": 2,
26
+ "note": "SOTA karaoke alt (~1GB download)",
27
+ },
28
+ {
29
+ "id": "vocals_mel_band_roformer.ckpt",
30
+ "family": "Mel-Band RoFormer",
31
+ "stems": 2,
32
+ "note": "SOTA vocals (~1GB download)",
33
+ },
34
+ {"id": "htdemucs_4s.yaml", "family": "Demucs", "stems": 4, "note": "Vocals/drums/bass/other"},
35
+ ]
36
+
37
+ DEFAULT_MODEL = "UVR-MDX-NET-Inst_HQ_3.onnx"
38
+
39
+ PHASES = ("loading_model", "separating", "writing")
40
+
41
+
42
+ def list_models() -> list[dict]:
43
+ return MODELS
44
+
45
+
46
+ def is_known_model(model: str) -> bool:
47
+ return model in {m["id"] for m in MODELS}
48
+
49
+
50
+ def detect_accelerator() -> str:
51
+ try:
52
+ import torch
53
+
54
+ if torch.cuda.is_available():
55
+ return "cuda"
56
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
57
+ return "mps"
58
+ except Exception:
59
+ pass
60
+ return "cpu"
61
+
62
+
63
+ def _install_progress_hook(progress_cb):
64
+ """Patch tqdm inside torch-based architectures so chunk loops report progress.
65
+
66
+ Returns a list of (module, attr, original) tuples to restore in finally.
67
+ MDX-Net models run through onnxruntime without tqdm loops, so callers get
68
+ phase-level progress only for that family.
69
+ """
70
+ from audio_separator.separator.architectures import demucs_separator, mdxc_separator
71
+ from tqdm import tqdm
72
+
73
+ class HookedTqdm(tqdm):
74
+ def update(self, n=1):
75
+ super().update(n)
76
+ if self.total:
77
+ progress_cb("separating", min(100.0, 100.0 * self.n / self.total))
78
+
79
+ saved = []
80
+ for module in (mdxc_separator, demucs_separator):
81
+ if hasattr(module, "tqdm"):
82
+ saved.append((module, "tqdm", module.tqdm))
83
+ module.tqdm = HookedTqdm
84
+ return saved
85
+
86
+
87
+ def run_separation(
88
+ input_path: Path,
89
+ output_dir: Path,
90
+ model: str,
91
+ model_dir: Path,
92
+ progress_cb=None,
93
+ ) -> list[Path]:
94
+ from audio_separator.separator import Separator
95
+
96
+ output_dir.mkdir(parents=True, exist_ok=True)
97
+ model_dir.mkdir(parents=True, exist_ok=True)
98
+
99
+ def report(phase: str, percent: float | None = None) -> None:
100
+ if progress_cb:
101
+ progress_cb(phase, percent)
102
+
103
+ report("loading_model", 0.0)
104
+ logger.info("Loading model %s", model)
105
+ saved = _install_progress_hook(report)
106
+ try:
107
+ separator = Separator(
108
+ model_file_dir=str(model_dir),
109
+ output_dir=str(output_dir),
110
+ output_format="WAV",
111
+ )
112
+ separator.load_model(model_filename=model)
113
+ output_files = separator.separate(str(input_path))
114
+ finally:
115
+ for module, attr, original in saved:
116
+ setattr(module, attr, original)
117
+ report("writing", 99.0)
118
+ logger.info("Separation produced %d files", len(output_files))
119
+ return [output_dir / Path(f).name for f in output_files]
@@ -0,0 +1,183 @@
1
+ const fileInput = document.getElementById("file-input");
2
+ const browseBtn = document.getElementById("browse");
3
+ const dropzone = document.getElementById("dropzone");
4
+ const fileName = document.getElementById("file-name");
5
+ const modelSelect = document.getElementById("model-select");
6
+ const splitBtn = document.getElementById("split-btn");
7
+ const jobList = document.getElementById("job-list");
8
+
9
+ let selectedFile = null;
10
+ const pollers = new Map();
11
+
12
+ async function loadModels() {
13
+ const res = await fetch("/api/models");
14
+ const models = await res.json();
15
+ for (const m of models) {
16
+ const opt = document.createElement("option");
17
+ opt.value = m.id;
18
+ opt.textContent = `${m.id} — ${m.note} (${m.stems} stems)`;
19
+ modelSelect.appendChild(opt);
20
+ }
21
+ }
22
+
23
+ browseBtn.addEventListener("click", () => fileInput.click());
24
+
25
+ fileInput.addEventListener("change", () => {
26
+ selectedFile = fileInput.files[0] || null;
27
+ fileName.textContent = selectedFile ? selectedFile.name : "";
28
+ splitBtn.disabled = !selectedFile;
29
+ });
30
+
31
+ ["dragenter", "dragover"].forEach((evt) =>
32
+ dropzone.addEventListener(evt, (e) => {
33
+ e.preventDefault();
34
+ dropzone.classList.add("dragover");
35
+ })
36
+ );
37
+
38
+ ["dragleave", "drop"].forEach((evt) =>
39
+ dropzone.addEventListener(evt, (e) => {
40
+ e.preventDefault();
41
+ dropzone.classList.remove("dragover");
42
+ })
43
+ );
44
+
45
+ dropzone.addEventListener("drop", (e) => {
46
+ const file = e.dataTransfer.files[0];
47
+ if (file) {
48
+ selectedFile = file;
49
+ fileName.textContent = file.name;
50
+ splitBtn.disabled = false;
51
+ }
52
+ });
53
+
54
+ splitBtn.addEventListener("click", async () => {
55
+ if (!selectedFile) return;
56
+ splitBtn.disabled = true;
57
+ splitBtn.textContent = "Uploading…";
58
+ try {
59
+ const form = new FormData();
60
+ form.append("file", selectedFile);
61
+ form.append("model", modelSelect.value);
62
+ const res = await fetch("/api/jobs", { method: "POST", body: form });
63
+ if (!res.ok) {
64
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
65
+ throw new Error(err.detail || "Upload failed");
66
+ }
67
+ const job = await res.json();
68
+ addJobCard(job);
69
+ pollJob(job.id);
70
+ selectedFile = null;
71
+ fileInput.value = "";
72
+ fileName.textContent = "";
73
+ } catch (err) {
74
+ alert(err.message);
75
+ } finally {
76
+ splitBtn.disabled = !selectedFile;
77
+ splitBtn.textContent = "Split";
78
+ }
79
+ });
80
+
81
+ function stemLabel(path) {
82
+ const base = path.split("/").pop();
83
+ const m = base.match(/_(vocals|instrumental|drums|bass|other|no_vocals)\./i);
84
+ return m ? m[1].replace("_", " ") : base;
85
+ }
86
+
87
+ function addJobCard(job) {
88
+ const empty = jobList.querySelector(".empty");
89
+ if (empty) empty.remove();
90
+
91
+ const div = document.createElement("div");
92
+ div.className = "job";
93
+ div.id = `job-${job.id}`;
94
+ div.innerHTML = `
95
+ <div class="job-head">
96
+ <span class="job-title">${escapeHtml(job.filename)}</span>
97
+ <span class="job-model">${escapeHtml(job.model)}</span>
98
+ <span class="status ${job.status}">${job.status}</span>
99
+ </div>
100
+ <div class="job-body"></div>
101
+ `;
102
+ jobList.prepend(div);
103
+ renderJobBody(div, job);
104
+ }
105
+
106
+ const PHASE_LABELS = {
107
+ loading_model: "Loading model…",
108
+ separating: "Separating…",
109
+ writing: "Writing stems…",
110
+ };
111
+
112
+ function renderJobBody(card, job) {
113
+ const body = card.querySelector(".job-body");
114
+ const status = card.querySelector(".status");
115
+ status.textContent = job.status;
116
+ status.className = `status ${job.status}`;
117
+
118
+ if (job.status === "failed") {
119
+ body.innerHTML = `<p class="job-error">${escapeHtml(job.error || "Unknown error")}</p>`;
120
+ } else if (job.status === "completed") {
121
+ const ul = document.createElement("ul");
122
+ ul.className = "stems";
123
+ for (const f of job.files) {
124
+ const name = f.split("/").pop();
125
+ const li = document.createElement("li");
126
+ li.innerHTML = `
127
+ <span class="stem-name">${escapeHtml(stemLabel(name))}</span>
128
+ <audio controls preload="none" src="/api/jobs/${job.id}/files/${encodeURIComponent(name)}"></audio>
129
+ <a class="download" href="/api/jobs/${job.id}/files/${encodeURIComponent(name)}" download>download</a>
130
+ `;
131
+ ul.appendChild(li);
132
+ }
133
+ body.innerHTML = "";
134
+ body.appendChild(ul);
135
+ } else {
136
+ const phase = PHASE_LABELS[job.phase] || (job.status === "queued" ? "Waiting…" : "Processing…");
137
+ let bar;
138
+ if (job.progress != null) {
139
+ bar = `<div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div>`;
140
+ } else {
141
+ bar = `<div class="bar"><div class="bar-fill indeterminate"></div></div>`;
142
+ }
143
+ body.innerHTML = `<p class="phase">${escapeHtml(phase)}</p>${bar}`;
144
+ }
145
+ }
146
+
147
+ function pollJob(jobId) {
148
+ if (pollers.has(jobId)) return;
149
+ const timer = setInterval(async () => {
150
+ const res = await fetch(`/api/jobs/${jobId}`);
151
+ if (!res.ok) {
152
+ clearInterval(timer);
153
+ pollers.delete(jobId);
154
+ return;
155
+ }
156
+ const job = await res.json();
157
+ const card = document.getElementById(`job-${jobId}`);
158
+ if (card) renderJobBody(card, job);
159
+ if (job.status === "completed" || job.status === "failed") {
160
+ clearInterval(timer);
161
+ pollers.delete(jobId);
162
+ }
163
+ }, 1500);
164
+ pollers.set(jobId, timer);
165
+ }
166
+
167
+ async function loadExistingJobs() {
168
+ const res = await fetch("/api/jobs");
169
+ const jobs = await res.json();
170
+ for (const job of jobs.slice(0, 20)) {
171
+ addJobCard(job);
172
+ if (job.status === "queued" || job.status === "processing") pollJob(job.id);
173
+ }
174
+ }
175
+
176
+ function escapeHtml(s) {
177
+ return s.replace(/[&<>"']/g, (c) => ({
178
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
179
+ }[c]));
180
+ }
181
+
182
+ loadModels();
183
+ loadExistingJobs();
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>VoxSplit — AI Stem Separation</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1><span class="accent">Vox</span>Split</h1>
12
+ <p class="tagline">Local-first AI stem separation</p>
13
+ </header>
14
+
15
+ <main>
16
+ <section class="card" id="dropzone" aria-label="Upload a song">
17
+ <input type="file" id="file-input" accept=".wav,.mp3,.flac,.ogg,.m4a,.aac,.wma" hidden />
18
+ <p class="drop-msg">Drop an audio file here, or <button type="button" class="link" id="browse">browse</button></p>
19
+ <p class="file-name" id="file-name"></p>
20
+ <div class="controls">
21
+ <label for="model-select">Model</label>
22
+ <select id="model-select"></select>
23
+ <button type="button" id="split-btn" class="primary" disabled>Split</button>
24
+ </div>
25
+ </section>
26
+
27
+ <section id="jobs">
28
+ <h2>Jobs</h2>
29
+ <div id="job-list" class="job-list">
30
+ <p class="empty">No jobs yet — split your first song above.</p>
31
+ </div>
32
+ </section>
33
+ </main>
34
+
35
+ <footer>
36
+ <p>
37
+ VoxSplit is free software (MIT). Powered by
38
+ <a href="https://github.com/nomadkaraoke/python-audio-separator" target="_blank" rel="noopener">python-audio-separator</a>.
39
+ Inspired by <a href="https://github.com/Anjok07/ultimatevocalremovergui" target="_blank" rel="noopener">Ultimate Vocal Remover</a>.
40
+ </p>
41
+ </footer>
42
+
43
+ <script src="/app.js"></script>
44
+ </body>
45
+ </html>
@@ -0,0 +1,175 @@
1
+ :root {
2
+ --bg: #0f1115;
3
+ --card: #171a21;
4
+ --border: #262b36;
5
+ --text: #e6e9ef;
6
+ --muted: #8b93a3;
7
+ --accent: #7c6cff;
8
+ --accent-2: #4ecdc4;
9
+ --danger: #ff6b6b;
10
+ --ok: #4caf7d;
11
+ }
12
+
13
+ * { box-sizing: border-box; }
14
+
15
+ body {
16
+ margin: 0;
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
+ background: var(--bg);
19
+ color: var(--text);
20
+ min-height: 100vh;
21
+ display: flex;
22
+ flex-direction: column;
23
+ }
24
+
25
+ header {
26
+ text-align: center;
27
+ padding: 2.5rem 1rem 1rem;
28
+ }
29
+
30
+ h1 { font-size: 2.2rem; margin: 0; letter-spacing: -0.02em; }
31
+ .accent { color: var(--accent); }
32
+ .tagline { color: var(--muted); margin: 0.3rem 0 0; }
33
+
34
+ main {
35
+ width: 100%;
36
+ max-width: 720px;
37
+ margin: 0 auto;
38
+ padding: 1rem;
39
+ flex: 1;
40
+ }
41
+
42
+ .card {
43
+ background: var(--card);
44
+ border: 1px solid var(--border);
45
+ border-radius: 12px;
46
+ padding: 1.5rem;
47
+ }
48
+
49
+ #dropzone { border-style: dashed; border-width: 2px; transition: border-color 0.15s; }
50
+ #dropzone.dragover { border-color: var(--accent); }
51
+ .drop-msg { text-align: center; color: var(--muted); margin: 0.5rem 0; }
52
+ .file-name { text-align: center; font-weight: 600; min-height: 1.4em; margin: 0.5rem 0; }
53
+
54
+ .link {
55
+ background: none;
56
+ border: none;
57
+ color: var(--accent);
58
+ cursor: pointer;
59
+ font: inherit;
60
+ text-decoration: underline;
61
+ padding: 0;
62
+ }
63
+
64
+ .controls {
65
+ display: flex;
66
+ gap: 0.75rem;
67
+ align-items: center;
68
+ justify-content: center;
69
+ margin-top: 1rem;
70
+ flex-wrap: wrap;
71
+ }
72
+
73
+ .controls label { color: var(--muted); }
74
+
75
+ select, button {
76
+ font: inherit;
77
+ border-radius: 8px;
78
+ border: 1px solid var(--border);
79
+ background: var(--bg);
80
+ color: var(--text);
81
+ padding: 0.5rem 0.9rem;
82
+ }
83
+
84
+ button.primary {
85
+ background: var(--accent);
86
+ border-color: var(--accent);
87
+ color: white;
88
+ font-weight: 600;
89
+ cursor: pointer;
90
+ }
91
+ button.primary:disabled { opacity: 0.45; cursor: not-allowed; }
92
+
93
+ h2 { font-size: 1.1rem; color: var(--muted); margin-top: 2rem; }
94
+
95
+ .job {
96
+ background: var(--card);
97
+ border: 1px solid var(--border);
98
+ border-radius: 12px;
99
+ padding: 1rem 1.25rem;
100
+ margin-bottom: 0.75rem;
101
+ }
102
+
103
+ .job-head {
104
+ display: flex;
105
+ justify-content: space-between;
106
+ align-items: baseline;
107
+ gap: 1rem;
108
+ }
109
+
110
+ .job-title { font-weight: 600; overflow-wrap: anywhere; }
111
+ .job-model { color: var(--muted); font-size: 0.85rem; }
112
+
113
+ .status { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
114
+ .status.queued { color: var(--muted); }
115
+ .status.processing { color: var(--accent-2); }
116
+ .status.completed { color: var(--ok); }
117
+ .status.failed { color: var(--danger); }
118
+
119
+ .job-error { color: var(--danger); font-size: 0.85rem; margin-top: 0.5rem; overflow-wrap: anywhere; }
120
+
121
+ .stems { list-style: none; padding: 0; margin: 0.75rem 0 0; }
122
+ .stems li {
123
+ display: flex;
124
+ align-items: center;
125
+ gap: 0.75rem;
126
+ padding: 0.4rem 0;
127
+ border-top: 1px solid var(--border);
128
+ }
129
+ .stems li audio { flex: 1; height: 36px; }
130
+
131
+ .stem-name {
132
+ font-size: 0.85rem;
133
+ color: var(--muted);
134
+ min-width: 30%;
135
+ overflow-wrap: anywhere;
136
+ }
137
+
138
+ a.download { color: var(--accent-2); font-size: 0.85rem; text-decoration: none; }
139
+ a.download:hover { text-decoration: underline; }
140
+
141
+ .empty { color: var(--muted); text-align: center; padding: 1.5rem 0; }
142
+
143
+ .phase { color: var(--muted); font-size: 0.85rem; margin: 0.5rem 0; }
144
+
145
+ .bar {
146
+ height: 8px;
147
+ border-radius: 4px;
148
+ background: var(--border);
149
+ overflow: hidden;
150
+ }
151
+
152
+ .bar-fill {
153
+ height: 100%;
154
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
155
+ border-radius: 4px;
156
+ transition: width 0.5s ease;
157
+ }
158
+
159
+ .bar-fill.indeterminate {
160
+ width: 35%;
161
+ animation: slide 1.2s ease-in-out infinite;
162
+ }
163
+
164
+ @keyframes slide {
165
+ 0% { margin-left: -35%; }
166
+ 100% { margin-left: 100%; }
167
+ }
168
+
169
+ footer {
170
+ text-align: center;
171
+ color: var(--muted);
172
+ font-size: 0.8rem;
173
+ padding: 1.5rem;
174
+ }
175
+ footer a { color: var(--accent-2); }
backend/jobs.py ADDED
@@ -0,0 +1,97 @@
1
+ """Thread-safe in-memory job store with a single-worker executor."""
2
+
3
+ import threading
4
+ import time
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from backend import engine
12
+
13
+
14
+ @dataclass
15
+ class Job:
16
+ id: str
17
+ filename: str
18
+ model: str
19
+ status: str = "queued"
20
+ phase: str = "queued"
21
+ progress: float | None = None
22
+ error: str | None = None
23
+ files: list[str] = field(default_factory=list)
24
+ created_at: float = field(default_factory=time.time)
25
+ finished_at: float | None = None
26
+
27
+ def to_dict(self) -> dict:
28
+ return {
29
+ "id": self.id,
30
+ "filename": self.filename,
31
+ "model": self.model,
32
+ "status": self.status,
33
+ "phase": self.phase,
34
+ "progress": self.progress,
35
+ "error": self.error,
36
+ "files": self.files,
37
+ "created_at": self.created_at,
38
+ "finished_at": self.finished_at,
39
+ }
40
+
41
+
42
+ class JobStore:
43
+ def __init__(self, output_dir: Path, model_dir: Path):
44
+ self._jobs: dict[str, Job] = {}
45
+ self._lock = threading.Lock()
46
+ self._executor = ThreadPoolExecutor(max_workers=1)
47
+ self.output_dir = output_dir
48
+ self.model_dir = model_dir
49
+
50
+ def create(self, filename: str, model: str) -> Job:
51
+ job = Job(id=uuid.uuid4().hex[:12], filename=filename, model=model)
52
+ with self._lock:
53
+ self._jobs[job.id] = job
54
+ return job
55
+
56
+ def get(self, job_id: str) -> Job | None:
57
+ with self._lock:
58
+ return self._jobs.get(job_id)
59
+
60
+ def remove(self, job_id: str) -> None:
61
+ with self._lock:
62
+ self._jobs.pop(job_id, None)
63
+
64
+ def list(self) -> list[Job]:
65
+ with self._lock:
66
+ return sorted(self._jobs.values(), key=lambda j: j.created_at, reverse=True)
67
+
68
+ def submit(self, job: Job, input_path: Path) -> None:
69
+ self._executor.submit(self._run, job, input_path)
70
+
71
+ def _run(self, job: Job, input_path: Path) -> None:
72
+ job.status = "processing"
73
+
74
+ def on_progress(phase: str, percent: float | None = None) -> None:
75
+ with self._lock:
76
+ job.phase = phase
77
+ job.progress = percent
78
+
79
+ try:
80
+ out_dir = self.output_dir / job.id
81
+ results = engine.run_separation(
82
+ input_path, out_dir, job.model, self.model_dir, progress_cb=on_progress
83
+ )
84
+ job.files = [str(p) for p in results]
85
+ job.status = "completed"
86
+ job.phase = "done"
87
+ job.progress = 100.0
88
+ except Exception as exc:
89
+ job.status = "failed"
90
+ job.phase = "error"
91
+ job.error = f"{type(exc).__name__}: {exc}"
92
+ finally:
93
+ job.finished_at = time.time()
94
+
95
+
96
+ def make_runner(store: JobStore) -> Callable[[Job, Path], None]:
97
+ return store.submit
backend/main.py ADDED
@@ -0,0 +1,121 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from backend import engine
11
+ from backend.jobs import JobStore
12
+
13
+ BASE_DIR = Path(__file__).resolve().parent
14
+ DATA_DIR = Path(os.environ.get("VOXSPLIT_DATA_DIR", Path.cwd() / "data"))
15
+ UPLOAD_DIR = DATA_DIR / "uploads"
16
+ OUTPUT_DIR = DATA_DIR / "output"
17
+ MODEL_DIR = DATA_DIR / "models"
18
+ FRONTEND_DIR = BASE_DIR / "frontend"
19
+
20
+ ALLOWED_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".wma"}
21
+ MAX_UPLOAD_BYTES = 500 * 1024 * 1024
22
+
23
+ app = FastAPI(title="VoxSplit", description="Local-first AI stem separation", version="0.1.0")
24
+ store = JobStore(output_dir=OUTPUT_DIR, model_dir=MODEL_DIR)
25
+
26
+
27
+ @app.get("/api/health")
28
+ def health() -> dict:
29
+ return {"status": "ok", "version": app.version, "accelerator": engine.detect_accelerator()}
30
+
31
+
32
+ @app.get("/api/models")
33
+ def models() -> list[dict]:
34
+ return engine.list_models()
35
+
36
+
37
+ @app.get("/api/jobs")
38
+ def list_jobs() -> list[dict]:
39
+ return [job.to_dict() for job in store.list()]
40
+
41
+
42
+ @app.post("/api/jobs")
43
+ async def create_job(file: UploadFile = File(...), model: str = Form(engine.DEFAULT_MODEL)) -> dict:
44
+ if not engine.is_known_model(model):
45
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
46
+ suffix = Path(file.filename or "").suffix.lower()
47
+ if suffix not in ALLOWED_EXTENSIONS:
48
+ raise HTTPException(status_code=400, detail=f"Unsupported file type: {suffix or 'unknown'}")
49
+
50
+ job = store.create(filename=file.filename or "upload", model=model)
51
+ input_path = UPLOAD_DIR / f"{job.id}{suffix}"
52
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
53
+ size = 0
54
+ with input_path.open("wb") as out:
55
+ while chunk := await file.read(1024 * 1024):
56
+ size += len(chunk)
57
+ if size > MAX_UPLOAD_BYTES:
58
+ input_path.unlink(missing_ok=True)
59
+ raise HTTPException(status_code=413, detail="File too large (500MB limit)")
60
+ out.write(chunk)
61
+ store.submit(job, input_path)
62
+ return job.to_dict()
63
+
64
+
65
+ @app.get("/api/jobs/{job_id}")
66
+ def get_job(job_id: str) -> dict:
67
+ job = store.get(job_id)
68
+ if job is None:
69
+ raise HTTPException(status_code=404, detail="Job not found")
70
+ return job.to_dict()
71
+
72
+
73
+ @app.delete("/api/jobs/{job_id}")
74
+ def delete_job(job_id: str) -> dict:
75
+ job = store.get(job_id)
76
+ if job is None:
77
+ raise HTTPException(status_code=404, detail="Job not found")
78
+ shutil.rmtree(OUTPUT_DIR / job_id, ignore_errors=True)
79
+ store.remove(job_id)
80
+ return {"deleted": job_id}
81
+
82
+
83
+ @app.get("/api/jobs/{job_id}/files/{name}", response_class=FileResponse)
84
+ def job_file(job_id: str, name: str) -> FileResponse:
85
+ job = store.get(job_id)
86
+ if job is None:
87
+ raise HTTPException(status_code=404, detail="Job not found")
88
+ path = (OUTPUT_DIR / job_id / name).resolve()
89
+ if not str(path).startswith(str((OUTPUT_DIR / job_id).resolve())):
90
+ raise HTTPException(status_code=400, detail="Invalid path")
91
+ if not path.is_file():
92
+ raise HTTPException(status_code=404, detail="File not found")
93
+ return FileResponse(path, filename=name)
94
+
95
+
96
+ app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
97
+
98
+
99
+ def main() -> None:
100
+ parser = argparse.ArgumentParser(
101
+ prog="voxsplit",
102
+ description="Local-first AI stem separation server",
103
+ )
104
+ parser.add_argument("command", nargs="?", default="serve", choices=["serve"])
105
+ parser.add_argument("--host", default="127.0.0.1")
106
+ parser.add_argument("--port", type=int, default=8399)
107
+ try:
108
+ from importlib.metadata import version
109
+
110
+ parser.add_argument("--version", action="version", version=f"voxsplit {version('voxsplit')}")
111
+ except Exception:
112
+ pass
113
+ args = parser.parse_args()
114
+
115
+ import uvicorn
116
+
117
+ uvicorn.run(app, host=args.host, port=args.port)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: voxsplit
3
+ Version: 0.1.0
4
+ Summary: Local-first AI stem separation with a modern web UI
5
+ Author: Michael Borck
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Michael Borck
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://voxsplit.borck.dev
29
+ Project-URL: Repository, https://github.com/michael-borck/voxsplit
30
+ Project-URL: Issues, https://github.com/michael-borck/voxsplit/issues
31
+ Keywords: audio,stems,source-separation,vocal-remover,karaoke,demucs,roformer,music
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Environment :: Web Environment
34
+ Classifier: Intended Audience :: End Users/Desktop
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Multimedia :: Sound/Audio
42
+ Requires-Python: >=3.10
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: fastapi>=0.115
46
+ Requires-Dist: uvicorn[standard]>=0.30
47
+ Requires-Dist: python-multipart>=0.0.9
48
+ Requires-Dist: audio-separator[cpu]>=0.30
49
+ Requires-Dist: audioread>=3.0
50
+ Provides-Extra: dev
51
+ Requires-Dist: pytest>=8.0; extra == "dev"
52
+ Requires-Dist: ruff>=0.6; extra == "dev"
53
+ Requires-Dist: httpx>=0.27; extra == "dev"
54
+ Dynamic: license-file
55
+
56
+ # VoxSplit
57
+
58
+ **[voxsplit.borck.dev](https://voxsplit.borck.dev)** — hear real separation output, then self-host.
59
+
60
+ Local-first AI stem separation with a modern web UI. Drop in a song, get back
61
+ vocals, instrumental, or full 4-stem splits — everything runs on your own
62
+ machine, no accounts, no uploads to the cloud.
63
+
64
+ VoxSplit is a greenfield web app built on proven open-source separation
65
+ engines. It is inspired by [Ultimate Vocal Remover GUI](https://github.com/Anjok07/ultimatevocalremovergui)
66
+ and stands on the shoulders of that project and its model authors.
67
+
68
+ ## Status
69
+
70
+ Working proof of concept: upload → separate with per-job progress → play/download
71
+ stems. Models auto-use the best available accelerator: CUDA on Linux/NVIDIA,
72
+ MPS on Apple Silicon (torch-based models), CPU everywhere.
73
+
74
+ ## Models
75
+
76
+ Weights are fetched on demand (first use of each model) into `data/models/`
77
+ from the community model repos maintained by the UVR and python-audio-separator
78
+ projects. No manual downloads needed.
79
+
80
+ | Model | Family | Stems | Notes |
81
+ |---|---|---|---|
82
+ | Mel-Band RoFormer (aufr33/viperx) | RoFormer | 2 | SOTA karaoke quality |
83
+ | Mel-Band RoFormer (becruily) | RoFormer | 2 | SOTA karaoke alt |
84
+ | Mel-Band RoFormer (KimberleyJSN) | RoFormer | 2 | SOTA vocals |
85
+ | UVR-MDX-NET-Inst HQ 3 | MDX-Net | 2 | Fast, high quality |
86
+ | Kim Vocal 2 | MDX-Net | 2 | Vocals, male-leaning |
87
+ | UVR MDXNET Kara 2 | MDX-Net | 2 | Karaoke-optimized |
88
+ | htdemucs_4s | Demucs | 4 | Vocals/drums/bass/other |
89
+
90
+ Accelerator notes: RoFormer and Demucs run on CUDA/MPS automatically. MDX-Net
91
+ runs via onnxruntime — CPU on macOS, CUDA on Linux with `audio-separator[gpu]`.
92
+
93
+ ## Quickstart
94
+
95
+ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/) (or plain `pip` —
96
+ see note below).
97
+
98
+ ```bash
99
+ git clone https://github.com/michael-borck/voxsplit
100
+ cd voxsplit
101
+ uv venv
102
+ source .venv/bin/activate
103
+ uv pip install -e .
104
+ voxsplit
105
+ ```
106
+
107
+ Without uv: `python3 -m venv .venv && source .venv/bin/activate && pip install -e .`
108
+
109
+ Open http://127.0.0.1:8399, drop a `.wav`/`.mp3`/`.flac`, pick a model, hit
110
+ **Split**. The first run downloads the selected model (~60 MB) to `data/models/`.
111
+
112
+ ## Architecture
113
+
114
+ ```
115
+ browser (vanilla JS) ──► FastAPI (backend/main.py)
116
+ ├── jobs.py thread-safe job store, 1-worker queue
117
+ ├── engine.py python-audio-separator wrapper (lazy import)
118
+ └── data/ uploads, outputs, model weights
119
+ ```
120
+
121
+ - **REST API**: `POST /api/jobs` (upload + model), `GET /api/jobs/{id}` (status),
122
+ `GET /api/jobs/{id}/files/{name}` (stream stem), `DELETE /api/jobs/{id}`.
123
+ - **Engines**: models run via `python-audio-separator`, which supports UVR's
124
+ MDX-Net/VR models, Demucs, and Mel-Band RoFormers.
125
+
126
+ ## Credits
127
+
128
+ This project exists because of the people who built the models and the
129
+ original app:
130
+
131
+ - **Anjok07 & aufr33** — [Ultimate Vocal Remover GUI](https://github.com/Anjok07/ultimatevocalremovergui),
132
+ the project that made high-quality local stem separation accessible.
133
+ - **nomadkaraoke** — [python-audio-separator](https://github.com/nomadkaraoke/python-audio-separator),
134
+ the maintained headless engine VoxSplit wraps.
135
+ - **ZFTurbo** — MDX23C weights and [Music-Source-Separation-Training](https://github.com/ZFTurbo/Music-Source-Separation-Training).
136
+ - **Kuielab & Woosung Choi** — original MDX-Net AI code.
137
+ - **tsurumeso** — original VR architecture code.
138
+ - **Alexandre Défossez & Meta (Demucs)** — Demucs AI code.
139
+ - **Bas Curtiz** — original UVR logo/icon design.
140
+
141
+ ## Deployment (NVIDIA)
142
+
143
+ On a Linux box with NVIDIA GPUs and Docker + nvidia-container-toolkit:
144
+
145
+ ```bash
146
+ cd deploy
147
+ docker compose up -d --build
148
+ ```
149
+
150
+ Then browse to `http://<box-ip>:8399`. Model weights persist in `deploy/data/`.
151
+
152
+ ## Roadmap
153
+
154
+ - [x] Per-job progress reporting (tqdm hooks on torch models)
155
+ - [x] Mel-Band RoFormer models (current SOTA)
156
+ - [x] Apple Silicon MPS acceleration (automatic for torch models)
157
+ - [ ] Ensemble mode + post-processing (from UVR's playbook)
158
+ - [ ] Optional Tauri desktop wrapper
159
+
160
+ ## License
161
+
162
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ backend/engine.py,sha256=MXcFCZVnGl_81TOmlD4VuYpg6fA1jxrg_Z4wOOaEzGA,3700
3
+ backend/jobs.py,sha256=OS0VUPB3xtXpfvrZtKC9qh63PvoRhohBUKXtkkQMxbE,2962
4
+ backend/main.py,sha256=Hnzsoan_C-aFld96HAZpRA-eSos6kBlgZDoyPBeDSmc,4032
5
+ backend/frontend/app.js,sha256=hAcI03-ekYT_I-5WkviUPoUuXx14x82rQ1c2_1IBui8,5606
6
+ backend/frontend/index.html,sha256=wty21_JYun85CjCGl2z8C_qJaqgfLM247u_NtK9O1Ic,1569
7
+ backend/frontend/style.css,sha256=I82d0tLGkvkwugLougbrok2CGHTtNwtI2e-A3Zm2x6o,3827
8
+ voxsplit-0.1.0.dist-info/licenses/LICENSE,sha256=tP28nznO05HKBQN8uZ9NSvmcpzCWMj4P8e6F7IDj24Q,1070
9
+ voxsplit-0.1.0.dist-info/METADATA,sha256=htAnLklKxWH9SqjqyahkU0KYskTXa5SWHx0QyOsxcAE,6710
10
+ voxsplit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ voxsplit-0.1.0.dist-info/entry_points.txt,sha256=j3NUBDCmkmjaEq9I547OXLHUEKzRaPYrkWqfkqUrBOA,47
12
+ voxsplit-0.1.0.dist-info/top_level.txt,sha256=o0r_qMzzBDDtK9APrv2QVcIw9oc00JRmCEsTE2-AFr0,8
13
+ voxsplit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ voxsplit = backend.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Borck
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 @@
1
+ backend