Rename everything to Stemify, and add a README

- Python package stemroller/ -> stemify/, Android package com.stemroller.mobile ->
  com.stemify.mobile, app names, QML module, icons, desktop entries, MPRIS and Plex
  product names
- Done-markers are now .stemify.json; stemd renames the old ones once at startup so
  songs separated before the rename aren't separated again
- A top-level README covering the server, the Android and Android Auto app, the EQ,
  Stem Now and the desktop apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClericalErrorandClaude Opus 5.5 committed 2026-10-14 21:24:56 -07:00
1 parent 1845f03c41
commit 45bc0eefe0
159 files changed
+1136 -542

No files matched your search

+542
View File
@@ -0,0 +1,542 @@
"""App controller: queue, dispatch to the worker, cache, export. Exposed to QML as `app`."""
import hashlib
import json
import os
import shutil
import threading
import time
import uuid
import numpy as np
import soundfile as sf
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QDesktopServices
from .catalog import MODELS, MODELS_BY_ID, STEM_COLORS, sort_stems
from .jobs_model import Job, JobsModel
from .settings import Settings
from .worker_client import WorkerClient
CACHE_ROOT = os.path.join(os.path.expanduser("~/.cache/stemify"), "stems")
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".aif", ".aiff",
".wma", ".mp4", ".mkv", ".webm", ".mka", ".alac", ".wv", ".ape"}
def _cpu_name():
try:
with open("/proc/cpuinfo") as f:
for line in f:
if line.startswith("model name"):
return line.split(":", 1)[1].strip().replace(" 8-Core Processor", "")
except OSError:
pass
return "CPU"
def _fmt_mb(mb):
return f"{mb / 1024:.1f} GB"
def _dir_size(path):
total = 0
for root, _, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
class Controller(QObject):
selectedChanged = Signal()
devicesChanged = Signal()
vramChanged = Signal()
workerStateChanged = Signal()
cacheChanged = Signal()
toast = Signal(str, str, str) # message, kind (info|success|error), folder to open ("" = none)
_exportDone = Signal(str, str, str)
_cacheSized = Signal(int)
def __init__(self, audio, peak_store, parent=None):
super().__init__(parent)
self.settings = Settings(self)
self.jobs = JobsModel(self)
self.audio = audio
self.peak_store = peak_store
self._selected_id = ""
self._set_running("")
self._devices = [{"id": "auto", "label": "Auto", "sub": "Starting engine…"},
{"id": "cpu", "label": "CPU", "sub": _cpu_name()}]
self._gpus = []
self._vram = {} # device -> (free_mb, total_mb)
self._cache_bytes = 0
self._exporting = False
self.worker = WorkerClient(self)
self.worker.stateChanged.connect(lambda _: (self.workerStateChanged.emit(), self._dispatch()))
self.worker.hello.connect(self._on_hello)
self.worker.vram.connect(self._on_vram)
self.worker.progress.connect(self._on_progress)
self.worker.done.connect(self._on_done)
self.worker.failed.connect(self._on_failed)
self.worker.cancelled.connect(self._on_cancelled)
self.worker.crashed.connect(self._on_crashed)
self._exportDone.connect(self._on_export_done)
self._cacheSized.connect(self._on_cache_sized)
self.settings.deviceIdChanged.connect(self.vramChanged)
self.settings.modelIdChanged.connect(self.selectedChanged)
self.settings.twoStemsChanged.connect(self.selectedChanged)
self.settings.outputModeChanged.connect(self.selectedChanged)
self.settings.stemsDirChanged.connect(self.selectedChanged)
self.audio.loadedChanged.connect(self.selectedChanged)
self.audio.loadFailed.connect(lambda m: self.toast.emit(f"Could not load stems: {m}", "error", ""))
self.audio.setVolume(self.settings.get("masterVolume"))
self.worker.start()
self.refreshCacheSize()
# ---- worker events -------------------------------------------------
def _on_hello(self, msg):
self._gpus = msg["devices"]
devs = []
if self._gpus:
best = max(self._gpus, key=lambda d: d["total_mb"])
devs.append({"id": "auto", "label": "Auto", "sub": best["name"]})
for d in self._gpus:
devs.append({"id": d["id"], "label": d["name"].replace("AMD ", ""),
"sub": f"{d['arch']} · {_fmt_mb(d['total_mb'])}"})
else:
devs.append({"id": "auto", "label": "Auto", "sub": "No GPU found, using CPU"})
devs.append({"id": "cpu", "label": "CPU", "sub": _cpu_name()})
self._devices = devs
self.devicesChanged.emit()
self.selectedChanged.emit()
def _on_vram(self, device, free_mb, total_mb):
self._vram[device] = (free_mb, total_mb)
if device == self._effective_gpu():
self.vramChanged.emit()
def _on_progress(self, job_id, pct, stage):
job = self.jobs.get(job_id)
if job and job.status == "running":
self.jobs.update(job_id, progress=max(job.progress, pct), stage=stage)
if job_id == self._selected_id:
self.selectedChanged.emit()
def _on_done(self, job_id, result):
job = self.jobs.get(job_id)
self._set_running("")
if job:
self.jobs.update(job_id, status="done", progress=1.0, stage="Done", result=result,
elapsed=time.monotonic() - job.started)
if job_id == self._selected_id:
self._load_selected()
self.selectedChanged.emit()
if self.settings.get("autoExport"):
self._export(job, [s["name"] for s in result["stems"]], mix=False, auto=True)
self._prune_cache()
self._dispatch()
def _on_failed(self, job_id, msg):
if job_id == self._running_id:
self._set_running("")
job = self.jobs.get(job_id)
if job:
self.jobs.update(job_id, status="error", stage="Failed", message=msg)
if job_id == self._selected_id:
self.selectedChanged.emit()
self.toast.emit(f"{job.file_name}: {msg}", "error", "")
self._dispatch()
def _on_cancelled(self, job_id):
if job_id == self._running_id:
self._set_running("")
if self.jobs.get(job_id):
self.jobs.update(job_id, status="cancelled", stage="Cancelled", progress=0.0)
if job_id == self._selected_id:
self.selectedChanged.emit()
self._dispatch()
def _on_crashed(self):
if self._running_id:
self._on_failed(self._running_id, "The separation engine crashed; restarting it.")
def _set_running(self, job_id):
self._running_id = job_id
self.workerStateChanged.emit()
# ---- queue ---------------------------------------------------------
def _cache_dir(self, path, model_id, two_stems):
st = os.stat(path)
key = hashlib.sha1(f"{os.path.realpath(path)}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
return os.path.join(CACHE_ROOT, f"{key}_{model_id}{'_2s' if two_stems else ''}")
def _enqueue(self, path):
model_id = self.settings.get("modelId")
if model_id not in MODELS_BY_ID:
model_id = "htdemucs"
two = self.settings.get("twoStems") and "vocals" in MODELS_BY_ID[model_id]["stems"] \
and len(MODELS_BY_ID[model_id]["stems"]) > 2
cache_dir = self._cache_dir(path, model_id, two)
job = Job(id=uuid.uuid4().hex[:12], file=path, file_name=os.path.basename(path),
model_id=model_id, model_label=MODELS_BY_ID[model_id]["label"] + (" · 2-stem" if two else ""),
two_stems=two, device_id=self.settings.get("deviceId"), cache_dir=cache_dir)
meta_path = os.path.join(cache_dir, "meta.json")
if os.path.exists(meta_path):
try:
with open(meta_path) as f:
meta = json.load(f)
if all(os.path.exists(s["path"]) for s in meta["stems"]):
os.utime(cache_dir) # LRU touch
job.status, job.progress, job.stage = "done", 1.0, "Done (cached)"
job.result = {k: meta[k] for k in ("stems", "peaks_path", "samplerate", "duration")}
job.result["device"] = meta.get("device", "")
except (OSError, ValueError, KeyError):
pass
self.jobs.add(job)
return job
@Slot("QVariantList")
def addUrls(self, urls):
paths = []
for u in urls:
if isinstance(u, QUrl):
p = u.toLocalFile()
else:
p = QUrl(u).toLocalFile() if str(u).startswith("file:") else str(u)
if os.path.isdir(p):
for root, _, files in os.walk(p):
paths += [os.path.join(root, f) for f in sorted(files)]
else:
paths.append(p)
paths = [p for p in paths if os.path.splitext(p)[1].lower() in AUDIO_EXTS and os.path.isfile(p)]
if not paths:
self.toast.emit("No audio files in that drop.", "info", "")
return
first = None
for p in paths:
job = self._enqueue(p)
first = first or job
if not self._selected_id or not self.jobs.get(self._selected_id) or len(paths) == 1:
self.select(first.id)
self._dispatch()
def _dispatch(self):
if self._running_id or self.worker.state != "ready":
return
job = next((j for j in self.jobs.jobs if j.status == "queued"), None)
if not job:
return
s = self.settings
ok = self.worker.send("start", id=job.id, file=job.file, model=job.model_id, device=job.device_id,
two_stems=job.two_stems, out_dir=job.cache_dir, segment=s.get("segment"),
shifts=s.get("shifts"), overlap=s.get("overlap"), fp16=s.get("fp16"))
if ok:
self._set_running(job.id)
self.jobs.update(job.id, status="running", stage="Starting", progress=0.0, message="",
started=time.monotonic())
if job.id == self._selected_id:
self.selectedChanged.emit()
@Slot(str)
def cancelJob(self, job_id):
job = self.jobs.get(job_id)
if not job:
return
if job.status == "running":
self.jobs.update(job_id, stage="Cancelling")
self.worker.send("cancel", id=job_id)
elif job.status == "queued":
self.jobs.update(job_id, status="cancelled", stage="Cancelled")
if job_id == self._selected_id:
self.selectedChanged.emit()
@Slot(str)
def removeJob(self, job_id):
job = self.jobs.get(job_id)
if not job:
return
if job.status == "running":
self.cancelJob(job_id)
row = self.jobs.row_of(job_id)
self.jobs.remove(job_id)
self.peak_store.drop(job_id)
if job_id == self._selected_id:
nxt = self.jobs.jobs[min(row, len(self.jobs.jobs) - 1)].id if self.jobs.jobs else ""
self.select(nxt)
@Slot(str)
def retryJob(self, job_id):
job = self.jobs.get(job_id)
if job and job.status in ("error", "cancelled"):
self.jobs.update(job_id, status="queued", stage="Waiting", progress=0.0, message="")
if job_id == self._selected_id:
self.selectedChanged.emit()
self._dispatch()
@Slot()
def rerunSelected(self):
job = self.jobs.get(self._selected_id)
if job:
new = self._enqueue(job.file)
self.select(new.id)
self._dispatch()
@Slot(str)
def select(self, job_id):
if job_id == self._selected_id:
return
self._selected_id = job_id
self._load_selected()
self.selectedChanged.emit()
def _load_selected(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
stems = sort_stems([s["name"] for s in job.result["stems"]])
by_name = {s["name"]: s["path"] for s in job.result["stems"]}
self.audio.load(job.id, [(n, by_name[n]) for n in stems])
else:
self.audio.unload()
# ---- export --------------------------------------------------------
def _target_dir(self, job):
base = os.path.splitext(job.file_name)[0]
if self.settings.get("outputMode") == "folder":
return os.path.join(self.settings.get("stemsDir"), base)
return os.path.join(os.path.dirname(job.file), f"{base} - stems")
def _export(self, job, names, mix, auto=False):
if self._exporting:
self.toast.emit("An export is already running.", "info", "")
return
self._exporting = True
fmt = self.settings.get("exportFormat")
target = self._target_dir(job)
base = os.path.splitext(job.file_name)[0]
loaded = self.audio.loaded_key() == job.id and self.audio.loaded
if loaded:
arrays, sr = self.audio.arrays()
gains = {n: self.audio.stem_gain(n) for n in arrays}
mix_gains = dict(zip(arrays.keys(), self.audio.mix_gains()))
else:
arrays, sr, gains, mix_gains = None, job.result["samplerate"], {}, {}
paths = {s["name"]: s["path"] for s in job.result["stems"]}
def read(name):
if arrays is not None and name in arrays:
return arrays[name]
data, _ = sf.read(paths[name], dtype="float32", always_2d=True)
return data
def write(path, data):
if fmt == "mp3":
sf.write(path, np.clip(data, -1, 1), sr, format="MP3", subtype="MPEG_LAYER_III",
bitrate_mode="CONSTANT", compression_level=0.0)
elif fmt == "flac":
sf.write(path, np.clip(data, -1, 1), sr, format="FLAC", subtype="PCM_24")
else:
subtype = "FLOAT" if np.abs(data).max() > 0.999 else "PCM_24"
sf.write(path, data, sr, format="WAV", subtype=subtype)
def work():
try:
os.makedirs(target, exist_ok=True)
if mix:
out = None
for n in paths:
g = mix_gains.get(n, 1.0)
if g:
d = read(n) * g
out = d if out is None else out + d
write(os.path.join(target, f"{base} - mix.{fmt}"), out if out is not None else read(next(iter(paths))) * 0)
msg = "Exported the current mix"
else:
for n in names:
g = gains.get(n, 1.0)
d = read(n)
write(os.path.join(target, f"{base} - {n}.{fmt}"), d * g if g != 1.0 else d)
msg = f"Exported {len(names)} stem{'s' if len(names) != 1 else ''}" if not auto else \
f"Auto-exported {len(names)} stems"
self._exportDone.emit(job.id, f"{msg} to {self._pretty(target)}", target)
except Exception as e:
self._exportDone.emit(job.id, f"Export failed: {e}", "")
threading.Thread(target=work, daemon=True).start()
def _on_export_done(self, job_id, msg, target):
self._exporting = False
if target and self.jobs.get(job_id):
self.jobs.update(job_id, exported_to=target)
if job_id == self._selected_id:
self.selectedChanged.emit()
self.toast.emit(msg, "success" if target else "error", target)
@staticmethod
def _pretty(path):
home = os.path.expanduser("~")
return "~" + path[len(home):] if path.startswith(home) else path
@Slot()
def exportAll(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, sort_stems([s["name"] for s in job.result["stems"]]), mix=False)
@Slot(str)
def exportStem(self, name):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, [name], mix=False)
@Slot()
def exportMix(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, [], mix=True)
@Slot(str)
def openFolder(self, path):
if path and os.path.isdir(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
@Slot()
def openTargetFolder(self):
job = self.jobs.get(self._selected_id)
if job:
t = self._target_dir(job)
self.openFolder(t if os.path.isdir(t) else os.path.dirname(t))
# ---- cache ---------------------------------------------------------
def _referenced(self):
return {os.path.realpath(j.cache_dir) for j in self.jobs.jobs}
def _prune_cache(self):
limit = self.settings.get("cacheLimitGb") * 2**30
refs = self._referenced()
def work():
if not os.path.isdir(CACHE_ROOT):
self._cacheSized.emit(0)
return
entries = []
for d in os.listdir(CACHE_ROOT):
p = os.path.join(CACHE_ROOT, d)
entries.append((os.path.getmtime(p), _dir_size(p), p))
total = sum(e[1] for e in entries)
for _, size, p in sorted(entries):
if total <= limit:
break
if os.path.realpath(p) not in refs:
shutil.rmtree(p, ignore_errors=True)
total -= size
self._cacheSized.emit(total)
threading.Thread(target=work, daemon=True).start()
@Slot()
def refreshCacheSize(self):
threading.Thread(target=lambda: self._cacheSized.emit(_dir_size(CACHE_ROOT) if os.path.isdir(CACHE_ROOT) else 0),
daemon=True).start()
@Slot()
def clearCache(self):
refs = self._referenced()
if os.path.isdir(CACHE_ROOT):
for d in os.listdir(CACHE_ROOT):
p = os.path.join(CACHE_ROOT, d)
if os.path.realpath(p) not in refs:
shutil.rmtree(p, ignore_errors=True)
self.refreshCacheSize()
self.toast.emit("Cleared cached stems not in the queue.", "info", "")
def _on_cache_sized(self, n):
self._cache_bytes = n
self.cacheChanged.emit()
# ---- QML-facing properties -----------------------------------------
def _effective_gpu(self):
dev = self.settings.get("deviceId")
if dev == "cpu" or not self._gpus:
return ""
if dev == "auto" or not any(g["id"] == dev for g in self._gpus):
return max(self._gpus, key=lambda d: d["total_mb"])["id"]
return dev
def peaks_path_for(self, job_id):
job = self.jobs.get(job_id)
return job.result.get("peaks_path") if job and job.status == "done" else None
def _get_selected(self):
job = self.jobs.get(self._selected_id)
if not job:
return {"valid": False}
stems = []
if job.status == "done":
stems = [{"name": n, "color": STEM_COLORS.get(n, "#9B9DA3")}
for n in sort_stems([s["name"] for s in job.result["stems"]])]
dev = job.result.get("device", "") if job.result else ""
dev_label = "CPU" if dev == "cpu" else next((g["name"].replace("AMD ", "") for g in self._gpus if g["id"] == dev), dev)
return {
"valid": True, "id": job.id, "fileName": job.file_name, "filePath": job.file,
"folder": self._pretty(os.path.dirname(job.file)),
"modelId": job.model_id, "modelLabel": job.model_label, "status": job.status,
"progress": job.progress, "stage": job.stage, "message": job.message,
"elapsed": job.elapsed, "duration": job.result.get("duration", 0.0) if job.result else 0.0,
"deviceLabel": dev_label, "stems": stems,
"audioReady": self.audio.loaded and self.audio.loaded_key() == job.id,
"target": self._pretty(self._target_dir(job)), "exportedTo": job.exported_to,
"rerunLabel": self._rerun_label(job),
}
def _rerun_label(self, job):
m = self.settings.get("modelId")
two = self.settings.get("twoStems") and len(MODELS_BY_ID.get(m, {"stems": []})["stems"]) > 2
if m == job.model_id and two == job.two_stems:
return ""
return MODELS_BY_ID[m]["label"] + (" · 2-stem" if two else "")
def _get_models(self):
return [{"id": m["id"], "label": m["label"], "note": m["note"], "family": m["family"],
"stems": len(m["stems"])} for m in MODELS]
def _get_devices(self):
return self._devices
def _get_vram(self):
gpu = self._effective_gpu()
if not gpu or gpu not in self._vram:
return {"valid": False}
free, total = self._vram[gpu]
return {"valid": True, "free": free, "total": total,
"text": f"{_fmt_mb(free)} free of {_fmt_mb(total)}"}
def _get_worker_state(self):
if self.worker.state == "ready" and self._running_id:
return "busy"
return self.worker.state
def _get_cache_text(self):
return f"{self._cache_bytes / 2**30:.2f} GB"
def _get_settings(self):
return self.settings
def _get_jobs(self):
return self.jobs
selected = Property("QVariantMap", _get_selected, notify=selectedChanged)
models = Property("QVariantList", _get_models, constant=True)
devices = Property("QVariantList", _get_devices, notify=devicesChanged)
vram = Property("QVariantMap", _get_vram, notify=vramChanged)
workerState = Property(str, _get_worker_state, notify=workerStateChanged)
cacheText = Property(str, _get_cache_text, notify=cacheChanged)
settingsObj = Property(QObject, _get_settings, constant=True)
jobsModel = Property(QObject, _get_jobs, constant=True)
def shutdown(self):
self.settings.masterVolume = self.audio.volume
self.audio.unload()
self.worker.shutdown()