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/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..ab484902 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,12 @@ 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. 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) + _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/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b1ad729c..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 @@ -1959,7 +1982,20 @@ 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; - for tag in &candidates { + // 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(); + // 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!( @@ -1967,13 +2003,41 @@ 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}")); + 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; @@ -1981,19 +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). - let stage = if cuda_installed { - "cuda-verify-failed" - } else { - "cuda-install-failed" - }; - match restore_cpu_torch(&python, &state, &app) { + // (#324), and check they import (#731). + let stage = cuda_failure_stage(&outcomes); + match put_cpu_torch_back( + &python, + site.as_deref(), + swapped, + &state, + &app, + &data_dir, + ) { Ok(()) => stage, Err(e) => { append_to_setup_log( @@ -2325,13 +2402,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 @@ -2912,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, @@ -2919,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) { - 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. @@ -3015,8 +3193,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 @@ -3050,7 +3269,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() { @@ -3060,7 +3279,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 @@ -3071,7 +3294,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 } } } @@ -6211,6 +6434,84 @@ 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"] + ); + } + + /// 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"))] + #[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 +6535,8 @@ mod tests { ]; let caps = [ None, + Some("5.2"), + Some("6.1"), Some("7.5"), Some("8.6"), Some("8.9"), diff --git a/desktop/src-tauri/src/torchswap.rs b/desktop/src-tauri/src/torchswap.rs new file mode 100644 index 00000000..522ca625 --- /dev/null +++ b/desktop/src-tauri/src/torchswap.rs @@ -0,0 +1,444 @@ +//! 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::*; + + 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 { + 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()); + } +} 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.` ); diff --git a/static/css/daw.css b/static/css/daw.css index 8370923a..4ae32ca0 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; } @@ -996,6 +1019,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; } @@ -1363,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 { @@ -5431,8 +5470,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/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 bf2c0c70..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) : "—"; @@ -894,16 +890,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); }; } @@ -923,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"); @@ -931,7 +919,6 @@ function applyTrackInfoToPanel(track) { summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); - summaryConfidenceLabel?.classList.remove("hidden"); } } } @@ -1425,6 +1412,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 +1466,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 +1669,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 +1678,9 @@ function renderRecentItem(trackId) {
${trackSublineHtml(track)}
+
${favButtonHtml(track)}
`; + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); return el; } @@ -1702,7 +1738,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 +1748,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 +1761,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { e.stopPropagation(); moveTrackToTrash(trackId); }); + wireFavButton(el, trackId); wireTrackDragAndLoad(el, trackId); @@ -4006,7 +4043,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..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", @@ -763,6 +766,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", @@ -802,6 +806,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", @@ -1264,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", @@ -1560,6 +1571,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", @@ -1602,6 +1614,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", @@ -2052,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": "テンポ安定性", @@ -2343,6 +2362,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": "標準", @@ -2381,6 +2401,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": "フォルダを削除", @@ -2815,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": "速度稳定性", @@ -3106,6 +3133,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": "标准", @@ -3144,6 +3172,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": "删除文件夹", @@ -3578,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", @@ -3872,6 +3907,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", @@ -3912,6 +3948,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", @@ -4352,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", @@ -4646,6 +4689,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", @@ -4686,6 +4730,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", @@ -5128,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", @@ -5419,6 +5470,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", @@ -5457,6 +5509,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", @@ -5891,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", @@ -6186,6 +6245,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", @@ -6225,6 +6285,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", @@ -6422,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…", @@ -6837,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", @@ -7132,6 +7200,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", @@ -7172,6 +7241,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", @@ -7634,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": "템포 안정성", @@ -7925,6 +8001,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": "표준", @@ -7963,6 +8040,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/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/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); + }); +}); 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); 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) 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