From 7216f43f9a80dcab541880311686d08796e1b015 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 12:25:54 +0100 Subject: [PATCH 1/9] fix(backend): start on CPU when torch is installed but cannot load available_torch_devices() caught only ImportError. A torch whose DLLs fail to load raises OSError instead (WinError 127 from a CUDA DLL left behind on Windows, #723), which killed startup when the device setting was "auto" and made every GET /api/settings a 500 otherwise. The probe now catches any failure, falls back to ["cpu"], logs the cause once with its traceback, and remembers it. /api/settings carries it as torch_error, and Settings replaces the device description with a warning in red: separation cannot run on any device until torch is repaired, CPU included, so a quiet fallback would hide the real state. tests/test_torch_unloadable.py installs an import hook that makes `import torch` raise that OSError; the probe and settings tests fail on the old code. Closes #730 --- app/core/config.py | 25 ++++++++++ app/main.py | 4 ++ static/css/daw.css | 2 + static/js/catalog.js | 8 +++- static/js/i18n.js | 10 ++++ tests/test_torch_unloadable.py | 87 ++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/test_torch_unloadable.py diff --git a/app/core/config.py b/app/core/config.py index 104f67cb..63ebf7c4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -28,11 +28,23 @@ def _env_path_opt(name: str) -> Path | None: return Path(raw).expanduser().resolve() if raw else None +# Why torch could not be loaded at the last probe, or None if it loaded (#730). +# Refreshed on every probe rather than decided once, so a repaired install +# clears it without a restart. +_torch_load_error: str | None = None + + +def torch_load_error() -> str | None: + """The last probe's reason torch is installed but unusable, if any.""" + return _torch_load_error + + def available_torch_devices() -> list[str]: """Compute devices this machine can actually use, best-first. CPU is always present; cuda/mps depend on the hardware + installed torch build. The Settings UI uses this to disable options that aren't available/detected so a user can't pick an impossible device.""" + global _torch_load_error devices: list[str] = [] try: import torch @@ -41,8 +53,21 @@ def available_torch_devices() -> list[str]: devices.append("cuda") if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): devices.append("mps") + _torch_load_error = None except ImportError: pass + except Exception as exc: # noqa: BLE001 -- any failure here must not stop the server + # Installed but unloadable: on Windows a CUDA DLL left behind by a + # half-reverted install raises OSError (WinError 127) from torch's own + # DLL loader (#723). Catching only ImportError let that kill startup + # and every /api/settings call. The server has to come up so it can + # say what is wrong. Logged loudly once per distinct cause, because + # separation cannot run on any device until torch is repaired, CPU + # included, and a quiet fallback would hide exactly that. + reason = f"{type(exc).__name__}: {exc}" + if reason != _torch_load_error: + logger.error("torch is installed but could not be loaded: %s", reason, exc_info=True) + _torch_load_error = reason devices.append("cpu") return devices diff --git a/app/main.py b/app/main.py index 2035d665..4d5fde4f 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ available_torch_devices, configure_portable_environment, ensure_runtime_dirs, + torch_load_error, ) from app.core.logging_setup import configure_logging from app.core.process import process_exists as _process_exists @@ -393,6 +394,9 @@ def _settings_payload() -> dict[str, object]: "demucs_device": get_demucs_device_choice(), "demucs_device_resolved": get_demucs_device(), "demucs_devices_available": available_torch_devices(), + # Set when torch is installed but cannot load, which stops separation + # on every device (#730). Read after the probe above refreshed it. + "torch_error": torch_load_error(), } diff --git a/static/css/daw.css b/static/css/daw.css index 8370923a..b945da7e 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -996,6 +996,8 @@ input, textarea { font-family: inherit; } .folder-editor-colors { display: flex; align-items: center; gap: 9px; } .folder-editor-colors .folder-color-dot { width: 22px; height: 22px; } .folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; } +/* The separation engine cannot load at all (#730). */ +.set-demucs-desc.is-broken { color: var(--danger); } .folder-editor-msg:empty { display: none; } .folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; } diff --git a/static/js/catalog.js b/static/js/catalog.js index bf2c0c70..2faad25e 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -4006,7 +4006,13 @@ async function wireGeneralSettings(overlay) { } if (deviceDesc) { const resolved = d.demucs_device_resolved ? i18nT("settings.device.currently", { device: d.demucs_device_resolved }) : ""; - deviceDesc.textContent = i18nT("settings.device.desc", { resolved }); + // torch installed but unloadable stops separation on every device, CPU + // included, so this replaces the usual line rather than sitting under a + // device list that looks healthy (#730). + deviceDesc.textContent = d.torch_error + ? i18nT("settings.device.torchBroken") + : i18nT("settings.device.desc", { resolved }); + deviceDesc.classList.toggle("is-broken", !!d.torch_error); } }; diff --git a/static/js/i18n.js b/static/js/i18n.js index 35f8785b..3428135d 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -763,6 +763,7 @@ const en = { "settings.device.cpu": "CPU", "settings.device.currently": " (currently: {device})", "settings.device.notAvailable": " — not available", + "settings.device.torchBroken": "The separation engine could not start on this computer, so tracks cannot be separated on any device. Reinstalling StemDeck repairs it. The reason is in the log.", "settings.quality.title": "Separation quality", "settings.quality.desc": "Best runs the separator twice with randomized shifts and averages the result — cleaner stems, twice the time.", "settings.quality.standard": "Standard", @@ -1560,6 +1561,7 @@ const pl = { "settings.device.cpu": "CPU", "settings.device.currently": " (obecnie: {device})", "settings.device.notAvailable": " — niedostępne", + "settings.device.torchBroken": "Silnik rozdzielania nie mógł się uruchomić na tym komputerze, więc utworów nie da się rozdzielić na żadnym urządzeniu. Ponowna instalacja StemDeck to naprawia. Przyczyna jest w logu.", "settings.quality.title": "Jakość rozdzielania", "settings.quality.desc": "Best uruchamia separator dwukrotnie z losowymi przesunięciami i uśrednia wynik — czystsze ścieżki, dwa razy dłużej.", "settings.quality.standard": "Standardowa", @@ -2343,6 +2345,7 @@ const ja = { "settings.device.cpu": "CPU", "settings.device.currently": " (現在: {device})", "settings.device.notAvailable": " — 利用不可", + "settings.device.torchBroken": "分離エンジンがこのコンピューターで起動できなかったため、どのデバイスでも曲を分離できません。StemDeck を再インストールすると修復されます。原因はログに記録されています。", "settings.quality.title": "分離品質", "settings.quality.desc": "Bestはランダムなシフトで分離を2回実行し結果を平均化します — よりクリーンなパートになりますが時間は2倍。", "settings.quality.standard": "標準", @@ -3106,6 +3109,7 @@ const zhHans = { "settings.device.cpu": "CPU", "settings.device.currently": "(当前:{device})", "settings.device.notAvailable": " — 不可用", + "settings.device.torchBroken": "分离引擎无法在这台电脑上启动,因此在任何设备上都无法分离曲目。重新安装 StemDeck 即可修复。原因已写入日志。", "settings.quality.title": "分离质量", "settings.quality.desc": "Best 会以随机偏移运行分离器两次并取平均结果 — 音轨更干净,但耗时翻倍。", "settings.quality.standard": "标准", @@ -3872,6 +3876,7 @@ const de = { "settings.device.cpu": "CPU", "settings.device.currently": " (aktuell: {device})", "settings.device.notAvailable": " — nicht verfügbar", + "settings.device.torchBroken": "Die Trenn-Engine konnte auf diesem Computer nicht starten, daher lassen sich Titel auf keinem Gerät trennen. Eine Neuinstallation von StemDeck behebt das. Der Grund steht im Protokoll.", "settings.quality.title": "Trennqualität", "settings.quality.desc": "„Best“ führt die Trennung zweimal mit zufälligen Verschiebungen aus und mittelt das Ergebnis — sauberere Stems, doppelte Zeit.", "settings.quality.standard": "Standard", @@ -4646,6 +4651,7 @@ const pt = { "settings.device.cpu": "CPU", "settings.device.currently": " (atualmente: {device})", "settings.device.notAvailable": " — indisponível", + "settings.device.torchBroken": "O mecanismo de separação não conseguiu iniciar neste computador, então nenhuma faixa pode ser separada em nenhum dispositivo. Reinstalar o StemDeck corrige isso. O motivo está no log.", "settings.quality.title": "Qualidade de separação", "settings.quality.desc": "\"Best\" executa o separador duas vezes com deslocamentos aleatórios e calcula a média do resultado — stems mais limpos, o dobro do tempo.", "settings.quality.standard": "Padrão", @@ -5419,6 +5425,7 @@ const id = { "settings.device.cpu": "CPU", "settings.device.currently": " (saat ini: {device})", "settings.device.notAvailable": " — tidak tersedia", + "settings.device.torchBroken": "Mesin pemisah tidak dapat berjalan di komputer ini, jadi lagu tidak dapat dipisahkan di perangkat mana pun. Memasang ulang StemDeck akan memperbaikinya. Penyebabnya ada di log.", "settings.quality.title": "Kualitas pemisahan", "settings.quality.desc": "Terbaik menjalankan pemisah dua kali dengan pergeseran acak dan merata-ratakan hasilnya — stem lebih bersih, dua kali lebih lama.", "settings.quality.standard": "Standar", @@ -6186,6 +6193,7 @@ const fr = { "settings.device.cpu": "CPU", "settings.device.currently": " (actuellement : {device})", "settings.device.notAvailable": " — indisponible", + "settings.device.torchBroken": "Le moteur de séparation n’a pas pu démarrer sur cet ordinateur : aucun morceau ne peut être séparé, quel que soit l’appareil. Réinstaller StemDeck corrige le problème. La cause figure dans le journal.", "settings.quality.title": "Qualité de séparation", "settings.quality.desc": "« Meilleure » exécute le séparateur deux fois avec des décalages aléatoires et fait la moyenne du résultat — des pistes plus nettes, deux fois plus de temps.", "settings.quality.standard": "Standard", @@ -7132,6 +7140,7 @@ const es = { "settings.device.cpu": "CPU", "settings.device.currently": " (actualmente: {device})", "settings.device.notAvailable": " — no disponible", + "settings.device.torchBroken": "El motor de separación no pudo iniciarse en este equipo, así que no se pueden separar pistas en ningún dispositivo. Reinstalar StemDeck lo repara. El motivo está en el registro.", "settings.quality.title": "Calidad de la separación", "settings.quality.desc": "«Máxima» ejecuta el separador dos veces con desplazamientos aleatorios y promedia el resultado: stems más limpios, el doble de tiempo.", "settings.quality.standard": "Estándar", @@ -7925,6 +7934,7 @@ const ko = { "settings.device.cpu": "CPU", "settings.device.currently": " (현재: {device})", "settings.device.notAvailable": " (사용할 수 없음)", + "settings.device.torchBroken": "이 컴퓨터에서 분리 엔진을 시작할 수 없어 어떤 장치에서도 곡을 분리할 수 없습니다. StemDeck을 다시 설치하면 해결됩니다. 원인은 로그에 기록되어 있습니다.", "settings.quality.title": "분리 품질", "settings.quality.desc": "최고 품질은 분리기를 두 번 돌려 무작위로 어긋낸 결과를 평균해요. 스템이 더 깨끗해지지만 시간은 두 배 걸려요.", "settings.quality.standard": "표준", diff --git a/tests/test_torch_unloadable.py b/tests/test_torch_unloadable.py new file mode 100644 index 00000000..f1d088ff --- /dev/null +++ b/tests/test_torch_unloadable.py @@ -0,0 +1,87 @@ +"""A torch that is installed but cannot load must not take the server down (#730). + +On Windows, a CUDA DLL left behind by a half-reverted CUDA install makes torch's +own DLL loader raise OSError (WinError 127) from `import torch` (#723). The +device probe caught only ImportError, so with the device on "auto" the backend +died at startup, and with it forced to CPU every /api/settings call was a 500. + +These install an import hook that makes `import torch` raise exactly that, so +they run whether or not a real torch is present. +""" + +from __future__ import annotations + +import importlib.abc +import importlib.machinery +import sys + +import pytest +from fastapi.testclient import TestClient + +from app.core import config + +REASON = "[WinError 127] The specified procedure could not be found. Error loading c10_cuda.dll" + + +class _BrokenTorchLoader(importlib.abc.Loader): + def create_module(self, spec): + return None + + def exec_module(self, module): + raise OSError(REASON) + + +class _BrokenTorchFinder(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + if name == "torch": + return importlib.machinery.ModuleSpec(name, _BrokenTorchLoader()) + return None + + +@pytest.fixture +def broken_torch(monkeypatch): + for name in [m for m in sys.modules if m == "torch" or m.startswith("torch.")]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.setattr(sys, "meta_path", [_BrokenTorchFinder(), *sys.meta_path]) + monkeypatch.setattr(config, "_torch_load_error", None) + yield + + +def test_the_probe_falls_back_to_cpu(broken_torch): + assert config.available_torch_devices() == ["cpu"] + assert config.detect_torch_device() == "cpu" + + +def test_the_probe_says_why(broken_torch): + config.available_torch_devices() + assert config.torch_load_error() == f"OSError: {REASON}" + + +def test_the_reason_is_logged_once_per_cause(broken_torch, caplog): + with caplog.at_level("ERROR", logger="stemdeck.config"): + config.available_torch_devices() + config.available_torch_devices() + assert len([r for r in caplog.records if "could not be loaded" in r.getMessage()]) == 1 + + +def test_settings_still_answer_and_carry_the_error(broken_torch): + from app.main import app + + with TestClient(app) as c: + resp = c.get("/api/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["demucs_devices_available"] == ["cpu"] + assert body["torch_error"] == f"OSError: {REASON}" + + +def test_a_working_torch_reports_no_error(monkeypatch): + # The healthy path: whatever this machine has, a torch that imports (or is + # simply absent) leaves no error behind. + monkeypatch.setattr(config, "_torch_load_error", "OSError: stale") + try: + import torch # noqa: F401 + except ImportError: + monkeypatch.setattr(config, "_torch_load_error", None) + config.available_torch_devices() + assert config.torch_load_error() is None From e529e3a46c468b28e3d1b27cc9b6b21f664e1e56 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 12:56:16 +0100 Subject: [PATCH 2/9] fix(library): favourite a track from its library row The Now Playing heart was the only way to favourite, and daw.css hides that whole card below 1460 px. A maximised laptop window is under that, so there was no way to favourite at all, and no way to un-favourite from the Favorites view. - Library and Favorites rows get a heart next to the delete button. - Every heart goes through toggleFavorite(), which saves, repaints the Now Playing heart when it is the open track, and re-renders the list, so the hearts cannot disagree with each other or the store. - Row actions float over the row's right edge on hover or keyboard focus. In the flow the hidden delete button took about 29 px from every title at rest; a second button would have taken it again. Delete was also invisible under keyboard focus before. - A favourite shows a small heart in its subline at rest. tests/e2e/favorite-reach.spec.mjs runs at 1280 and 1600 px; all four fail on the old code. Closes #724 --- static/css/daw.css | 35 ++++++++++++--- static/js/catalog.js | 71 +++++++++++++++++++++++------ static/js/i18n.js | 40 +++++++++++++++++ tests/e2e/favorite-reach.spec.mjs | 74 +++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/favorite-reach.spec.mjs diff --git a/static/css/daw.css b/static/css/daw.css index b945da7e..4f27b334 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -782,6 +782,7 @@ input, textarea { font-family: inherit; } /* cat-item (legacy catalog items JS creates) */ .cat-item { + position: relative; display: flex; gap: 9px; align-items: center; @@ -905,14 +906,36 @@ input, textarea { font-family: inherit; } .queue-row:hover .queue-top { opacity: 0.65; } .queue-top:hover { background: rgba(232, 168, 56, 0.16); color: var(--accent); opacity: 1; } .queue-top:disabled { opacity: 0.3; cursor: default; } -.cat-del { +/* A row's actions float over its right edge and show on hover or keyboard + focus (#724). In the flow they cost the title their width on every row at + rest, which is where titles were being cut short, and a second button in + the flow would have cost it again. The fade lets a long title run under + them instead of stopping at a hard edge. */ +.cat-actions { + position: absolute; top: 50%; right: 6px; transform: translateY(-50%); + display: flex; gap: 2px; padding-left: 16px; + background: linear-gradient(to right, transparent, var(--panel-2) 14px); + opacity: 0; pointer-events: none; + transition: opacity var(--t-fast); +} +.cat-item:hover .cat-actions, +.cat-item:focus-within .cat-actions { opacity: 1; pointer-events: auto; } +.cat-del, .cat-fav { width: 20px; height: 20px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; - opacity: 0; display: flex; align-items: center; justify-content: center; - padding: 0; flex-shrink: 0; transition: opacity var(--t-fast), background var(--t-fast); + display: flex; align-items: center; justify-content: center; + padding: 0; flex-shrink: 0; transition: background var(--t-fast), color var(--t-fast); } -.cat-item:hover .cat-del { opacity: 1; } .cat-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); } +.cat-fav:hover { background: rgba(21,31,39,0.8); color: var(--fg); } +.cat-fav.active, .cat-fav.active:hover { color: #e54e4e; } +/* A favourite says so at rest, in the subline, where it takes no title room. */ +.cat-item.is-fav .cat-sub::before { + content: ""; flex-shrink: 0; align-self: center; + width: 9px; height: 9px; background: #e54e4e; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat; +} /* Folder items (legacy catalog) */ .folder { display: flex; flex-direction: column; } @@ -5433,8 +5456,8 @@ input, textarea { font-family: inherit; } * So the two things that were moved up here give way, in the order they can be * done without: the card first, since the track is also named in the library * and the mixer, then the toggles drop to a line of their own, which is where - * they lived before. Nothing is lost at any width, it just stops being one - * line. + * they lived before. The card's favourite heart is also on every library row + * (#724). Its About this song button has no second home yet (#735). */ @media (max-width: 1460px) { .daw-composer > #nowPlayingPanel { display: none; } diff --git a/static/js/catalog.js b/static/js/catalog.js index 2faad25e..a5c28321 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -894,16 +894,9 @@ function applyTrackInfoToPanel(track) { // The now-playing square shows the same format icon the library row does. paintNowPlayingArt(trackFormat(track)); if (favBtn) { - favBtn.classList.toggle("active", Boolean(track.favorite)); - favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite))); + paintFavButton(favBtn, Boolean(track.favorite)); favBtn.onclick = () => { - if (!_currentTrackId) return; - const t = tracks[_currentTrackId]; - if (!t) return; - t.favorite = !t.favorite; - favBtn.classList.toggle("active", t.favorite); - favBtn.setAttribute("aria-pressed", String(t.favorite)); - saveState(); + if (_currentTrackId) toggleFavorite(_currentTrackId); }; } @@ -1425,6 +1418,53 @@ function dropOnFolder(folderId, trackId) { render(); } +function paintFavButton(btn, on) { + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", String(on)); +} + +/** + * Flip a track's favourite, from whichever heart was pressed. + * + * The Now Playing heart used to be the only one, and it disappears with its + * card below 1460 px (#724), which left no way to favourite at all. Library + * rows have one too now, and every heart goes through here so none of them can + * disagree with the store or with each other. + */ +export function toggleFavorite(trackId) { + const track = tracks[trackId]; + if (!track) return; + track.favorite = !track.favorite; + saveState(); + if (trackId === _currentTrackId) { + const favBtn = document.getElementById("fav-btn"); + if (favBtn) paintFavButton(favBtn, track.favorite); + } + // Rows show the state at rest, and the Favorites view lists by it. + render(); +} + +function favButtonHtml(track) { + const on = Boolean(track.favorite); + const label = i18nT(on ? "track.unfavoriteTitle" : "track.favoriteTitle", { + title: track.title ?? i18nT("track.unknown"), + }); + return ``; +} + +function wireFavButton(el, trackId) { + el.querySelector(".cat-fav")?.addEventListener("click", (e) => { + // The row itself loads the track on click. + e.stopPropagation(); + toggleFavorite(trackId); + }); +} + function wireTrackDragAndLoad(el, trackId) { el.draggable = true; el.addEventListener("dragstart", (e) => { @@ -1432,7 +1472,7 @@ function wireTrackDragAndLoad(el, trackId) { }); el.addEventListener("dragend", () => endDrag(el)); el.addEventListener("click", (e) => { - if (e.target.closest(".cat-del")) return; + if (e.target.closest(".cat-del, .cat-fav")) return; loadTrackIntoStudio(trackId); }); } @@ -1635,7 +1675,7 @@ function renderRecentItem(trackId) { if (!track) return null; const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; - el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; + el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${track.favorite ? " is-fav" : ""}`; el.dataset.id = trackId; el.innerHTML = `
${thumbHtml(track)}
@@ -1644,7 +1684,9 @@ function renderRecentItem(trackId) {
${trackSublineHtml(track)}
+
${favButtonHtml(track)}
`; + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); return el; } @@ -1702,7 +1744,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; - el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; + el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${!inTrash && track.favorite ? " is-fav" : ""}`; el.dataset.id = trackId; el.innerHTML = ` @@ -1712,12 +1754,12 @@ function renderTrackItem(trackId, { inTrash = false } = {}) {
${trackSublineHtml(track, { inTrash })}
- ${inTrash ? "" : ``} + `} `; el.querySelector(".cat-del")?.setAttribute("aria-label", i18nT("track.moveTitleToTrash", { title: track.title ?? i18nT("track.unknown") })); @@ -1725,6 +1767,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { e.stopPropagation(); moveTrackToTrash(trackId); }); + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); diff --git a/static/js/i18n.js b/static/js/i18n.js index 3428135d..44380b3c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -803,6 +803,10 @@ const en = { "track.removed": "Removed", "track.moveToTrash": "Move to Trash", "track.moveTitleToTrash": "Move {title} to Trash", + "track.favorite": "Add to Favorites", + "track.unfavorite": "Remove from Favorites", + "track.favoriteTitle": "Add {title} to Favorites", + "track.unfavoriteTitle": "Remove {title} from Favorites", "folder.dragToReorder": "Drag to reorder", "folder.newSubfolder": "New subfolder", "folder.deleteFolder": "Delete folder", @@ -1604,6 +1608,10 @@ const pl = { "track.removed": "Usunięto", "track.moveToTrash": "Przenieś do kosza", "track.moveTitleToTrash": "Przenieś {title} do kosza", + "track.favorite": "Dodaj do ulubionych", + "track.unfavorite": "Usuń z ulubionych", + "track.favoriteTitle": "Dodaj {title} do ulubionych", + "track.unfavoriteTitle": "Usuń {title} z ulubionych", "folder.dragToReorder": "Przeciągnij, aby zmienić kolejność", "folder.newSubfolder": "Nowy podfolder", "folder.deleteFolder": "Usuń folder", @@ -2384,6 +2392,10 @@ const ja = { "track.removed": "削除済み", "track.moveToTrash": "ゴミ箱に移動", "track.moveTitleToTrash": "{title}をゴミ箱に移動", + "track.favorite": "お気に入りに追加", + "track.unfavorite": "お気に入りから削除", + "track.favoriteTitle": "{title}をお気に入りに追加", + "track.unfavoriteTitle": "{title}をお気に入りから削除", "folder.dragToReorder": "ドラッグして並べ替え", "folder.newSubfolder": "新しいサブフォルダ", "folder.deleteFolder": "フォルダを削除", @@ -3148,6 +3160,10 @@ const zhHans = { "track.removed": "已移除", "track.moveToTrash": "移到回收站", "track.moveTitleToTrash": "将 {title} 移到回收站", + "track.favorite": "添加到收藏", + "track.unfavorite": "从收藏中移除", + "track.favoriteTitle": "将 {title} 添加到收藏", + "track.unfavoriteTitle": "将 {title} 从收藏中移除", "folder.dragToReorder": "拖动以重新排序", "folder.newSubfolder": "新建子文件夹", "folder.deleteFolder": "删除文件夹", @@ -3917,6 +3933,10 @@ const de = { "track.removed": "Entfernt", "track.moveToTrash": "In den Papierkorb verschieben", "track.moveTitleToTrash": "{title} in den Papierkorb verschieben", + "track.favorite": "Zu Favoriten hinzufügen", + "track.unfavorite": "Aus Favoriten entfernen", + "track.favoriteTitle": "{title} zu Favoriten hinzufügen", + "track.unfavoriteTitle": "{title} aus Favoriten entfernen", "folder.dragToReorder": "Ziehen, um die Reihenfolge zu ändern", "folder.newSubfolder": "Neuer Unterordner", "folder.deleteFolder": "Ordner löschen", @@ -4692,6 +4712,10 @@ const pt = { "track.removed": "Removido", "track.moveToTrash": "Mover para a lixeira", "track.moveTitleToTrash": "Mover {title} para a lixeira", + "track.favorite": "Adicionar aos favoritos", + "track.unfavorite": "Remover dos favoritos", + "track.favoriteTitle": "Adicionar {title} aos favoritos", + "track.unfavoriteTitle": "Remover {title} dos favoritos", "folder.dragToReorder": "Arraste para reordenar", "folder.newSubfolder": "Nova subpasta", "folder.deleteFolder": "Excluir pasta", @@ -5464,6 +5488,10 @@ const id = { "track.removed": "Dihapus", "track.moveToTrash": "Pindahkan ke Sampah", "track.moveTitleToTrash": "Pindahkan {title} ke Sampah", + "track.favorite": "Tambahkan ke favorit", + "track.unfavorite": "Hapus dari favorit", + "track.favoriteTitle": "Tambahkan {title} ke favorit", + "track.unfavoriteTitle": "Hapus {title} dari favorit", "folder.dragToReorder": "Seret untuk mengatur ulang", "folder.newSubfolder": "Subfolder baru", "folder.deleteFolder": "Hapus folder", @@ -6233,6 +6261,10 @@ const fr = { "track.removed": "Supprimé", "track.moveToTrash": "Mettre à la corbeille", "track.moveTitleToTrash": "Mettre {title} à la corbeille", + "track.favorite": "Ajouter aux favoris", + "track.unfavorite": "Retirer des favoris", + "track.favoriteTitle": "Ajouter {title} aux favoris", + "track.unfavoriteTitle": "Retirer {title} des favoris", "folder.dragToReorder": "Glisser pour réorganiser", "folder.newSubfolder": "Nouveau sous-dossier", "folder.deleteFolder": "Supprimer le dossier", @@ -7181,6 +7213,10 @@ const es = { "track.removed": "Eliminada", "track.moveToTrash": "Mover a la papelera", "track.moveTitleToTrash": "Mover {title} a la papelera", + "track.favorite": "Añadir a favoritos", + "track.unfavorite": "Quitar de favoritos", + "track.favoriteTitle": "Añadir {title} a favoritos", + "track.unfavoriteTitle": "Quitar {title} de favoritos", "folder.dragToReorder": "Arrastra para reordenar", "folder.newSubfolder": "Nueva subcarpeta", "folder.deleteFolder": "Eliminar carpeta", @@ -7973,6 +8009,10 @@ const ko = { "track.removed": "삭제됨", "track.moveToTrash": "휴지통으로 옮기기", "track.moveTitleToTrash": "{title}을(를) 휴지통으로 옮기기", + "track.favorite": "즐겨찾기에 추가", + "track.unfavorite": "즐겨찾기에서 제거", + "track.favoriteTitle": "{title}을(를) 즐겨찾기에 추가", + "track.unfavoriteTitle": "{title}을(를) 즐겨찾기에서 제거", "folder.dragToReorder": "끌어서 순서 바꾸기", "folder.newSubfolder": "새 하위 폴더", "folder.deleteFolder": "폴더 삭제", diff --git a/tests/e2e/favorite-reach.spec.mjs b/tests/e2e/favorite-reach.spec.mjs new file mode 100644 index 00000000..5cc37dfc --- /dev/null +++ b/tests/e2e/favorite-reach.spec.mjs @@ -0,0 +1,74 @@ +// A track can be favourited at any window width (#724). +// +// The only heart used to be the Now Playing card's, and daw.css hides that +// whole card below 1460 px. A maximised laptop window is under that, so there +// was no way to favourite at all. Library and Favorites rows have one now, and +// every heart goes through one toggle, so they cannot disagree. + +import { test, expect } from "@playwright/test"; +import { JOB_ID, SIBLING_JOB_ID, openStudio, readCatalogState } from "./helpers.mjs"; + +const row = (page, id) => page.locator(`#catalogList .cat-item[data-id="${id}"]`).first(); +const rowHeart = (page, id) => row(page, id).locator(".cat-fav"); +const favoriteIn = async (page, id) => Boolean((await readCatalogState(page))?.tracks?.[id]?.favorite); + +test.describe("favourites at a narrow window", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("a library row can favourite a track the Now Playing card cannot reach", async ({ page }) => { + await openStudio(page); + await expect(page.locator("#fav-btn")).toBeHidden(); + + await row(page, SIBLING_JOB_ID).hover(); + await expect(rowHeart(page, SIBLING_JOB_ID)).toBeVisible(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(true); + await expect(rowHeart(page, SIBLING_JOB_ID)).toHaveAttribute("aria-pressed", "true"); + await expect(row(page, SIBLING_JOB_ID)).toHaveClass(/\bis-fav\b/); + // The heart is a button on the row, not a way of opening the track. + await expect(row(page, JOB_ID)).toHaveClass(/\bactive\b/); + await expect(row(page, SIBLING_JOB_ID)).not.toHaveClass(/\bactive\b/); + }); + + test("the Favorites view can take a track back out", async ({ page }) => { + await openStudio(page); + await row(page, SIBLING_JOB_ID).hover(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + await page.locator(".rail-favorites").click(); + await expect(row(page, SIBLING_JOB_ID)).toBeVisible(); + await row(page, SIBLING_JOB_ID).hover(); + await rowHeart(page, SIBLING_JOB_ID).click(); + + await expect(row(page, SIBLING_JOB_ID)).toHaveCount(0); + expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(false); + }); + + test("keyboard focus reveals the row heart", async ({ page }) => { + await openStudio(page); + await rowHeart(page, SIBLING_JOB_ID).focus(); + // Polled: the reveal fades in over a short transition. + await expect.poll(() => row(page, SIBLING_JOB_ID).locator(".cat-actions") + .evaluate((el) => Number(getComputedStyle(el).opacity))).toBeGreaterThan(0.9); + }); +}); + +test.describe("favourites at a wide window", () => { + test.use({ viewport: { width: 1600, height: 900 } }); + + test("the row heart and the Now Playing heart stay in step", async ({ page }) => { + await openStudio(page); + const npHeart = page.locator("#fav-btn"); + await expect(npHeart).toBeVisible(); + await expect(npHeart).toHaveAttribute("aria-pressed", "false"); + + await row(page, JOB_ID).hover(); + await rowHeart(page, JOB_ID).click(); + await expect(npHeart).toHaveAttribute("aria-pressed", "true"); + + await npHeart.click(); + await expect(rowHeart(page, JOB_ID)).toHaveAttribute("aria-pressed", "false"); + expect(await favoriteIn(page, JOB_ID)).toBe(false); + }); +}); From b690103aa25905acb244bb29b3c7b3cbfa097c2a Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 13:13:29 +0100 Subject: [PATCH 3/9] fix(desktop): offer older NVIDIA cards a torch build with their kernels A CUDA 13 driver was offered cu128 alone. cu128 installs torch 2.8, whose builds start at sm_61 on Windows and sm_70 on Linux (TORCH_CUDA_ARCH_LIST in pytorch's release/2.8 .ci scripts), so a GTX 970 (sm_52) installed it, failed verification with "no kernel image is available", and dropped to CPU. - A card below compute capability 7 is never offered cu128; it gets cu124 then cu118, the torch 2.6 builds, which carry sm_50 on both platforms (release/2.6 scripts), and an sm_50 binary runs on 5.x. - A CUDA 13 driver on a newer card keeps cu128 first, with cu124 and cu118 behind it instead of CPU. New tests: a_pre_volta_card_is_never_offered_cu128 (the old code returned ["cu128"] for 5.2 under 13.0) and a_cuda_13_driver_falls_back_to_the_2_6_builds. The published-wheel check now covers caps 5.2 and 6.1. Closes #732 --- desktop/src-tauri/src/main.rs | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b1ad729c..c346387e 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2325,13 +2325,23 @@ fn wheel_tag(compute_cap: Option<&str>, cuda_version: &str) -> &'static str { /// on the GPU and then fail at separation time. #[cfg(any(not(target_os = "macos"), test))] fn wheel_candidates(compute_cap: Option<&str>, cuda_version: &str) -> Vec<&'static str> { - let blackwell = compute_cap - .and_then(|cap| cap.split('.').next()?.parse::().ok()) - .is_some_and(|major| major >= 10); + let cap_major = compute_cap.and_then(|cap| cap.split('.').next()?.parse::().ok()); + let blackwell = cap_major.is_some_and(|major| major >= 10); if blackwell { return vec!["cu128"]; } + // torch 2.8 (cu128) dropped Maxwell and Pascal: its Windows builds start + // at sm_61 and its Linux builds at sm_70 (TORCH_CUDA_ARCH_LIST in + // pytorch's release/2.8 .ci scripts). The 2.6 line keeps sm_50 in both + // cu124 and cu118, and an sm_50 binary runs on any 5.x card. A GTX 970 + // under a CUDA 13 driver was offered cu128 alone, failed verification with + // "no kernel image is available", and dropped to CPU (#732). + let pre_volta = cap_major.is_some_and(|major| major < 7); match cuda_tag(cuda_version) { + "cu128" if pre_volta => vec!["cu124", "cu118"], + // A CUDA 13 driver runs the 2.6 builds too, so they are a second + // chance when cu128 installs but does not verify. + "cu128" => vec!["cu128", "cu124", "cu118"], // cu118 as a second chance for a CUDA 12 driver. cu124 is the right // first answer for all of them (see cuda_tag), but minor-version // compatibility is the thing being relied on there, and when it does @@ -6211,6 +6221,46 @@ mod tests { assert_eq!(super::wheel_candidates(None, "11.8"), vec!["cu118"]); } + /// The reporter's GTX 970 (sm_52) under a CUDA 13 driver (#723, #732). + /// torch 2.8's cu128 builds have no kernels below sm_61 on Windows or + /// sm_70 on Linux, so every pre-Volta card goes to the 2.6 builds. + #[cfg(not(target_os = "macos"))] + #[test] + fn a_pre_volta_card_is_never_offered_cu128() { + for cap in ["5.0", "5.2", "6.0", "6.1", "6.2"] { + for driver in ["13.0", "12.8", "12.4", "11.8"] { + let tags = super::wheel_candidates(Some(cap), driver); + assert!( + !tags.is_empty(), + "cap {cap}, driver {driver}: nothing offered" + ); + assert!( + !tags.contains(&"cu128"), + "cap {cap}, driver {driver}: offered {tags:?}" + ); + } + } + assert_eq!( + super::wheel_candidates(Some("5.2"), "13.0"), + vec!["cu124", "cu118"] + ); + } + + /// A card cu128 does support still starts there, with the 2.6 builds + /// behind it instead of CPU. + #[cfg(not(target_os = "macos"))] + #[test] + fn a_cuda_13_driver_falls_back_to_the_2_6_builds() { + assert_eq!( + super::wheel_candidates(Some("8.6"), "13.0"), + vec!["cu128", "cu124", "cu118"] + ); + assert_eq!( + super::wheel_candidates(None, "13.0"), + vec!["cu128", "cu124", "cu118"] + ); + } + /// Every wheel tag setup can offer publishes the torch line it installs. /// /// The tag comes from the driver and the version from a table, and nothing @@ -6234,6 +6284,8 @@ mod tests { ]; let caps = [ None, + Some("5.2"), + Some("6.1"), Some("7.5"), Some("8.6"), Some("8.9"), From d0d04d670a55d507cfd19e4aaa5a66c72cc6fd77 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 13:29:19 +0100 Subject: [PATCH 4/9] fix(ui): show a track's key once, as one label that fits The analysis strip said the key three times: "D maj" on the Key card, "Major" under it, and "Major" again on a Scale card of its own, while the Key card was too narrow for a longer label. - keyLabel.js formatKey() turns the stored key and scale into one label, "B minor" or "B harmonic minor", from a translated template and mode names in all ten languages (plus the European Portuguese spelling). Note names stay letters everywhere, as DAWs write them. - The Scale card, the Major/Minor sub-line and an unused confidence label are gone; the confidence ring stays. - Key takes the Scale card's grid column, so the row keeps the seven edges the presence row lines up with. Measured at a 950 px window it still has 115 px for a label that can need 155, so the key wraps to a second line rather than clip; a short key stays on one line. - Display only: stored key, scale and key_confidence are unchanged, so nothing needs re-analysis. tests/js/key-label.test.mjs checks every language; the new tests/e2e/key-card.spec.mjs checks en, de and fr at 1366 and 950 px for the label and that it is not clipped, and fails on the old code. Closes #736 --- static/css/daw.css | 14 ++++++++ static/index.html | 9 ------ static/js/catalog.js | 10 ++---- static/js/i18n.js | 51 +++++++++++++++++++++++------ static/js/job.js | 9 ++---- static/js/keyLabel.js | 35 ++++++++++++++++++++ static/js/player.js | 4 --- tests/e2e/key-card.spec.mjs | 64 +++++++++++++++++++++++++++++++++++++ tests/js/key-label.test.mjs | 61 +++++++++++++++++++++++++++++++++++ 9 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 static/js/keyLabel.js create mode 100644 tests/e2e/key-card.spec.mjs create mode 100644 tests/js/key-label.test.mjs diff --git a/static/css/daw.css b/static/css/daw.css index 4f27b334..4ae32ca0 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1388,6 +1388,20 @@ input, textarea { font-family: inherit; } grid-template-columns: repeat(7, minmax(0, 1fr)); border-bottom: 1px solid var(--border); } +/* Key takes the column the Scale card had (#736). "F# harmonisches Moll" has to + fit, and two columns keep the seven edges the presence row lines up with. */ +.daw-meta-card[data-meta="key"] { grid-column: span 2; } +/* Even two columns are 115 px of text at a 950 px window, and "F# harmonisches + Moll" needs 155. Clipping it would hide the mode, which is the part the + label exists to say, so the key wraps instead: a short key still sits on + one line at any width, a long one takes two. */ +.daw-meta-card[data-meta="key"] .meta-card-main { align-items: center; } +.daw-meta-card[data-meta="key"] .meta-card-value { + white-space: normal; + overflow-wrap: break-word; + line-height: 1.15; + text-wrap: balance; +} /* Track card */ .daw-track-card { diff --git a/static/index.html b/static/index.html index 6c353e35..1a7182b1 100644 --- a/static/index.html +++ b/static/index.html @@ -452,8 +452,6 @@

Lyrics

— - -
@@ -479,13 +477,6 @@

Lyrics

-
- SCALE -
- — -
-
-
DYNAMIC RANGE
diff --git a/static/js/catalog.js b/static/js/catalog.js index a5c28321..ef8d6d42 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -5,6 +5,7 @@ import { initSections } from "./sections.js"; import { bpmChip, foregroundJobId, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js"; import { refreshStemChoiceVisuals } from "./stemChoice.js"; import { trackFormat, formatIconSvg, paintNowPlayingArt } from "./formatIcon.js"; +import { formatKey } from "./keyLabel.js"; import { showError, importFromUrl, detachForegroundJob, runVocalSplitIfWanted } from "./job.js"; import { cancelQueuedJob, getQueueSnapshot, isPaused, onJobSettled, onQueueChange, @@ -856,18 +857,13 @@ function applyTrackInfoToPanel(track) { const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); - const summaryScale = document.getElementById("summary-scale"); - const summaryScaleName = document.getElementById("summary-scale-name"); const summaryConfidence = document.getElementById("summary-confidence"); - const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const summaryLufs = document.getElementById("summary-lufs"); const summaryPeak = document.getElementById("summary-peak"); const summaryDuration = document.getElementById("summary-duration"); - if (summaryKey) summaryKey.textContent = track.key || "—"; + if (summaryKey) summaryKey.textContent = formatKey(track.key, track.scale, i18nT) || "—"; if (summaryBpm) summaryBpm.textContent = track.bpm ? String(track.bpm) : "—"; - if (summaryScale) summaryScale.textContent = track.scale || ""; - if (summaryScaleName) summaryScaleName.textContent = track.scale || "—"; if (summaryLufs) summaryLufs.textContent = track.lufs != null ? Number(track.lufs).toFixed(1) : "—"; if (summaryPeak) summaryPeak.textContent = track.peakDb != null ? i18nT("job.peakDb", { value: Number(track.peakDb).toFixed(1) }) : ""; if (summaryDuration) summaryDuration.textContent = track.duration ? fmtTime(track.duration) : "—"; @@ -916,7 +912,6 @@ function applyTrackInfoToPanel(track) { summaryConfidence.textContent = ""; summaryConfidence.style.removeProperty("--confidence-pct"); summaryConfidence.classList.add("hidden"); - summaryConfidenceLabel?.classList.add("hidden"); if (track.keyConfidence != null) { const confidence = Math.max(0, Math.min(100, Number(track.keyConfidence))); const confSpan = document.createElement("span"); @@ -924,7 +919,6 @@ function applyTrackInfoToPanel(track) { summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); - summaryConfidenceLabel?.classList.remove("hidden"); } } } diff --git a/static/js/i18n.js b/static/js/i18n.js index 44380b3c..848b1b51 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -466,10 +466,13 @@ const en = { "trash.noSearchMatch": "No deleted tracks match your search", "meta.key": "Key", + "key.label": "{tonic} {mode}", + "key.mode.major": "major", + "key.mode.minor": "minor", + "key.mode.harmonicMinor": "harmonic minor", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duration", - "meta.scale": "Scale", "meta.dynamicRange": "Dynamic Range", "meta.tempoStability": "Tempo Stability", @@ -1269,10 +1272,13 @@ const pl = { "trash.noSearchMatch": "Żadne usunięte utwory nie pasują do wyszukiwania", "meta.key": "Tonacja", + "key.label": "{tonic} {mode}", + "key.mode.major": "dur", + "key.mode.minor": "moll", + "key.mode.harmonicMinor": "moll harmoniczna", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Czas", - "meta.scale": "Skala", "meta.dynamicRange": "Zakres dynamiki", "meta.tempoStability": "Stabilność tempa", @@ -2062,10 +2068,13 @@ const ja = { "trash.noSearchMatch": "検索に一致する削除済みトラックはありません", "meta.key": "キー", + "key.label": "{tonic} {mode}", + "key.mode.major": "メジャー", + "key.mode.minor": "マイナー", + "key.mode.harmonicMinor": "ハーモニック・マイナー", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "長さ", - "meta.scale": "スケール", "meta.dynamicRange": "ダイナミックレンジ", "meta.tempoStability": "テンポ安定性", @@ -2830,10 +2839,13 @@ const zhHans = { "trash.noSearchMatch": "没有已删除的曲目与你的搜索匹配", "meta.key": "调性", + "key.label": "{tonic} {mode}", + "key.mode.major": "大调", + "key.mode.minor": "小调", + "key.mode.harmonicMinor": "和声小调", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "时长", - "meta.scale": "音阶", "meta.dynamicRange": "动态范围", "meta.tempoStability": "速度稳定性", @@ -3598,10 +3610,13 @@ const de = { "trash.noSearchMatch": "Keine gelöschten Tracks entsprechen deiner Suche", "meta.key": "Tonart", + "key.label": "{tonic} {mode}", + "key.mode.major": "Dur", + "key.mode.minor": "Moll", + "key.mode.harmonicMinor": "harmonisches Moll", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Dauer", - "meta.scale": "Tonleiter", "meta.dynamicRange": "Dynamikumfang", "meta.tempoStability": "Tempostabilität", @@ -4377,10 +4392,13 @@ const pt = { "trash.noSearchMatch": "Nenhuma faixa excluída corresponde à sua pesquisa", "meta.key": "Tom", + "key.label": "{tonic} {mode}", + "key.mode.major": "maior", + "key.mode.minor": "menor", + "key.mode.harmonicMinor": "menor harmônica", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duração", - "meta.scale": "Escala", "meta.dynamicRange": "Faixa dinâmica", "meta.tempoStability": "Estabilidade de tempo", @@ -5158,10 +5176,13 @@ const id = { "trash.noSearchMatch": "Tidak ada trek terhapus yang cocok dengan pencarian Anda", "meta.key": "Nada Dasar", + "key.label": "{tonic} {mode}", + "key.mode.major": "mayor", + "key.mode.minor": "minor", + "key.mode.harmonicMinor": "minor harmonik", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Durasi", - "meta.scale": "Tangga Nada", "meta.dynamicRange": "Rentang Dinamis", "meta.tempoStability": "Stabilitas Tempo", @@ -5926,10 +5947,13 @@ const fr = { "trash.noSearchMatch": "Aucun morceau supprimé ne correspond à votre recherche", "meta.key": "Tonalité", + "key.label": "{tonic} {mode}", + "key.mode.major": "majeur", + "key.mode.minor": "mineur", + "key.mode.harmonicMinor": "mineur harmonique", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Durée", - "meta.scale": "Gamme", "meta.dynamicRange": "Plage dynamique", "meta.tempoStability": "Stabilité du tempo", @@ -6462,6 +6486,7 @@ const fr = { // through pt (see FALLBACK), so the two variants cannot drift and a key // added to pt later is picked up here rather than reverting to English. const ptPT = { + "key.mode.harmonicMinor": "menor harmónica", "lyrics.align.hint": "Coloque o cursor de reprodução onde a primeira linha é cantada e prima Começar a letra aqui. Para usar outra linha, clique nela primeiro e depois leve o cursor até onde é cantada.", "lyrics.align.targetPicked": "Move a linha em que clicou: “{line}”", "lyrics.align.detecting": "A verificar a voz…", @@ -6877,10 +6902,13 @@ const es = { "trash.noSearchMatch": "Ninguna pista eliminada coincide con tu búsqueda", "meta.key": "Tonalidad", + "key.label": "{tonic} {mode}", + "key.mode.major": "mayor", + "key.mode.minor": "menor", + "key.mode.harmonicMinor": "menor armónica", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "Duración", - "meta.scale": "Escala", "meta.dynamicRange": "Rango dinámico", "meta.tempoStability": "Estabilidad del tempo", @@ -7679,10 +7707,13 @@ const ko = { "trash.noSearchMatch": "검색과 맞는 삭제된 트랙이 없어요", "meta.key": "키", + "key.label": "{tonic} {mode}", + "key.mode.major": "메이저", + "key.mode.minor": "마이너", + "key.mode.harmonicMinor": "하모닉 마이너", "meta.bpm": "BPM", "meta.lufs": "LUFS", "meta.duration": "길이", - "meta.scale": "음계", "meta.dynamicRange": "다이내믹 레인지", "meta.tempoStability": "템포 안정성", diff --git a/static/js/job.js b/static/js/job.js index a41b4cbb..547b39e7 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -13,6 +13,7 @@ import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenc import { initSections } from "./sections.js"; import { importPlaylist, looksLikePlaylist } from "./playlist.js"; import { t } from "./i18n.js"; +import { formatKey } from "./keyLabel.js"; // Playful stage label rotation (Claude-Code-style flair). The backend // emits truthful stage strings; we surface them in the small #job-detail @@ -329,17 +330,12 @@ export function detachForegroundJob() { function applyStudioSummary(state) { const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); - const summaryScale = document.getElementById("summary-scale"); - const summaryScaleName = document.getElementById("summary-scale-name"); const summaryConfidence = document.getElementById("summary-confidence"); - const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const summaryLufs = document.getElementById("summary-lufs"); const summaryPeak = document.getElementById("summary-peak"); const summaryDuration = document.getElementById("summary-duration"); - if (summaryKey && state.key) summaryKey.textContent = state.key; + if (summaryKey && state.key) summaryKey.textContent = formatKey(state.key, state.scale, t); if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm); - if (summaryScale && state.scale) summaryScale.textContent = state.scale; - if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale; if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1); if (summaryPeak && state.peak_db != null) summaryPeak.textContent = t("job.peakDb", { value: state.peak_db.toFixed(1) }); if (summaryDuration && state.duration) { @@ -355,7 +351,6 @@ function applyStudioSummary(state) { summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); - summaryConfidenceLabel?.classList.remove("hidden"); } const summaryDr = document.getElementById("summary-dr"); const summaryDrLabel = document.getElementById("summary-dr-label"); diff --git a/static/js/keyLabel.js b/static/js/keyLabel.js new file mode 100644 index 00000000..34c0114a --- /dev/null +++ b/static/js/keyLabel.js @@ -0,0 +1,35 @@ +// A track's key as one label, "B minor" or "B harmonic minor" (#736). +// +// Analysis stores the key as "B min" and the scale separately as "Natural +// Minor", and the analysis strip used to show both plus a Scale card of its +// own: "D maj", then "Major", then "Major" again. One label says it once. +// +// Note names stay as letters in every language, the convention DAWs and chord +// charts use, rather than H or Si. The mode is translated and placed by a +// template, because word order differs ("B minor", "B-Moll" style, "B 小调"). + +// The scale names analysis can produce, to the mode each is shown as. +const MODE_KEYS = { + "Major": "key.mode.major", + "Natural Minor": "key.mode.minor", + "Harmonic Minor": "key.mode.harmonicMinor", +}; + +/** + * @param {string|null|undefined} key as stored, e.g. "F# min" + * @param {string|null|undefined} scale as stored, e.g. "Harmonic Minor" + * @param {(key: string, vars?: object) => string} t the i18n lookup + * @returns {string} the label, or "" when there is no key to show + */ +export function formatKey(key, scale, t) { + const text = String(key ?? "").trim(); + if (!text) return ""; + const [tonic, suffix] = text.split(/\s+/); + // A scale this does not know (or none, from tracks analysed before it was + // stored) falls back on the key's own maj/min suffix. + let modeKey = MODE_KEYS[scale]; + if (!modeKey && suffix === "maj") modeKey = "key.mode.major"; + if (!modeKey && suffix === "min") modeKey = "key.mode.minor"; + if (!modeKey) return text; + return t("key.label", { tonic, mode: t(modeKey) }); +} diff --git a/static/js/player.js b/static/js/player.js index 4cbee674..e1c1c9db 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -223,19 +223,15 @@ function clearStemSelectionFilter() { function resetAnalysisCards() { const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); - const summaryScale = document.getElementById("summary-scale"); const summaryConfidence = document.getElementById("summary-confidence"); - const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const loudnessCard = document.getElementById("loudness-card"); if (summaryKey) summaryKey.textContent = "—"; if (summaryBpm) summaryBpm.innerHTML = "— BPM"; - if (summaryScale) summaryScale.textContent = ""; if (summaryConfidence) { summaryConfidence.textContent = ""; summaryConfidence.style.removeProperty("--confidence-pct"); summaryConfidence.classList.add("hidden"); } - if (summaryConfidenceLabel) summaryConfidenceLabel.classList.add("hidden"); if (loudnessCard) loudnessCard.classList.add("hidden"); } diff --git a/tests/e2e/key-card.spec.mjs b/tests/e2e/key-card.spec.mjs new file mode 100644 index 00000000..56c6e7be --- /dev/null +++ b/tests/e2e/key-card.spec.mjs @@ -0,0 +1,64 @@ +// The key is one label on one card, and it fits (#736). +// +// It used to be "D maj" on the Key card, "Major" under it and "Major" again on +// a Scale card of its own, while the Key card was too narrow for anything +// longer. The longest label analysis can produce is a sharp harmonic minor, and +// German spells it longest, so that is what this measures. + +import { test, expect } from "@playwright/test"; +import { + JOB_ID, SIBLING_JOB_ID, TRACK_TITLE, SIBLING_TITLE, + fixtureTrack, seedCatalogState, stubExportEndpoints, stubUpdateCheck, +} from "./helpers.mjs"; + +async function openWithKey(page, { language }) { + await page.addInitScript((code) => window.localStorage.setItem("stemdeck.language", JSON.stringify(code)), language); + await seedCatalogState(page, { + folders: [ + { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null }, + { id: "trash", name: "Trash", items: [], color: null }, + ], + tracks: { + [JOB_ID]: { ...fixtureTrack(JOB_ID, TRACK_TITLE), key: "F# min", scale: "Harmonic Minor", keyConfidence: 72 }, + [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE), + }, + }); + // The server's own record wins over the store once the page syncs, and the + // fixture job says "C maj", so the answer is rewritten on the way in. + await page.route(`**/api/jobs**`, async (route) => { + const response = await route.fetch(); + let body = await response.text(); + if ((response.headers()["content-type"] || "").includes("json")) { + body = body.replaceAll('"key":"C maj"', '"key":"F# min"') + .replaceAll('"key": "C maj"', '"key": "F# min"') + .replaceAll('"scale":"Major"', '"scale":"Harmonic Minor"') + .replaceAll('"scale": "Major"', '"scale": "Harmonic Minor"'); + } + await route.fulfill({ response, body }); + }); + await stubExportEndpoints(page); + await stubUpdateCheck(page, { available: false }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click(); +} + +// Background syncs are still in flight through the rewriter when a test ends. +test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); +}); + +const EXPECTED = { en: "F# harmonic minor", de: "F# harmonisches Moll", fr: "F# mineur harmonique" }; + +for (const [language, label] of Object.entries(EXPECTED)) { + for (const width of [1366, 950]) { + test(`${language} at ${width}px: the key is one label and it fits`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await openWithKey(page, { language }); + const key = page.locator("#summary-key"); + await expect(key).toHaveText(label); + await expect(page.locator('[data-meta="scale"]')).toHaveCount(0); + const clipped = await key.evaluate((el) => el.scrollWidth > el.clientWidth); + expect(clipped).toBe(false); + }); + } +} diff --git a/tests/js/key-label.test.mjs b/tests/js/key-label.test.mjs new file mode 100644 index 00000000..10afc7b2 --- /dev/null +++ b/tests/js/key-label.test.mjs @@ -0,0 +1,61 @@ +// The merged key label (#736): "B minor", not "B min" plus "Natural Minor" plus +// a Scale card saying it a third time. +// +// Run: node tests/js/key-label.test.mjs + +import { formatKey } from '../../static/js/keyLabel.js'; +import { TRANSLATIONS } from '../../static/js/i18n.js'; + +let passed = 0; +let failed = 0; + +function check(name, condition, detail = '') { + if (condition) { + passed++; + console.log(`PASS ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +// The same lookup the app does: the language, then pt for pt-PT, then English. +const FALLBACK = { 'pt-PT': 'pt' }; +function tFor(code) { + return (key, vars = {}) => { + let text; + for (let c = code; c && text === undefined; c = FALLBACK[c]) text = TRANSLATIONS[c]?.[key]; + text ??= TRANSLATIONS.en[key] ?? key; + return text.replace(/\{(\w+)\}/g, (_, v) => String(vars[v] ?? `{${v}}`)); + }; +} +const en = tFor('en'); + +check('a major key reads as one label', formatKey('D maj', 'Major', en) === 'D major', formatKey('D maj', 'Major', en)); +check('a natural minor key drops "natural"', formatKey('B min', 'Natural Minor', en) === 'B minor'); +check('harmonic minor is named', formatKey('B min', 'Harmonic Minor', en) === 'B harmonic minor'); +check('sharps keep their letter', formatKey('F# min', 'Natural Minor', en) === 'F# minor'); +check('a track analysed before scale was stored still reads', formatKey('F# min', undefined, en) === 'F# minor'); +check('an unknown scale falls back on the key', formatKey('C maj', 'Dorian', en) === 'C major'); +check('an unreadable key is shown as stored', formatKey('weird', null, en) === 'weird'); +check('no key gives nothing', formatKey(null, 'Major', en) === '' && formatKey('', null, en) === ''); + +// Every language, including the regional variant, produces a complete label: +// the tonic letter, no raw key names, no unfilled placeholder. +for (const code of Object.keys(TRANSLATIONS)) { + const t = tFor(code); + for (const scale of ['Major', 'Natural Minor', 'Harmonic Minor']) { + const label = formatKey('F# min', scale, t); + check( + `${code}: ${scale} is a complete label`, + label.startsWith('F#') && !/key\.|\{|\}/.test(label) && label.length > 3, + label, + ); + } +} + +check('German reads naturally', formatKey('B min', 'Natural Minor', tFor('de')) === 'B Moll'); +check('European Portuguese keeps its spelling', formatKey('B min', 'Harmonic Minor', tFor('pt-PT')) === 'B menor harmónica'); + +console.log(`\n${passed}/${passed + failed} checks passed`); +process.exit(failed === 0 ? 0 : 1); From d2fb0ce90a325bdd26fc7dde6daf6ec38869ee28 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 13:33:32 +0100 Subject: [PATCH 5/9] fix(desktop): stop retrying CUDA setup on a GPU no build supports A CPU result born from a failure is re-probed on every launch (#247), which is right for a network drop. For a GPU that every offered CUDA build refuses with "no kernel image is available", it meant downloading and installing CUDA torch, failing verification and restoring CPU torch on every launch: six cycles in the #723 reporter's setup.log. - verify_cuda_torch() now says why it failed; the no-kernel case is matched on CUDA's exact wording, so driver, memory or install problems are never mistaken for it. - When every candidate installed and was refused for that reason, setup records cuda-unsupported-gpu. Any other mix keeps today's reasons. - The setup screen treats that reason as settled and says plainly why the GPU is not used. A new app version re-runs setup anyway, which is when different builds could be on offer. New tests: a_gpu_every_build_refuses_is_settled_as_unsupported and only_cudas_own_wording_counts_as_no_kernel_image. Closes #733 --- desktop/src-tauri/src/main.rs | 107 ++++++++++++++++++++++++++++++---- desktop/ui/setup.js | 15 ++++- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index c346387e..688458e5 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1959,6 +1959,9 @@ fn ensure_torch_device( // reads in a bug report and sends them after the wrong thing // (#502: the install had been killed mid-download). let mut cuda_installed = false; + // One entry per candidate tried: None when it did not install, + // otherwise what verification said. Decides the reason below. + let mut outcomes: Vec> = Vec::new(); for tag in &candidates { append_to_setup_log( &data_dir, @@ -1970,10 +1973,13 @@ fn ensure_torch_device( let index_url = format!("https://download.pytorch.org/whl/{tag}"); if let Err(e) = install_cuda_torch(&python, &index_url, &state, &app) { append_to_setup_log(&data_dir, &format!("{tag} install failed: {e}")); + outcomes.push(None); continue; } cuda_installed = true; - if verify_cuda_torch(&python) { + let outcome = verify_cuda_torch(&python); + outcomes.push(Some(outcome)); + if outcome == CudaVerify::Verified { append_to_setup_log(&data_dir, &format!("{tag} verified")); cuda_verified = true; break; @@ -1988,11 +1994,7 @@ fn ensure_torch_device( // module scope, so a wheel that cannot load keeps the // backend from starting at all. Put the CPU wheels back // (#324). - let stage = if cuda_installed { - "cuda-verify-failed" - } else { - "cuda-install-failed" - }; + let stage = cuda_failure_stage(&outcomes); match restore_cpu_torch(&python, &state, &app) { Ok(()) => stage, Err(e) => { @@ -2931,7 +2933,7 @@ fn install_cuda_torch( ) -> Result<(), String> { // Skip only when CUDA torch is already active — torch.version.cuda is // None for CPU-only wheels, so this correctly re-installs when needed. - if verify_cuda_torch(python) { + if verify_cuda_torch(python) == CudaVerify::Verified { return Ok(()); } @@ -3025,8 +3027,49 @@ fn cuda_install_passes<'a>( /// an unbounded wait is an unbounded hang (#502). const GPU_VERIFY_TIMEOUT: Duration = Duration::from_secs(120); +/// What a CUDA verification run showed. +#[cfg(any(not(target_os = "macos"), test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CudaVerify { + Verified, + /// The build has no kernels for this GPU. Another build might, but trying + /// the same builds again on the next launch never will (#733). + NoKernelImage, + /// Anything else, including a timeout. Worth retrying next launch. + Failed, +} + +/// Whether a failed verification was the "this build has no kernels for your +/// GPU" error. Matched on CUDA's exact wording, because a looser match would +/// settle a driver or install problem as permanent. +#[cfg(any(not(target_os = "macos"), test))] +fn is_no_kernel_image(stderr: &str) -> bool { + stderr.contains("no kernel image is available for execution on the device") +} + +/// The reason recorded when CUDA setup does not end on the GPU. +/// +/// `cuda-unsupported-gpu` means every build that installed was refused for +/// having no kernels for this card, and nothing else went wrong. The setup +/// screen treats it as settled, so the same builds are not downloaded and +/// refused again on every launch (#733). A new app version re-runs setup +/// anyway, which is when different builds could be on offer. +#[cfg(any(not(target_os = "macos"), test))] +fn cuda_failure_stage(outcomes: &[Option]) -> &'static str { + let installed: Vec = outcomes.iter().flatten().copied().collect(); + if installed.is_empty() { + "cuda-install-failed" + } else if installed.len() == outcomes.len() + && installed.iter().all(|o| *o == CudaVerify::NoKernelImage) + { + "cuda-unsupported-gpu" + } else { + "cuda-verify-failed" + } +} + #[cfg(not(target_os = "macos"))] -fn verify_cuda_torch(python: &Path) -> bool { +fn verify_cuda_torch(python: &Path) -> CudaVerify { // Don't trust torch.cuda.is_available() alone: it returns True even when the // installed wheel has no kernels for the device (e.g. sm_120 on a cu124 // build), which then crashes mid-extraction with "no kernel image is @@ -3060,7 +3103,7 @@ fn verify_cuda_torch(python: &Path) -> bool { }; match command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "CUDA verify") { - Ok(out) if out.status.success() => true, + Ok(out) if out.status.success() => CudaVerify::Verified, Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); if !stderr.trim().is_empty() { @@ -3070,7 +3113,11 @@ fn verify_cuda_torch(python: &Path) -> bool { // torch loaded but reported no usable device. log("CUDA verify failed: torch reported no usable CUDA device"); } - false + if is_no_kernel_image(&stderr) { + CudaVerify::NoKernelImage + } else { + CudaVerify::Failed + } } Err(e) => { // The timeout path lands here, and it is the one worth naming @@ -3081,7 +3128,7 @@ fn verify_cuda_torch(python: &Path) -> bool { and falling back to CPU. A driver that does not match the installed \ CUDA wheel is the usual cause." )); - false + CudaVerify::Failed } } } @@ -6246,6 +6293,44 @@ mod tests { ); } + /// The reporter's setup log: the GPU was refused by every build for having + /// no kernels, and setup repeated the whole install on each of six + /// launches (#733). That outcome, and only that one, is settled. + #[test] + fn a_gpu_every_build_refuses_is_settled_as_unsupported() { + use super::CudaVerify::{Failed, NoKernelImage, Verified}; + let stage = super::cuda_failure_stage; + assert_eq!(stage(&[Some(NoKernelImage)]), "cuda-unsupported-gpu"); + assert_eq!( + stage(&[Some(NoKernelImage), Some(NoKernelImage)]), + "cuda-unsupported-gpu" + ); + // Anything that might go differently next time is not settled. + assert_eq!( + stage(&[Some(NoKernelImage), Some(Failed)]), + "cuda-verify-failed" + ); + assert_eq!(stage(&[Some(NoKernelImage), None]), "cuda-verify-failed"); + assert_eq!(stage(&[Some(Failed)]), "cuda-verify-failed"); + assert_eq!(stage(&[None, None]), "cuda-install-failed"); + assert_eq!(stage(&[]), "cuda-install-failed"); + // Verified never reaches here, but must not read as unsupported. + assert_eq!(stage(&[Some(Verified)]), "cuda-verify-failed"); + } + + #[test] + fn only_cudas_own_wording_counts_as_no_kernel_image() { + // The line from the reporter's setup.log. + assert!(super::is_no_kernel_image( + "torch.AcceleratorError: CUDA error: no kernel image is available for execution on the device" + )); + assert!(!super::is_no_kernel_image("CUDA error: out of memory")); + assert!(!super::is_no_kernel_image( + "RuntimeError: CUDA kernel launch timed out" + )); + assert!(!super::is_no_kernel_image("")); + } + /// A card cu128 does support still starts there, with the 2.6 builds /// behind it instead of CPU. #[cfg(not(target_os = "macos"))] diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js index 3a0bb557..fed8146b 100644 --- a/desktop/ui/setup.js +++ b/desktop/ui/setup.js @@ -322,10 +322,17 @@ async function runSetup() { // that predates reason tracking -- re-runs the GPU step on this launch, so // a single bad first run can't pin the install to CPU forever (#247). // Cost when nothing changed: one fast nvidia-smi probe. + // + // A GPU that every offered CUDA build refused for having no kernels for it + // is settled too: trying the same builds again cannot go differently, and + // did cost about 45 s of download and install on every launch (#733). A + // new version re-runs setup through versionMismatch below, which is when + // different builds could be on offer. const torchDeviceSettled = runtime.torchDevice === "cuda" || runtime.torchDevice === "mps" || - (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cpu-only-package"); + (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cpu-only-package") || + (runtime.torchDevice === "cpu" && runtime.torchDeviceReason === "cuda-unsupported-gpu"); if (runtime.pythonReady && runtime.ffmpegReady && torchDeviceSettled && !versionMismatch) { for (const step of steps) { @@ -449,7 +456,11 @@ async function runSetup() { ? `${gpu.gpuName} acceleration enabled` : "MPS acceleration unavailable - stem separation will use CPU"; } else { - if (gpu.gpuDetected && !gpu.cudaVerified) { + if (gpu.gpuDetected && !gpu.cudaVerified && gpu.reason === "cuda-unsupported-gpu") { + showError( + `GPU detected (${gpu.gpuName}), but none of the CUDA builds StemDeck can install support it - stem separation will use CPU. StemDeck will not try again until the next update.` + ); + } else if (gpu.gpuDetected && !gpu.cudaVerified) { showError( `GPU detected (${gpu.gpuName}) but CUDA setup failed - stem separation will use CPU.\nCheck logs/setup.log in the StemDeck data folder for details.` ); From a0d5ff5293dad692f21fdf979c61edfaf9c23bc2 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 13:39:35 +0100 Subject: [PATCH 6/9] fix(desktop): put the bundled torch back exactly when CUDA fails The CUDA install laid the CUDA wheels over the CPU ones, and the restore laid the CPU wheels back over those, both with --ignore-installed. Overlaying never removes files only the CUDA wheel has. On Windows torch loads every DLL in torch/lib, so the leftover c10_cuda.dll of one torch version failed to link against the restored CPU DLLs, and `import torch` raised WinError 127 for good while setup reported success (#723). New module torchswap: - Before the first real CUDA install the bundled torch is moved aside into .stemdeck-torch-backup next to site-packages. What belongs to torch is read from pip's RECORD files; on a real install that is only torch, functorch, torchgen, torchaudio, torio, torchvision and their dist-infos. - A manifest is written and synced before anything moves, so a crash at any point is recoverable; the next setup run restores it first. - Each candidate installs into clean directories; a failure deletes the CUDA build and renames the original back, with no network. Success deletes the backup. After any restore `import torch` is now checked. Without a backup (the move failed, or the install broke before this fix) the CPU wheels are reinstalled, and if torch still does not import, everything torch owns is deleted and they are installed once more into clean directories. That is what repairs an install already carrying the stray DLL. Whether a working CUDA build is already present is now asked once, before anything moves, instead of inside each install. Eight new tests on real temp directories, including the reporter's case (no c10_cuda.dll after rollback, c10.dll byte for byte) and a snapshot interrupted part way. Closes #731 --- desktop/src-tauri/src/main.rs | 182 ++++++++++++++- desktop/src-tauri/src/torchswap.rs | 358 +++++++++++++++++++++++++++++ 2 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/torchswap.rs diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 688458e5..442e0c0f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,6 +1,8 @@ mod certs; mod dragout; mod dropin; +#[cfg(any(not(target_os = "macos"), test))] +mod torchswap; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; @@ -1945,6 +1947,27 @@ fn ensure_torch_device( #[cfg(not(target_os = "macos"))] { + let site = torch_site_packages(&python); + if site.is_none() { + append_to_setup_log( + &data_dir, + "could not locate site-packages; torch swaps overlay", + ); + } + // A swap that was interrupted (crash, forced quit, a rollback that did + // not finish) is put back before anything else looks at torch. + if let Some(site) = site.as_deref().filter(|s| torchswap::has_backup(s)) { + match torchswap::restore(site) { + Ok(()) => append_to_setup_log( + &data_dir, + "restored the bundled torch left aside by an interrupted swap", + ), + Err(e) => append_to_setup_log( + &data_dir, + &format!("could not restore an interrupted torch swap ({e})"), + ), + } + } let setup = match detect_nvidia_gpu(&data_dir) { Some((gpu_name, cuda_version, compute_cap)) => { // Try each candidate wheel in turn. A build that installs but @@ -1962,7 +1985,17 @@ fn ensure_torch_device( // One entry per candidate tried: None when it did not install, // otherwise what verification said. Decides the reason below. let mut outcomes: Vec> = Vec::new(); - for tag in &candidates { + // Whether the bundled torch was moved aside, so a failure can + // put exactly it back (#731). + let mut swapped = false; + // A re-run (a new app version) on a CUDA build that already + // works has nothing to install. + let already = verify_cuda_torch(&python) == CudaVerify::Verified; + if already { + append_to_setup_log(&data_dir, "CUDA torch already installed and verified"); + cuda_verified = true; + } + for tag in candidates.iter().filter(|_| !already) { append_to_setup_log( &data_dir, &format!( @@ -1970,6 +2003,31 @@ fn ensure_torch_device( torch_version_for_tag(tag) ), ); + // Each build goes into clean directories. The first moves + // the bundled torch aside; later ones clear the build + // before them, so no build is laid over another. + if let Some(site) = &site { + if !swapped { + match torchswap::snapshot(site) { + Ok(n) => { + swapped = true; + append_to_setup_log( + &data_dir, + &format!("moved the bundled torch aside ({n} entries)"), + ); + } + Err(e) => append_to_setup_log( + &data_dir, + &format!("could not move the bundled torch aside ({e}); installing over it"), + ), + } + } else if let Err(e) = torchswap::discard_installed(site) { + append_to_setup_log( + &data_dir, + &format!("could not clear the previous CUDA build ({e})"), + ); + } + } let index_url = format!("https://download.pytorch.org/whl/{tag}"); if let Err(e) = install_cuda_torch(&python, &index_url, &state, &app) { append_to_setup_log(&data_dir, &format!("{tag} install failed: {e}")); @@ -1987,15 +2045,32 @@ fn ensure_torch_device( append_to_setup_log(&data_dir, &format!("{tag} installed but did not verify")); } let reason = if cuda_verified { + if swapped { + if let Some(site) = &site { + if let Err(e) = torchswap::commit(site) { + append_to_setup_log( + &data_dir, + &format!("could not delete the torch backup ({e})"), + ); + } + } + } "verified" } else { // CUDA torch is installed but unusable. Falling back to the // "cpu" device is not enough — app/main.py imports torch at // module scope, so a wheel that cannot load keeps the // backend from starting at all. Put the CPU wheels back - // (#324). + // (#324), and check they import (#731). let stage = cuda_failure_stage(&outcomes); - match restore_cpu_torch(&python, &state, &app) { + match put_cpu_torch_back( + &python, + site.as_deref(), + swapped, + &state, + &app, + &data_dir, + ) { Ok(()) => stage, Err(e) => { append_to_setup_log( @@ -2924,6 +2999,99 @@ fn restore_cpu_torch( ) } +/// The site-packages this Python installs into, asked of the interpreter +/// itself rather than assumed from the layout, which differs between the +/// Windows and Linux bundles and the downloaded runtime pack. +#[cfg(not(target_os = "macos"))] +fn torch_site_packages(python: &Path) -> Option { + let mut command = Command::new(python); + command + .args([ + "-c", + "import sysconfig; print(sysconfig.get_paths()['purelib'])", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + hide_console_window(&mut command); + let out = command_output_with_timeout(command, Duration::from_secs(60), "site-packages probe") + .ok() + .filter(|out| out.status.success())?; + let path = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + path.is_dir().then_some(path) +} + +/// Whether `import torch` works. A restore that leaves torch unimportable is +/// a failed restore, whatever pip said (#731). +#[cfg(not(target_os = "macos"))] +fn verify_torch_imports(python: &Path) -> Result<(), String> { + let mut command = Command::new(python); + command + .args(["-c", "import torch"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + hide_console_window(&mut command); + let out = command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "torch import check")?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = stderr.trim().lines().rev().take(3).collect(); + Err(tail.into_iter().rev().collect::>().join("\n")) +} + +/// Put CPU torch back after CUDA did not work out, and prove it imports. +/// +/// With the bundled torch moved aside, that is a rename back: exact, and no +/// network. Without one (the move failed, or the install broke before this +/// fix existed and has no backup), the CPU wheels are reinstalled, and if +/// torch still cannot import, everything torch owns is deleted and they are +/// installed once more into clean directories. That second pass is what +/// repairs an install already carrying a stray CUDA DLL (#723). +#[cfg(not(target_os = "macos"))] +fn put_cpu_torch_back( + python: &Path, + site: Option<&Path>, + swapped: bool, + state: &BackendState, + app: &tauri::AppHandle, + data_dir: &Path, +) -> Result<(), String> { + if let (true, Some(site)) = (swapped, site) { + match torchswap::restore(site) { + Ok(()) => match verify_torch_imports(python) { + Ok(()) => { + append_to_setup_log(data_dir, "put the bundled torch back"); + return Ok(()); + } + Err(e) => append_to_setup_log( + data_dir, + &format!("the restored torch does not import:\n{e}"), + ), + }, + Err(e) => append_to_setup_log( + data_dir, + &format!("could not put the bundled torch back ({e})"), + ), + } + } + restore_cpu_torch(python, state, app)?; + let Err(first) = verify_torch_imports(python) else { + return Ok(()); + }; + append_to_setup_log( + data_dir, + &format!("CPU torch reinstalled over the old files but does not import:\n{first}"), + ); + let site = site.ok_or("torch does not import and site-packages is unknown")?; + torchswap::discard_installed(site) + .map_err(|e| format!("could not clear the broken torch ({e})"))?; + restore_cpu_torch(python, state, app)?; + verify_torch_imports(python) + .map_err(|e| format!("CPU torch does not import after a clean reinstall:\n{e}"))?; + append_to_setup_log(data_dir, "CPU torch reinstalled into clean directories"); + Ok(()) +} + #[cfg(not(target_os = "macos"))] fn install_cuda_torch( python: &Path, @@ -2931,11 +3099,9 @@ fn install_cuda_torch( state: &BackendState, app: &tauri::AppHandle, ) -> Result<(), String> { - // Skip only when CUDA torch is already active — torch.version.cuda is - // None for CPU-only wheels, so this correctly re-installs when needed. - if verify_cuda_torch(python) == CudaVerify::Verified { - return Ok(()); - } + // Whether a working CUDA build is already installed is decided once, by + // the caller, before any build is moved aside: asked here, after the + // bundled torch has been moved, it would always say no. // Fix the build machine's Python path baked into pyvenv.cfg before pip // runs — pip validates the `home` entry and fails if it doesn't exist. diff --git a/desktop/src-tauri/src/torchswap.rs b/desktop/src-tauri/src/torchswap.rs new file mode 100644 index 00000000..c00b6647 --- /dev/null +++ b/desktop/src-tauri/src/torchswap.rs @@ -0,0 +1,358 @@ +//! Swapping the bundled torch for a CUDA build, and back, without leftovers. +//! +//! The CUDA install used to lay the CUDA wheels over the CPU ones with +//! `pip install --ignore-installed`, and the restore laid the CPU wheels back +//! over those. Overlaying replaces shared files but never removes files only +//! the CUDA wheel has. On Windows torch loads every DLL in `torch/lib`, so a +//! leftover `c10_cuda.dll` from one torch version failed to link against the +//! restored CPU DLLs from another, and `import torch` raised WinError 127 for +//! good (#731, reported in #723). +//! +//! So the torch the bundle shipped is moved aside before the first CUDA +//! install, each candidate goes into clean directories, and a failed attempt +//! is rolled back by deleting what was installed and moving the originals +//! back: exact, and with no network. +//! +//! What belongs to torch is read from pip's own `RECORD` files, never guessed +//! from names, so nothing else in site-packages is ever touched. + +use std::collections::BTreeSet; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// The distributions a CUDA install replaces. +const PACKAGES: [&str; 3] = ["torch", "torchaudio", "torchvision"]; +/// Next to site-packages, so moving into it is a rename on the same volume. +const BACKUP_DIR: &str = ".stemdeck-torch-backup"; +/// Written, and flushed to disk, before anything is moved. +const MANIFEST: &str = "manifest.txt"; + +/// Where the backup for this site-packages lives. +pub fn backup_root(site: &Path) -> PathBuf { + site.parent().unwrap_or(site).join(BACKUP_DIR) +} + +/// Whether a swap was started and not finished: a crash, a forced quit, or a +/// rollback that did not complete. +pub fn has_backup(site: &Path) -> bool { + backup_root(site).join(MANIFEST).is_file() +} + +/// Whether a dist-info directory name belongs to one of the torch packages. +fn is_torch_dist_info(name: &str) -> bool { + let Some(stem) = name.strip_suffix(".dist-info") else { + return false; + }; + let Some((dist, _version)) = stem.split_once('-') else { + return false; + }; + PACKAGES.iter().any(|p| p.eq_ignore_ascii_case(dist)) +} + +/// The top-level names in `site` that the installed torch packages own: their +/// dist-info directories and the first path segment of every file listed in +/// their RECORD. Only names that exist are returned. +pub fn owned_entries(site: &Path) -> io::Result> { + let mut owned = BTreeSet::new(); + for entry in fs::read_dir(site)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if !is_torch_dist_info(&name) { + continue; + } + owned.insert(name.clone()); + match fs::read_to_string(entry.path().join("RECORD")) { + Ok(record) => { + for line in record.lines() { + let path = line.split(',').next().unwrap_or("").trim(); + let first = path.split(['/', '\\']).next().unwrap_or(""); + // "../../Scripts/torchrun.exe" and the like live outside + // site-packages; they are replaced in place and harmless. + if first.is_empty() || first == "." || first == ".." { + continue; + } + owned.insert(first.to_string()); + } + } + // A damaged install with no RECORD still owns its package dir. + Err(_) => { + if let Some((dist, _)) = name.trim_end_matches(".dist-info").split_once('-') { + owned.insert(dist.to_lowercase()); + } + } + } + } + owned.retain(|name| name != BACKUP_DIR && site.join(name).exists()); + Ok(owned) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if path.is_dir() { + fs::remove_dir_all(path) + } else if path.exists() { + fs::remove_file(path) + } else { + Ok(()) + } +} + +/// Move the installed torch packages aside. Fails without moving anything if +/// there is no torch to move or a backup already exists. +pub fn snapshot(site: &Path) -> io::Result { + let backup = backup_root(site); + if has_backup(site) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "a torch backup already exists; restore it first", + )); + } + let entries = owned_entries(site)?; + if entries.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no torch packages found to back up", + )); + } + fs::create_dir_all(&backup)?; + // The manifest goes first and reaches the disk before anything moves, so + // a crash part way through leaves a record of what to put back. + let mut manifest = fs::File::create(backup.join(MANIFEST))?; + for name in &entries { + writeln!(manifest, "{name}")?; + } + manifest.sync_all()?; + drop(manifest); + for name in &entries { + fs::rename(site.join(name), backup.join(name))?; + } + Ok(entries.len()) +} + +/// Delete whatever torch packages are installed now. Used between candidates, +/// so one CUDA build is never laid over another. +pub fn discard_installed(site: &Path) -> io::Result<()> { + for name in owned_entries(site)? { + remove_entry(&site.join(name))?; + } + Ok(()) +} + +/// Put the backed-up torch back exactly, removing anything installed since. +/// Safe to run after a crash at any point in `snapshot` or in itself. +pub fn restore(site: &Path) -> io::Result<()> { + let backup = backup_root(site); + let Ok(manifest) = fs::read_to_string(backup.join(MANIFEST)) else { + return Ok(()); + }; + let names: BTreeSet = manifest + .lines() + .map(str::trim) + .filter(|n| !n.is_empty() && *n != "." && *n != ".." && !n.contains(['/', '\\'])) + .map(str::to_string) + .collect(); + // Moved names come back, replacing whatever is there now. A name the + // manifest lists but the backup lacks was never moved, so it is still the + // original and stays. + for name in &names { + let saved = backup.join(name); + if !saved.exists() { + continue; + } + remove_entry(&site.join(name))?; + fs::rename(&saved, site.join(name))?; + } + // Then anything torch-owned that the original did not have: the CUDA + // build's own dist-info, its extra top-level packages. + for name in owned_entries(site)? { + if !names.contains(&name) { + remove_entry(&site.join(name))?; + } + } + fs::remove_dir_all(&backup) +} + +/// The swap worked: the backup is no longer needed. +pub fn commit(site: &Path) -> io::Result<()> { + let backup = backup_root(site); + if backup.exists() { + fs::remove_dir_all(backup)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A site-packages with torch as pip lays it out, plus a neighbour that + /// must never be touched. + fn fake_site(root: &Path, version: &str, extra_lib: Option<&str>) -> PathBuf { + let site = root.join("Lib").join("site-packages"); + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10.dll"), format!("c10 {version}")).unwrap(); + let mut record = String::from("torch/lib/c10.dll,,\n"); + // A leftover from an earlier overlay: on disk, but in no RECORD, + // exactly as the reporter's c10_cuda.dll was. + if let Some(dll) = extra_lib { + fs::write(lib.join(dll), "cuda").unwrap(); + } + fs::create_dir_all(site.join("functorch")).unwrap(); + fs::write(site.join("functorch").join("__init__.py"), version).unwrap(); + record.push_str("functorch/__init__.py,,\n../../Scripts/torchrun.exe,,\n"); + let dist = site.join(format!("torch-{version}.dist-info")); + fs::create_dir_all(&dist).unwrap(); + record.push_str(&format!("torch-{version}.dist-info/RECORD,,\n")); + fs::write(dist.join("RECORD"), record).unwrap(); + fs::create_dir_all(site.join("numpy")).unwrap(); + fs::write(site.join("numpy").join("__init__.py"), "numpy").unwrap(); + site + } + + /// Lay a CUDA torch into `site` the way pip would into clean directories. + fn install_cuda(site: &Path) { + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10.dll"), "c10 cuda").unwrap(); + fs::write(lib.join("c10_cuda.dll"), "cuda").unwrap(); + let dist = site.join("torch-2.6.0+cu124.dist-info"); + fs::create_dir_all(&dist).unwrap(); + fs::write( + dist.join("RECORD"), + "torch/lib/c10.dll,,\ntorch/lib/c10_cuda.dll,,\ntorch-2.6.0+cu124.dist-info/RECORD,,\n", + ) + .unwrap(); + } + + #[test] + fn owned_entries_come_from_record_and_leave_neighbours_alone() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + let owned = owned_entries(&site).unwrap(); + assert!(owned.contains("torch")); + assert!(owned.contains("functorch")); + assert!(owned.contains("torch-2.6.0+cpu.dist-info")); + assert!(!owned.contains("numpy")); + assert!(!owned.contains("..")); + } + + /// The #723 install: rolling back must leave no c10_cuda.dll behind, and + /// the original c10.dll byte for byte. + #[test] + fn a_rollback_leaves_no_cuda_dll_behind() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + assert!( + !site.join("torch").exists(), + "the original was not moved aside" + ); + install_cuda(&site); + + restore(&site).unwrap(); + + let lib = site.join("torch").join("lib"); + assert!(!lib.join("c10_cuda.dll").exists()); + assert_eq!( + fs::read_to_string(lib.join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + assert!(site.join("torch-2.6.0+cpu.dist-info").exists()); + assert!(!site.join("torch-2.6.0+cu124.dist-info").exists()); + assert_eq!( + fs::read_to_string(site.join("numpy").join("__init__.py")).unwrap(), + "numpy" + ); + assert!(!has_backup(&site)); + } + + #[test] + fn a_second_candidate_installs_into_clean_directories() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + discard_installed(&site).unwrap(); + assert!(!site.join("torch").exists()); + assert!(!site.join("torch-2.6.0+cu124.dist-info").exists()); + assert!(site.join("numpy").exists()); + assert!( + has_backup(&site), + "the original must survive between candidates" + ); + } + + #[test] + fn a_verified_swap_drops_the_backup() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + commit(&site).unwrap(); + assert!(!has_backup(&site)); + assert!(site.join("torch").join("lib").join("c10_cuda.dll").exists()); + } + + /// A crash part way through the snapshot: some entries moved, some not. + /// Restoring puts back what moved and keeps what never did. + #[test] + fn a_snapshot_interrupted_part_way_is_recovered() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + let backup = backup_root(&site); + fs::create_dir_all(&backup).unwrap(); + let entries = owned_entries(&site).unwrap(); + let listed: Vec<&String> = entries.iter().collect(); + fs::write( + backup.join(MANIFEST), + listed.iter().map(|n| format!("{n}\n")).collect::(), + ) + .unwrap(); + fs::rename(site.join(listed[0]), backup.join(listed[0])).unwrap(); + + assert!(has_backup(&site)); + restore(&site).unwrap(); + + assert_eq!(owned_entries(&site).unwrap(), entries); + assert_eq!( + fs::read_to_string(site.join("torch").join("lib").join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + assert!(!has_backup(&site)); + } + + #[test] + fn a_second_snapshot_refuses_rather_than_overwrite_the_first() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + snapshot(&site).unwrap(); + install_cuda(&site); + assert!(snapshot(&site).is_err()); + restore(&site).unwrap(); + assert_eq!( + fs::read_to_string(site.join("torch").join("lib").join("c10.dll")).unwrap(), + "c10 2.6.0+cpu" + ); + } + + #[test] + fn nothing_to_restore_is_not_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", None); + restore(&site).unwrap(); + assert!(site.join("torch").exists()); + } + + /// An install that already has the leftover DLL (the reporter's state) + /// is cleared entirely by discard_installed, ahead of a clean reinstall. + #[test] + fn a_broken_install_is_cleared_before_reinstalling() { + let tmp = tempfile::tempdir().unwrap(); + let site = fake_site(tmp.path(), "2.6.0+cpu", Some("c10_cuda.dll")); + discard_installed(&site).unwrap(); + assert!(!site.join("torch").exists()); + assert!(!site.join("functorch").exists()); + assert!(site.join("numpy").exists()); + } +} From 244d8e843efea9abb5f1dea56e0a8221974a6975 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 14:27:20 +0100 Subject: [PATCH 7/9] fix(analyze): stop reporting minor keys as their relative major Key detection multiplied each key's profile correlation by how loud its root was. In a minor song the relative major's root is the minor chord's third and sits in most of a typical loop's chords, so it is usually louder: every i-VI-III-VII loop came out as its relative major at up to 100% confidence ("Plug in Baby", B minor, read D major). Harmonic minor was never reported, and confidence changed with the input's level. - Keys are ranked by correlation alone, which is level-invariant. - The keys close to the best (within 0.1) and their relatives are then weighed against the song's edges: how well the first and, when the whole song was analysed, last 4 s of music fit each key's tonic triad and bass note. A song starts and ends on its tonic far more often than anywhere else, which is what a whole-song histogram cannot show. - A minor key is reported as harmonic minor when its raised seventh outweighs the flat seventh 1.3 to 1. - After separation the key is detected again from the stems: harmony stems without drums, the bass stem as the bass line, and the whole song. Any failure keeps the estimate from the mix. - Chroma uses 93 ms frames: 4.1 s instead of 8.6 s on a 9.5 min track's stems, same score. The stems pass takes about 1 s per minute of audio. Measured on tests/keyset.py, 33 synthetic songs with known keys (chord loops over a bass line, 11 progressions in 3 keys), key and scale both right: before 21 of 33 (27 keys right) after 30 of 33 from the mix, 30 of 33 from stems The three misses are vi-IV-I-V, which starts and ends on its minor chord. A real 9.5 min track (E minor) reads the same before and after. Already analysed tracks keep their stored key; nothing is re-analysed. tests/test_key_detection.py scores the set and the reporter's loop, and fails on the old detector. Closes #726 --- app/pipeline/analyze.py | 352 ++++++++++++++++++++++++++++++------ app/pipeline/runner.py | 11 +- tests/keyset.py | 106 +++++++++++ tests/test_key_detection.py | 156 ++++++++++++++++ 4 files changed, 564 insertions(+), 61 deletions(-) create mode 100644 tests/keyset.py create mode 100644 tests/test_key_detection.py diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py index 60d60c13..2acbb3f1 100644 --- a/app/pipeline/analyze.py +++ b/app/pipeline/analyze.py @@ -54,6 +54,34 @@ # and minor is the better default when the call is genuinely ambiguous. _MINOR_TIE_BREAK_FRAC = 0.05 +# How much the song's edges count against the correlation when choosing +# between close candidates, a key and its relative above all. Tuned on +# tests/keyset.py: the reporter's +# i-VI-III-VII leads its relative major by up to 0.22 in correlation and +# trails it by about 0.8 in edge fit, and in every song detected correctly the +# edges agree with the correlation, so weighing them in never flips those. +_EDGE_WEIGHT = 0.4 +# Keys this close to the best correlation, and their relatives, are all +# candidates when the edges are weighed in. A loop like i-VII-VI-VII puts the +# VII's own key ahead of the tonic's by a hair, and neither is the other's +# relative. +_FAMILY_WINDOW = 0.1 +# Without edges, a relative pair this close in correlation counts as a tie. +_RELATIVE_MARGIN = 0.15 +# Harmonic minor is reported when the raised seventh outweighs the flat one by +# this much and reaches this share of the loudest pitch class. On +# tests/keyset.py the ratio is at most 0.77 for natural minor and at least 1.54 +# for harmonic minor, on the mix and on stems; the floor only keeps two +# near-silent pitch classes from deciding it. +_HARMONIC_RATIO = 1.3 +_HARMONIC_FLOOR = 0.1 +# A correlation gap this wide over the nearest unrelated key is full +# confidence; an edge margin this wide settles a relative pair outright. +_CONFIDENCE_FULL_GAP = 0.25 +_EDGE_CONFIDENT_MARGIN = 0.3 +# How much of the start and end of the music counts as its edges. +_EDGE_SECONDS = 4.0 + def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float: n = len(profile) @@ -68,66 +96,131 @@ def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> f return num / (denom_p * denom_c) -def _detect_key(chroma_mean: list[float]) -> tuple[str, str, int]: - """Find the best-matching key by combining profile correlation with - root prominence. The Pearson correlation alone is fooled by relative - keys whose diatonic notes happen to overlap with the song's loud - pitches but whose own tonic is weak (e.g. picking A minor for an - E-minor song because E is its 5th and D is its 4th). Weighting by - the candidate root's chroma value forces the algorithm to also - confirm 'is this proposed tonic actually loud in the audio?'. - Logs the chroma vector and top-5 candidates for diagnostics. +def _key_candidates(chroma_mean: list[float]) -> list[tuple[float, int, str]]: + """Every key's Pearson correlation with its profile, best first, as + (correlation, root, "maj" | "min").""" + out = [] + for shift in range(12): + out.append((_correlate(_MAJOR_PROFILE, chroma_mean, shift), shift, "maj")) + out.append((_correlate(_MINOR_PROFILE, chroma_mean, shift), shift, "min")) + out.sort(key=lambda c: c[0], reverse=True) + return out + + +def _relative(root: int, mode: str) -> tuple[int, str]: + return ((root + 9) % 12, "min") if mode == "maj" else ((root + 3) % 12, "maj") + + +def _tonic_fit(root: int, mode: str, edges: list[tuple[list[float], list[float]]]) -> float: + """How much the song's edges sound like this key's home chord. + + Each edge is (chroma, bass chroma) averaged over a few seconds at the start + or the end of the music, each scaled to a maximum of 1. A song starts and + ends on its tonic far more often than on any other chord, and that is the + one thing a whole-song histogram cannot show: a minor loop such as + i-VI-III-VII uses exactly its relative major's notes (#726). + """ + triad = {root, (root + (4 if mode == "maj" else 3)) % 12, (root + 7) % 12} + score = 0.0 + for harmony, bass in edges: + inside = sum(harmony[i] for i in triad) / 3 + outside = sum(harmony[i] for i in range(12) if i not in triad) / 9 + score += inside - outside + bass[root] + return score / len(edges) + + +def _detect_key( + chroma_mean: list[float], + edges: list[tuple[list[float], list[float]]] | None = None, +) -> tuple[str, str, int]: + """Find the key: profile correlation narrows it to a few close candidates, + and the song's edges decide between them, a key and its relative above all. + + It used to multiply each key's correlation by how loud its root was. In a + minor song the relative major's root is the minor chord's third, and is + also in two of the other three chords of a typical loop, so it is usually + the louder one: every i-VI-III-VII progression came out as its relative + major, at up to 100% confidence (#726). Correlation alone is scale + invariant and does not have that pull; what it cannot do is tell relative + keys apart, which is the edges' job. + + `edges` is optional: without it a near-tie between a key and its relative + falls back on the pop/rock minor prior. Returns (label, scale_name, confidence_pct). - - label: e.g. "G# maj" - - scale_name: "Major" or "Natural Minor" - - confidence_pct: 0-100, derived from the gap between the winning - candidate and the runner-up, normalized so a clear - win ranks high and a near-tie ranks low.""" - raw: list[tuple[float, float, str, int]] = [] # (weighted, pearson, label, root_idx) - for shift in range(12): - root_strength = chroma_mean[shift] - pearson_maj = _correlate(_MAJOR_PROFILE, chroma_mean, shift) - pearson_min = _correlate(_MINOR_PROFILE, chroma_mean, shift) - # Multiplicative root weighting. Pearson can be negative; when - # it is, a low-chroma root makes things less negative (closer to - # zero), which is actually the desired ordering. - raw.append((pearson_maj * root_strength, pearson_maj, f"{_PITCHES[shift]} maj", shift)) - raw.append((pearson_min * root_strength, pearson_min, f"{_PITCHES[shift]} min", shift)) - raw.sort(key=lambda x: x[0], reverse=True) - - # Diagnostic log: chroma profile + top 5 candidates with both raw - # and weighted scores. Lets us see what the algorithm is "hearing". + - label: e.g. "G# maj" + - scale_name: "Major", "Natural Minor" or "Harmonic Minor" + - confidence_pct: 0-100 + """ + candidates = _key_candidates(chroma_mean) + best = candidates[0] + rel_root, rel_mode = _relative(best[1], best[2]) + rel = next(c for c in candidates if c[1] == rel_root and c[2] == rel_mode) + chroma_str = ", ".join(f"{_PITCHES[i]}={chroma_mean[i]:.3f}" for i in range(12)) - top5_str = ", ".join( - f"{label}={weighted:+.3f}(p{pearson:+.2f}*r{chroma_mean[idx]:.2f})" - for weighted, pearson, label, idx in raw[:5] - ) + top5_str = ", ".join(f"{_PITCHES[r]} {m}={p:+.3f}" for p, r, m in candidates[:5]) logger.debug("chroma: %s", chroma_str) logger.debug("key candidates (top 5): %s", top5_str) - # Pick best major and best minor for the tie-break, both by the - # weighted score. - best_maj = next(c for c in raw if c[2].endswith("maj")) - best_min = next(c for c in raw if c[2].endswith("min")) - - gap = abs(best_maj[0] - best_min[0]) - threshold = max(abs(best_maj[0]), abs(best_min[0])) * _MINOR_TIE_BREAK_FRAC - # Near-tie -> prefer minor (pop/rock prior); clear winner -> use it. - winner = (best_maj if best_maj[0] > best_min[0] else best_min) if gap > threshold else best_min - - # Confidence: gap between the winner and the runner-up that *isn't* - # the relative major/minor of the winner (those will always be near- - # ties with the algorithm's profile-correlation approach, so they - # tell us nothing about real ambiguity). Normalize so a healthy 0.15 - # gap = 100% confident; tiny gap = 0%. - runner_up = next(c for c in raw if c[2] != winner[2]) - confidence_score = winner[0] - runner_up[0] - confidence_pct = max(0, min(100, round(confidence_score / 0.15 * 100))) - - label = winner[2] - scale_name = "Major" if label.endswith("maj") else "Natural Minor" - return label, scale_name, confidence_pct + winner = best + # How clearly the winner was separated from its closest rival, 0-1. + clarity = 1.0 + if edges: + by_key = {(c[1], c[2]): c for c in candidates} + pool = set() + for c in candidates: + if best[0] - c[0] <= _FAMILY_WINDOW: + pool.add((c[1], c[2])) + pool.add(_relative(c[1], c[2])) + scored = sorted( + ( + (by_key[k][0] + _EDGE_WEIGHT * _tonic_fit(k[0], k[1], edges), by_key[k]) + for k in pool + ), + key=lambda s: s[0], + reverse=True, + ) + logger.debug( + "with edges: %s", + ", ".join(f"{_PITCHES[c[1]]} {c[2]}={score:.3f}" for score, c in scored[:5]), + ) + winner = scored[0][1] + runner = scored[1][0] if len(scored) > 1 else scored[0][0] - _EDGE_CONFIDENT_MARGIN + clarity = min(1.0, (scored[0][0] - runner) / _EDGE_CONFIDENT_MARGIN) + elif best[0] - rel[0] < _RELATIVE_MARGIN: + # Near-tie with nothing else to go on: prefer minor, the pop/rock + # prior ("Come As You Are" hammers the open D of an E minor song). + threshold = max(abs(best[0]), abs(rel[0])) * _MINOR_TIE_BREAK_FRAC + if best[0] - rel[0] <= threshold and rel[2] == "min": + winner = rel + clarity = 0.5 + + root, mode = winner[1], winner[2] + label = f"{_PITCHES[root]} {mode}" + + scale_name = "Major" + if mode == "min": + # The raised seventh is the harmonic minor's signature (a major V); + # the natural minor's is the flat seventh. Only called when the raised + # one clearly dominates and is actually sounding. + raised = chroma_mean[(root + 11) % 12] + flat = chroma_mean[(root + 10) % 12] + peak = max(chroma_mean) or 1.0 + harmonic = raised > _HARMONIC_RATIO * flat and raised > _HARMONIC_FLOOR * peak + scale_name = "Harmonic Minor" if harmonic else "Natural Minor" + + # Confidence: how clearly the key family won, against the best candidate + # that is neither the winner nor its relative (relative keys correlate + # alike by construction, so they say nothing about the family), then + # scaled by how clearly the pair itself was separated. + # With edges, the margin over the runner-up already covers both, since + # every close candidate was in the running. + if edges: + return label, scale_name, round(clarity * 100) + others = [c for c in candidates if (c[1], c[2]) not in {(best[1], best[2]), (rel[1], rel[2])}] + family_gap = best[0] - others[0][0] + confidence = min(1.0, max(0.0, family_gap / _CONFIDENCE_FULL_GAP)) * clarity + return label, scale_name, round(confidence * 100) def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: @@ -261,12 +354,11 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: # chroma_cqt is constant-Q based — better pitch resolution than # chroma_stft, especially in the bass register where the open # strings of a guitar live. - chroma = librosa.feature.chroma_cqt(y=y_harmonic, sr=sr) - chroma_mean = chroma.mean(axis=1).tolist() - if any(chroma_mean): - key, scale, key_confidence = _detect_key(chroma_mean) - else: - key, scale, key_confidence = None, None, None + # The decode stops at 180 s, so the song's last chord is only among + # the evidence when the whole song fitted. + whole_song = y.size < 179.5 * sr + detected = detect_key_from_audio(y_harmonic, None, sr, whole_song=whole_song) + key, scale, key_confidence = detected if detected else (None, None, None) # LUFS / peak. Computed on the same 22 kHz mono buffer; this # loses a few dB of accuracy vs full-sample-rate stereo, but @@ -310,3 +402,143 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: logger.exception("analyze failed for job %s", job.id) _set(job, stage="Analysis skipped") return None, None + + +def _key_evidence( + harmony: object, + bass: object | None, + sr: int, + whole_song: bool, +) -> tuple[list[float], list[tuple[list[float], list[float]]]]: + """The whole-song chroma, and the chroma and bass chroma at the edges of + the music, for _detect_key. + + `bass` is the separated bass stem when there is one. Without it, the low + three octaves of `harmony` stand in for it. The end of the song is only an + edge when `whole_song`: a decode capped part way through ends mid-song. + """ + import librosa + import numpy as np + + # 93 ms frames. Key needs no finer: measured on a 9.5 min track's stems, + # 512 took 8.6 s for both chromas and 2048 took 4.1 s, with the same + # score on tests/keyset.py. + hop = 2048 + chroma = librosa.feature.chroma_cqt(y=harmony, sr=sr, hop_length=hop) + low = bass if bass is not None else harmony + bass_chroma = librosa.feature.chroma_cqt( + y=low, sr=sr, hop_length=hop, fmin=librosa.note_to_hz("C1"), n_octaves=3 + ) + level = librosa.feature.rms(y=harmony if bass is None else harmony + bass, hop_length=hop)[0] + frames = min(chroma.shape[1], bass_chroma.shape[1], level.shape[0]) + chroma, bass_chroma, level = chroma[:, :frames], bass_chroma[:, :frames], level[:frames] + + # Music, not the silence or count-in before it: frames within 20 dB of + # the loudest. + active = np.flatnonzero(level > 0.1 * (level.max() or 1.0)) + span = max(1, int(_EDGE_SECONDS * sr / hop)) + + def region(a: int, b: int) -> tuple[list[float], list[float]]: + h = chroma[:, a:b].mean(axis=1) + lo = bass_chroma[:, a:b].mean(axis=1) + return (h / (h.max() or 1.0)).tolist(), (lo / (lo.max() or 1.0)).tolist() + + edges = [] + if active.size: + start = int(active[0]) + edges.append(region(start, start + span)) + if whole_song: + end = int(active[-1]) + 1 + edges.append(region(max(0, end - span), end)) + return chroma.mean(axis=1).tolist(), edges + + +def detect_key_from_audio( + harmony: object, bass: object | None, sr: int, whole_song: bool = True +) -> tuple[str, str, int] | None: + """Key, scale and confidence from mono audio, or None for silence.""" + chroma_mean, edges = _key_evidence(harmony, bass, sr, whole_song) + if not any(chroma_mean): + return None + return _detect_key(chroma_mean, edges) + + +# Stems that carry harmony. Vocals count for half: a melody leans on the tonic +# at phrase ends, but it also dwells on passing notes. +_HARMONY_STEMS = {"guitar": 1.0, "piano": 1.0, "other": 1.0, "vocals": 0.5} +# The stems are decoded whole up to this long, so the end of the song counts. +_STEM_KEY_MAX_SECONDS = 600.0 + + +def refine_key_from_stems(job: Job, stems_dir: Path) -> None: + """Detect the key again from the separated stems, and keep it if it works. + + The first estimate comes from the full mix before separation, so the key + shows while separation runs. The stems are better evidence: the bass line + on its own says which note is home, drums no longer smear the chroma, and + the whole song, including its last chord, is available rather than the + first 180 s. Any failure keeps the first estimate. + """ + import numpy as np + + sr = 22050 + try: + harmony = None + heard = [] + longest = 0 + for name, weight in _HARMONY_STEMS.items(): + path = stems_dir / f"{name}.wav" + if not path.is_file(): + continue + loaded = _load_audio_ffmpeg(path, sr=sr, duration=_STEM_KEY_MAX_SECONDS) + if loaded is None: + continue + y = loaded[0] * weight + longest = max(longest, y.size) + harmony = y if harmony is None else _sum_padded(harmony, y) + heard.append(name) + if harmony is None: + logger.info("key from stems: no harmony stems for job %s, keeping %s", job.id, job.key) + return + bass = None + bass_path = stems_dir / "bass.wav" + if bass_path.is_file(): + loaded = _load_audio_ffmpeg(bass_path, sr=sr, duration=_STEM_KEY_MAX_SECONDS) + if loaded is not None: + bass = loaded[0] + harmony, bass = _pad_pair(harmony, bass) + if bass is None: + logger.info( + "key from stems: no bass stem for job %s, using the harmony's low end", job.id + ) + whole = longest < (_STEM_KEY_MAX_SECONDS - 1) * sr + result = detect_key_from_audio(harmony.astype(np.float32), bass, sr, whole_song=whole) + if result is None: + return + key, scale, confidence = result + if key != job.key or scale != job.scale: + logger.info( + "key from stems (%s%s): %s %s %s%% (mix said %s %s)", + "+".join(heard), + "+bass" if bass is not None else "", + key, + scale, + confidence, + job.key, + job.scale, + ) + _set(job, key=key, scale=scale, key_confidence=confidence) + except Exception: + logger.exception("key from stems failed for job %s, keeping %s", job.id, job.key) + + +def _sum_padded(a: object, b: object) -> object: + a, b = _pad_pair(a, b) + return a + b + + +def _pad_pair(a: object, b: object) -> tuple[object, object]: + import numpy as np + + n = max(a.size, b.size) # type: ignore[attr-defined] + return np.pad(a, (0, n - a.size)), np.pad(b, (0, n - b.size)) # type: ignore[attr-defined] diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 83a70b0f..3bf6028b 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -21,7 +21,7 @@ from app.core.redact import redact from app.core.registry import is_upload, set_proc from app.core.registry import persist as persist_registry -from app.pipeline.analyze import analyze +from app.pipeline.analyze import analyze, refine_key_from_stems from app.pipeline.beatgrid import compute_beat_grid from app.pipeline.collect import ( cleanup_source, @@ -222,6 +222,15 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: cleanup_source(job_dir) job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found] _check_cancel(job) + # The key was first estimated from the mix, before separation. The stems + # are better evidence, the bass line above all (#726). Keeps the first + # estimate on any failure. + # Timed on its own, and still inside "post" as before, so post timings + # from earlier versions compare like for like. + key_start = time.monotonic() + refine_key_from_stems(job, stems_dir) + _lap(job, "key", key_start) + _check_cancel(job) _set(job, stage="Mixing tracks...") original_path = make_original_track(job, job_dir, stems_dir) if original_path is not None: diff --git a/tests/keyset.py b/tests/keyset.py new file mode 100644 index 00000000..9b0736d6 --- /dev/null +++ b/tests/keyset.py @@ -0,0 +1,106 @@ +"""A labelled set of synthetic songs for measuring key detection (#726). + +Each song is a chord loop over a bass line, rendered to audio with numpy, so +the right answer is known and nothing is downloaded. The harmony and the bass +come back separately as well as mixed, which is what lets the full-mix path +and the stems path be scored on the same material. + +The progressions are the ones that make relative keys hard: a minor loop such +as i-VI-III-VII uses exactly the notes of its relative major. Like real songs, +each loop starts on its tonic chord and the song ends on it. +""" + +from __future__ import annotations + +import zlib +from dataclasses import dataclass + +import numpy as np + +SR = 22050 +PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + +# Chords as (semitones above the tonic, quality). "M" major, "m" minor. +PROGRESSIONS: dict[str, tuple[str, list[tuple[int, str]]]] = { + # Major keys + "I-V-vi-IV": ("maj", [(0, "M"), (7, "M"), (9, "m"), (5, "M")]), + "I-IV-V-I": ("maj", [(0, "M"), (5, "M"), (7, "M"), (0, "M")]), + "I-vi-IV-V": ("maj", [(0, "M"), (9, "m"), (5, "M"), (7, "M")]), + "I-bVII-IV-I": ("maj", [(0, "M"), (10, "M"), (5, "M"), (0, "M")]), + "vi-IV-I-V": ("maj", [(9, "m"), (5, "M"), (0, "M"), (7, "M")]), + # Natural minor keys + "i-VI-III-VII": ("min", [(0, "m"), (8, "M"), (3, "M"), (10, "M")]), + "i-VII-VI-VII": ("min", [(0, "m"), (10, "M"), (8, "M"), (10, "M")]), + "i-iv-v-i": ("min", [(0, "m"), (5, "m"), (7, "m"), (0, "m")]), + "i-III-VII-iv": ("min", [(0, "m"), (3, "M"), (10, "M"), (5, "m")]), + # Harmonic minor: a major V carries the raised seventh + "i-iv-V-i": ("hmin", [(0, "m"), (5, "m"), (7, "M"), (0, "m")]), + "i-VI-iv-V": ("hmin", [(0, "m"), (8, "M"), (5, "m"), (7, "M")]), +} + +# Every progression in three keys, spread round the circle. +TONICS = (11, 4, 7) # B, E, G + + +@dataclass +class Song: + name: str + tonic: int + mode: str # "maj", "min" or "hmin" + harmony: np.ndarray + bass: np.ndarray + + @property + def mix(self) -> np.ndarray: + return self.harmony + self.bass + + @property + def label(self) -> str: + return f"{PITCHES[self.tonic]} {'maj' if self.mode == 'maj' else 'min'}" + + @property + def scale(self) -> str: + return {"maj": "Major", "min": "Natural Minor", "hmin": "Harmonic Minor"}[self.mode] + + +def _tone(freq: float, n: int, harmonics: int, rng: np.random.Generator) -> np.ndarray: + t = np.arange(n) / SR + phase = rng.uniform(0, 2 * np.pi) + out = np.zeros(n) + for h in range(1, harmonics + 1): + out += np.sin(2 * np.pi * freq * h * t + phase * h) / h + # A short attack and release, so chord changes are not clicks. + env = np.minimum(1.0, np.minimum(t / 0.02, (n / SR - t) / 0.05)) + return out * np.clip(env, 0, 1) + + +def _midi_freq(midi: int) -> float: + return 440.0 * 2 ** ((midi - 69) / 12) + + +def render(name: str, tonic: int, bars_per_chord: int = 1, loops: int = 4, bpm: int = 110) -> Song: + mode, chords = PROGRESSIONS[name] + rng = np.random.default_rng(zlib.crc32(f"{name}/{tonic}".encode())) + bar = int(SR * 4 * 60 / bpm) * bars_per_chord + harmony: list[np.ndarray] = [] + bass: list[np.ndarray] = [] + sequence = chords * loops + [chords[0]] # end on the tonic chord + for offset, quality in sequence: + root = tonic + offset + third = 4 if quality == "M" else 3 + chord = np.zeros(bar) + for interval in (0, third, 7): + chord += _tone(_midi_freq(60 + (root + interval) % 12), bar, 6, rng) + harmony.append(0.12 * chord) + # Root on the beat, fifth on the offbeats: a plain rock bass line. + beat = bar // (4 * bars_per_chord) + line = np.zeros(bar) + for b in range(4 * bars_per_chord): + note = root if b % 2 == 0 else root + 7 + line[b * beat : (b + 1) * beat] = _tone(_midi_freq(36 + note % 12), beat, 4, rng) + bass.append(0.25 * line) + return Song(name, tonic % 12, mode, np.concatenate(harmony), np.concatenate(bass)) + + +def songs() -> list[Song]: + return [render(name, tonic) for name in PROGRESSIONS for tonic in TONICS] diff --git a/tests/test_key_detection.py b/tests/test_key_detection.py new file mode 100644 index 00000000..617f44a1 --- /dev/null +++ b/tests/test_key_detection.py @@ -0,0 +1,156 @@ +"""Telling a minor key from its relative major (#726). + +A minor loop such as i-VI-III-VII uses exactly the notes of its relative +major, so a whole-song pitch histogram cannot separate them. The detector used +to break the tie by how loud each candidate's root was, and in a minor song +the relative major's root is usually louder: every i-VI-III-VII came out as +its relative major, at up to 100% confidence. "Plug in Baby", in B minor, +came out as D major. + +These score the detector on tests/keyset.py, synthetic songs whose keys are +known. On that set the old detector got 27 of 33 keys and 21 of 33 keys with +their scale; it never reported harmonic minor. +""" + +from __future__ import annotations + +import wave +from pathlib import Path + +import numpy as np +import pytest + +from app.core.models import Job +from app.pipeline import analyze as az +from tests import keyset +from tests.ffmpeg_probe import skip_without_ffmpeg + + +def _stems_key(song: keyset.Song) -> tuple[str, str, int]: + result = az.detect_key_from_audio( + song.harmony.astype(np.float32), song.bass.astype(np.float32), keyset.SR + ) + assert result is not None + return result + + +def _mix_key(song: keyset.Song) -> tuple[str, str, int]: + import librosa + + harmonic, _ = librosa.effects.hpss(song.mix.astype(np.float32)) + result = az.detect_key_from_audio(harmonic, None, keyset.SR) + assert result is not None + return result + + +def test_the_reported_loop_is_minor_from_the_mix(): + # B minor, i-VI-III-VII: the old detector said D major. + song = keyset.render("i-VI-III-VII", 11) + label, scale, _ = _mix_key(song) + assert (label, scale) == ("B min", "Natural Minor") + + +@pytest.mark.parametrize( + "name, tonic", + [("i-VI-III-VII", 11), ("i-VII-VI-VII", 7), ("I-V-vi-IV", 4), ("i-iv-v-i", 4)], +) +def test_keys_from_stems(name, tonic): + song = keyset.render(name, tonic) + label, scale, _ = _stems_key(song) + assert (label, scale) == (song.label, song.scale) + + +@pytest.mark.parametrize("name", ["i-iv-V-i", "i-VI-iv-V"]) +def test_a_major_five_reads_as_harmonic_minor(name): + song = keyset.render(name, 4) + label, scale, _ = _stems_key(song) + assert (label, scale) == ("E min", "Harmonic Minor") + + +def test_the_whole_labelled_set_from_stems(): + # 30 of 33 when this was written. The three misses are vi-IV-I-V, which + # starts and ends on its minor chord and reads as minor: ambiguous enough + # that the set's "major" label is arguable. + songs = keyset.songs() + right = sum(_stems_key(s)[:2] == (s.label, s.scale) for s in songs) + assert right >= 30, f"{right} of {len(songs)}" + + +def test_confidence_does_not_depend_on_level(): + # It did: the old score multiplied correlation by a raw chroma value, so + # the same vector read 100% raw and 21% normalised. + vec = [0.9, 0.05, 0.4, 0.05, 0.7, 0.4, 0.05, 0.8, 0.05, 0.5, 0.05, 0.4] + edges = [([1.0, 0, 0, 0, 0.8, 0, 0, 0.9, 0, 0, 0, 0], [1.0] + [0.0] * 11)] + for with_edges in (None, edges): + loud = az._detect_key([v * 5 for v in vec], with_edges) + quiet = az._detect_key([v / 5 for v in vec], with_edges) + assert loud == quiet + + +def test_edges_decide_between_a_key_and_its_relative(): + # The same histogram, opened on a C major chord or on an A minor one. + diatonic = [1.0, 0.05, 0.8, 0.05, 0.9, 0.8, 0.05, 0.9, 0.05, 0.9, 0.05, 0.7] + c_chord = [1.0, 0, 0, 0, 0.9, 0, 0, 0.9, 0, 0, 0, 0] + a_chord = [0.9, 0, 0, 0, 0.9, 0, 0, 0, 0, 1.0, 0, 0] + c_bass = [1.0] + [0.0] * 11 + a_bass = [0.0] * 9 + [1.0, 0.0, 0.0] + assert az._detect_key(diatonic, [(c_chord, c_bass)])[0] == "C maj" + assert az._detect_key(diatonic, [(a_chord, a_bass)])[0] == "A min" + + +# ── refine_key_from_stems ──────────────────────────────────────────── + + +def _write_wav(path: Path, samples: np.ndarray, sr: int) -> None: + pcm = (np.clip(samples, -1, 1) * 32767).astype(" Job: + job = Job(id="a1b2c3d4e5f6") + job.key, job.scale, job.key_confidence = key, scale, 90 + return job + + +def _stems_dir(job: Job) -> Path: + # The decoder only reads inside the jobs directory, which the test + # fixtures point at a temporary one. + d = az.JOBS_DIR / job.id / "stems" + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_stems_replace_the_mix_estimate(): + skip_without_ffmpeg() + song = keyset.render("i-VI-III-VII", 11) + job = _job() + stems = _stems_dir(job) + _write_wav(stems / "other.wav", song.harmony, keyset.SR) + _write_wav(stems / "bass.wav", song.bass, keyset.SR) + az.refine_key_from_stems(job, stems) + assert (job.key, job.scale) == ("B min", "Natural Minor") + + +def test_no_stems_keeps_the_mix_estimate(): + job = _job() + az.refine_key_from_stems(job, _stems_dir(job)) + assert (job.key, job.scale, job.key_confidence) == ("D maj", "Major", 90) + + +def test_a_failure_keeps_the_mix_estimate(monkeypatch): + skip_without_ffmpeg() + song = keyset.render("i-VI-III-VII", 11) + job = _job() + stems = _stems_dir(job) + _write_wav(stems / "other.wav", song.harmony, keyset.SR) + + def boom(*_a, **_k): + raise RuntimeError("chroma exploded") + + monkeypatch.setattr(az, "detect_key_from_audio", boom) + az.refine_key_from_stems(job, stems) + assert (job.key, job.scale, job.key_confidence) == ("D maj", "Major", 90) From f78874c94b8870e5a9a7fa0359b5907cde6fe9e7 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 14:35:33 +0100 Subject: [PATCH 8/9] fix(pipeline): count the stems key pass inside post A separate "key" stage timing broke the timings' order, which tests/test_identify_regressions.py holds as a contract: new entries may only follow the existing stages. The pass is short and already inside post, so it is counted there. Refs #726 --- app/pipeline/runner.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 3bf6028b..ab484902 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -224,12 +224,9 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: _check_cancel(job) # The key was first estimated from the mix, before separation. The stems # are better evidence, the bass line above all (#726). Keeps the first - # estimate on any failure. - # Timed on its own, and still inside "post" as before, so post timings - # from earlier versions compare like for like. - key_start = time.monotonic() + # estimate on any failure. Counted in "post", not as a stage of its own: + # the timings' order is a contract (tests/test_identify_regressions.py). refine_key_from_stems(job, stems_dir) - _lap(job, "key", key_start) _check_cancel(job) _set(job, stage="Mixing tracks...") original_path = make_original_track(job, job_dir, stems_dir) From 4e81173f5836610342ac1b9a3e598600a1b79089 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 30 Sep 2026 21:42:56 +0100 Subject: [PATCH 9/9] test(desktop): check the torch rollback on a real torch install An ignored test, run by hand: copies a real site-packages torch (1.2 GB here), moves it aside, lays a CUDA build with a stray c10_cuda.dll into its place, rolls back, and compares every file's SHA-256 with the original. Passed against the dev venv's torch 2.6.0+cpu, which then imported and ran a tensor op from the restored copy. Refs #731 --- desktop/src-tauri/src/torchswap.rs | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/desktop/src-tauri/src/torchswap.rs b/desktop/src-tauri/src/torchswap.rs index c00b6647..522ca625 100644 --- a/desktop/src-tauri/src/torchswap.rs +++ b/desktop/src-tauri/src/torchswap.rs @@ -185,6 +185,92 @@ pub fn commit(site: &Path) -> io::Result<()> { mod tests { use super::*; + fn tree_hashes(root: &Path) -> std::collections::BTreeMap> { + use sha2::{Digest, Sha256}; + let mut out = std::collections::BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + } else { + let digest = Sha256::digest(fs::read(&path).unwrap()).to_vec(); + out.insert(path.strip_prefix(root).unwrap().to_path_buf(), digest); + } + } + } + out + } + + fn copy_tree(from: &Path, to: &Path) { + if from.is_dir() { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + copy_tree(&entry.path(), &to.join(entry.file_name())); + } + } else { + fs::copy(from, to).unwrap(); + } + } + + /// The rollback on a real torch install rather than a fake one: gigabytes, + /// thousands of files, real RECORDs. Run by hand, with a site-packages to + /// copy from and a folder to work in (left behind, so the result can be + /// imported afterwards): + /// + /// STEMDECK_REAL_TORCH_SITE= STEMDECK_REAL_TORCH_WORK= + /// cargo test real_torch -- --ignored + #[test] + #[ignore] + fn a_real_torch_comes_back_byte_for_byte() { + let (Ok(source), Ok(work)) = ( + std::env::var("STEMDECK_REAL_TORCH_SITE"), + std::env::var("STEMDECK_REAL_TORCH_WORK"), + ) else { + panic!("set STEMDECK_REAL_TORCH_SITE and STEMDECK_REAL_TORCH_WORK"); + }; + let source = PathBuf::from(source); + let site = PathBuf::from(work).join("Lib").join("site-packages"); + if site.exists() { + fs::remove_dir_all(&site).unwrap(); + } + let _ = fs::remove_dir_all(backup_root(&site)); + fs::create_dir_all(&site).unwrap(); + let owned = owned_entries(&source).unwrap(); + assert!(owned.contains("torch"), "no torch in {}", source.display()); + for name in &owned { + copy_tree(&source.join(name), &site.join(name)); + } + let before = tree_hashes(&site); + + let moved = snapshot(&site).unwrap(); + assert_eq!(moved, owned.len()); + // A CUDA build laid into the clean directories, with a DLL the CPU + // build does not have. + let lib = site.join("torch").join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::write(lib.join("c10_cuda.dll"), "cuda").unwrap(); + fs::write(lib.join("c10.dll"), "cuda c10").unwrap(); + let dist = site.join("torch-2.8.0+cu128.dist-info"); + fs::create_dir_all(&dist).unwrap(); + fs::write( + dist.join("RECORD"), + "torch/lib/c10.dll,,\ntorch/lib/c10_cuda.dll,,\ntorch-2.8.0+cu128.dist-info/RECORD,,\n", + ) + .unwrap(); + + restore(&site).unwrap(); + + assert_eq!( + tree_hashes(&site), + before, + "the restored torch differs from the original" + ); + assert!(!has_backup(&site)); + } + /// A site-packages with torch as pip lays it out, plus a neighbour that /// must never be touched. fn fake_site(root: &Path, version: &str, extra_lib: Option<&str>) -> PathBuf {