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 +0 -0
- backend/engine.py +119 -0
- backend/frontend/app.js +183 -0
- backend/frontend/index.html +45 -0
- backend/frontend/style.css +175 -0
- backend/jobs.py +97 -0
- backend/main.py +121 -0
- voxsplit-0.1.0.dist-info/METADATA +162 -0
- voxsplit-0.1.0.dist-info/RECORD +13 -0
- voxsplit-0.1.0.dist-info/WHEEL +5 -0
- voxsplit-0.1.0.dist-info/entry_points.txt +2 -0
- voxsplit-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxsplit-0.1.0.dist-info/top_level.txt +1 -0
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]
|
backend/frontend/app.js
ADDED
|
@@ -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
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
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,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
|