StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems - android/: Plex music player that plays per-stem mixes, with Android Auto support, a graphic + parametric (Equalizer APO-style) EQ, and offline downloads - server/: stemserve (serves stems to the app) and stemd (separates the library on the server's GPU, with "Stem Now" priority requests) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -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/stemroller"), "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()
|
||||
Reference in new issue
Block a user