diff --git a/.trivyignore b/.trivyignore
index 17b6d25b..81f03eac 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -35,3 +35,20 @@ DS-0002
# update. Take the bump the next time the lock moves for another reason, and
# drop this entry then.
CVE-2026-63374
+
+# CVE-2026-97687 (traffic interception through an HTTPS proxy's TLS
+# configuration) and CVE-2026-97689 (denial of service through unbounded
+# memory): urllib3, fixed in 2.8.0; the lock pins 2.7.0.
+#
+# StemDeck's own requests all go through the standard library's urllib.
+# urllib3 is here only through requests, which audio-separator, librosa's
+# pooch and Whisper's tiktoken use to download models and data from fixed
+# hosts (GitHub, Hugging Face, OpenAI's CDN). The first needs an HTTPS proxy
+# configured, the second a hostile server on one of those downloads.
+#
+# Not bumped now for the same reason as CVE-2026-63374: any uv.lock change
+# sends every existing desktop install to a full download instead of an
+# in-app update, and 0.19.0 already did that two days before 0.19.1. Take the
+# bump the next time the lock moves for another reason, and drop these then.
+CVE-2026-97687
+CVE-2026-97689
diff --git a/app/api/jobs.py b/app/api/jobs.py
index bb7a3ae3..eecca686 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -16,7 +16,7 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, StrictBool, field_validator
from app.core.config import (
DISCOGS_LOOKUP_BUDGET_SEC,
@@ -44,6 +44,7 @@
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
+from app.core.registry import set_favorite as registry_set_favorite
from app.core.registry import set_trashed as registry_set_trashed
from app.core.settings import (
get_acoustid_api_key,
@@ -68,6 +69,7 @@
copy_lyrics,
find_lyrics,
keep_answer,
+ lookup_lyrics,
lyrics_path,
lyrics_settled,
read_candidates,
@@ -423,6 +425,25 @@ def restore_job(job_id: str) -> dict:
return {"job_id": job.id, "trashed_at": job.trashed_at}
+class FavoriteBody(BaseModel):
+ """Strict, so a string such as "false" is refused rather than read as true."""
+
+ favorite: StrictBool
+
+
+@router.put("/{job_id}/favorite")
+def set_favorite(job_id: str, body: FavoriteBody) -> dict:
+ """Mark a job as a favourite or take it back out (#734). Kept on the server
+ so the desktop and the phone share one answer."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ job = registry_set_favorite(job_id, body.favorite)
+ if job is None:
+ raise HTTPException(status_code=404, detail="job not found")
+ registry_persist(JOBS_DIR)
+ return {"job_id": job.id, "favorite": job.favorite}
+
+
@router.get("/{job_id}")
def get_job(job_id: str) -> dict:
"""Get the current state of a job by ID."""
@@ -1174,6 +1195,84 @@ def get_lyrics(job_id: str) -> Response:
raise HTTPException(status_code=404, detail="no lyrics")
+class LookupBand(BaseModel):
+ """The band saved on the track from the artist box, which lives in the
+ studio's own store and may not be on the job yet."""
+
+ id: str = Field(pattern=r"^Q\d{1,12}$")
+ name: str = Field(default="", max_length=300)
+ englishName: str = Field(default="", max_length=300) # noqa: N815 (the store's own key)
+
+
+class LyricsLookupBody(BaseModel):
+ band: LookupBand | None = None
+
+
+# Jobs whose lyrics the tab is looking up now, so a second request for the
+# same track waits for the first rather than asking LRCLIB twice.
+_LYRICS_LOOKUPS: set[str] = set()
+
+
+@router.post("/{job_id}/lyrics/lookup")
+async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None) -> Response:
+ """Look the track's lyrics up on LRCLIB now, for the Lyrics tab (#719).
+
+ The tab used to search LRCLIB itself, with its own copy of the rules that
+ decide which version is this song by this artist and which length fits.
+ The two copies had to be kept equal by hand, and when they drifted the
+ same track could get lyrics at import and none in the tab. This runs the
+ import's own lookup instead, keeps what it finds the way the import does,
+ and answers exactly as GET .../lyrics would afterwards.
+
+ Only ever asked when the tab is opened on a track with no lyrics, or the
+ user presses Look up again: LRCLIB is never reached on its own. 502 when
+ LRCLIB cannot be reached, so the tab can offer to try again; 404 with
+ {"nothing_known": true} when the track says too little to look up.
+ """
+ job, job_dir = _lyrics_job(job_id)
+ if await asyncio.to_thread(lyrics_path(job_dir).is_file):
+ return await asyncio.to_thread(get_lyrics, job_id)
+ if job_id in _LYRICS_LOOKUPS:
+ raise HTTPException(status_code=409, detail="already looking")
+ band = body.band.model_dump() if body and body.band else None
+ query = build_query(job, band=band)
+ if query is None:
+ return JSONResponse(
+ {"detail": "nothing to look up", "others": [], "nothing_known": True}, status_code=404
+ )
+ _LYRICS_LOOKUPS.add(job_id)
+ try:
+ answer = await asyncio.wait_for(
+ asyncio.to_thread(
+ lookup_lyrics,
+ query,
+ cancelled=lambda: registry_get(job_id) is not job,
+ fallback_title=job.title or "",
+ ),
+ timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1,
+ )
+ except asyncio.TimeoutError:
+ logger.info("[%s] lyrics lookup timed out", job_id)
+ raise HTTPException(status_code=502, detail="lyrics service unreachable") from None
+ except Exception:
+ logger.info("[%s] lyrics lookup failed", job_id, exc_info=True)
+ raise HTTPException(status_code=502, detail="lyrics service unreachable") from None
+ finally:
+ _LYRICS_LOOKUPS.discard(job_id)
+ if registry_get(job_id) is not job:
+ raise HTTPException(status_code=404, detail="job not found")
+ if (
+ answer is not None
+ and not lyrics_path(job_dir).is_file()
+ and await asyncio.to_thread(keep_answer, job, job_dir, answer)
+ ):
+ registry_persist(JOBS_DIR)
+ try:
+ return await asyncio.to_thread(get_lyrics, job_id)
+ except HTTPException:
+ return JSONResponse({"detail": "no lyrics", "others": []}, status_code=404)
+
+
class LyricsOffsetBody(BaseModel):
"""How many seconds later than their own timing the lyrics are shown:
negative for earlier, 0 for their own timing again."""
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/core/models.py b/app/core/models.py
index 77e4abc9..e51c84d2 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -306,6 +306,16 @@ class Job:
# A timestamp rather than a bool so the Trash can say when, and so a future
# auto-purge has something to work from.
trashed_at: float | None = None
+ # Whether the user marked this track as a favourite, or None if no
+ # client has said either way yet (#734).
+ #
+ # Server-side for the same reason as trashed_at: favourites lived only in
+ # the desktop page's catalog store, so the phone, which builds its library
+ # from GET /api/jobs, had no way to set one or to filter by them. None
+ # rather than False lets the desktop tell "never recorded here" from
+ # "taken back out", so it can hand up favourites it set before this field
+ # existed without undoing one removed on the phone.
+ favorite: bool | None = None
# Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages.
# Not surfaced via to_state() -- it's internal control state.
cancel_requested: bool = False
@@ -366,6 +376,7 @@ def to_state(self) -> dict[str, Any]:
"stage_timings": self.stage_timings,
"vocal_split": self.vocal_split,
"trashed_at": self.trashed_at,
+ "favorite": self.favorite,
"created_at": self.created_at,
}
diff --git a/app/core/registry.py b/app/core/registry.py
index 1a732c3e..9b16793f 100644
--- a/app/core/registry.py
+++ b/app/core/registry.py
@@ -107,6 +107,16 @@ def set_trashed(job_id: str, trashed: bool) -> Job | None:
return job
+def set_favorite(job_id: str, favorite: bool) -> Job | None:
+ """Mark a job as a favourite, or take it back out (#734)."""
+ with _lock:
+ job = _jobs.get(job_id)
+ if job is None:
+ return None
+ job.favorite = favorite
+ return job
+
+
def all_jobs() -> dict[str, Job]:
"""Return a snapshot of the registry for sweep / cleanup."""
with _lock:
diff --git a/app/main.py b/app/main.py
index eb050740..238d576b 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(),
}
@@ -915,18 +919,21 @@ def download_logs_zip() -> StreamingResponse:
# Content-Security-Policy. Defense-in-depth so an injected string in the webview
# can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171.
# script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the
-# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles*
+# inline scripts/onclick were moved out. 'wasm-unsafe-eval' lets WebAssembly
+# compile, which the Signalsmith tempo stage in the audio worklet needs (#729);
+# it does not re-enable JS eval, new Function or string timers. 'unsafe-inline' is allowed for *styles*
# only (the UI sets many style attributes). Allowances:
# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC,
-# Wikidata/Wikipedia for the artist box the now-playing card
-# opens, and LRCLIB for the Lyrics tab (#699). All four are
-# read-only public APIs, asked only about the open track, and
-# sent only an artist and a song name.
+# and Wikidata/Wikipedia for the artist box the now-playing
+# card opens. Read-only public APIs, asked only about the
+# open track, and sent only an artist and a song name. Not
+# LRCLIB: the Lyrics tab asks the server to look lyrics up
+# (#719), so the page never reaches it.
# img-src https: -> remote YouTube/SoundCloud thumbnails
# style/font -> the Google Fonts
_CSP = (
"default-src 'self'; "
- "script-src 'self'; "
+ "script-src 'self' 'wasm-unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com data:; "
"img-src 'self' data: blob: https:; "
@@ -936,8 +943,7 @@ def download_logs_zip() -> StreamingResponse:
# (#186). They are inline/same-origin schemes, not network endpoints, so
# they add no exfiltration channel — script-src below stays locked.
"connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob: "
- "https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org "
- "https://lrclib.net; "
+ "https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org; "
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
)
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/Cargo.lock b/desktop/src-tauri/Cargo.lock
index b6a073b8..0a8becdf 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -103,6 +103,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -2503,11 +2509,11 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
-version = "1.9.0"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"indexmap 2.14.0",
"quick-xml",
"serde",
@@ -2635,9 +2641,9 @@ dependencies = [
[[package]]
name = "quick-xml"
-version = "0.39.3"
+version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
@@ -2990,9 +2996,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.40"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
@@ -3014,9 +3020,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
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