from __future__ import annotations import logging import subprocess from pathlib import Path from app.core.config import JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable from app.core.models import Job, _set logger = logging.getLogger("stemdeck.analyze") # Albrecht-Shanahan key profiles, derived from a corpus of popular music # (Albrecht & Shanahan, 2013). Critically, the minor profile here weights # b7 high (3.48) and M7 low (0.81) — the opposite of Temperley/Kostka-Payne, # which were derived from Bach chorales and bias toward harmonic minor's # leading tone. Pop/rock uses natural minor: the b7 is the diatonic # seventh and rings out constantly (e.g. open D in "Come As You Are", # which is in E minor and uses D as the b7). Values rescaled so that the # tonic weight is ≈5 to match the prior code's magnitude. _MAJOR_PROFILE = ( 5.47, 0.14, 2.55, 0.14, 3.15, 2.16, 0.37, 4.92, 0.21, 1.84, 0.18, 1.86, ) _MINOR_PROFILE = ( 5.06, 0.14, 2.42, 2.42, 0.35, 1.96, 0.35, 4.16, 2.53, 0.28, 2.67, 0.62, ) _PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") # When the best-major and best-minor scores are this close, we prefer # minor. Pop/rock has a strong minor-mode prior; the algorithm often # walks toward the relative major because of an ostinato bass note # (e.g. "Come As You Are" hammers the open D string in an E minor song), # and minor is the better default when the call is genuinely ambiguous. _MINOR_TIE_BREAK_FRAC = 0.05 def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float: n = len(profile) rotated = [chroma[(i + shift) % n] for i in range(n)] mean_p = sum(profile) / n mean_c = sum(rotated) / n num = sum((profile[i] - mean_p) * (rotated[i] - mean_c) for i in range(n)) denom_p = sum((profile[i] - mean_p) ** 2 for i in range(n)) ** 0.5 denom_c = sum((rotated[i] - mean_c) ** 2 for i in range(n)) ** 0.5 if denom_p == 0 or denom_c == 0: return 0.0 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. 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". 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] ) 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 def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: """Compute integrated loudness (LUFS, BS.1770) and sample peak (dBFS) of the loaded mono signal. Returns (lufs, peak_db); either may be None on failure or silence. We use sample peak rather than oversampled true peak -- the difference is typically <1 dB and not worth the 4x resample cost for a display field.""" import numpy as np if y is None or getattr(y, "size", 0) == 0: return None, None peak_lin = float(np.abs(y).max()) peak_db = 20.0 * float(np.log10(peak_lin)) if peak_lin > 1e-9 else None lufs: float | None = None try: import pyloudnorm as pyln meter = pyln.Meter(sr) # BS.1770-4 with default 400ms blocks lufs_raw = float(meter.integrated_loudness(y)) # pyloudnorm returns -inf for silence; surface as None instead so # the frontend can hide the field rather than render "-inf LUFS". if np.isfinite(lufs_raw): lufs = lufs_raw except (ImportError, ValueError) as e: # ValueError fires if the clip is shorter than the gating window. logger.warning("LUFS measurement failed: %s", e) return lufs, peak_db def _load_audio_ffmpeg( source: Path, sr: int = 22050, duration: float = 180.0 ) -> tuple[object, int] | None: """Decode `source` to a mono float32 numpy array at `sr` via ffmpeg. Bypasses librosa's deprecated audioread fallback (which fires a FutureWarning on .webm/.m4a/.opus inputs because soundfile can't read those directly). Returns (samples, sr) or None on failure.""" import numpy as np # Defence in depth: even though `source` is constructed by the server # (never user-typed), confirm it's a real file inside JOBS_DIR before # handing it to a subprocess. Belt-and-suspenders against a future # caller change that would let a path slip in from elsewhere. resolved = source.resolve() jobs_resolved = JOBS_DIR.resolve() if not resolved.is_file(): logger.warning("analyze source is not a file: %s", source) return None if not resolved.is_relative_to(jobs_resolved): logger.warning( "analyze source escapes JOBS_DIR (%s not under %s)", resolved, jobs_resolved, ) return None cmd = [ ffmpeg_executable(), "-nostdin", "-loglevel", "error", "-i", str(resolved), "-ac", "1", # mono "-ar", str(sr), # resample "-f", "f32le", # raw 32-bit float little-endian "-t", str(duration), # cap input duration "-", # write to stdout ] try: proc = subprocess.run(cmd, capture_output=True, check=True, timeout=TIMEOUT_ANALYZE) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: logger.warning("ffmpeg decode failed for %s: %s", source, e) return None y = np.frombuffer(proc.stdout, dtype=np.float32) if y.size == 0: return None return y, sr def compute_stem_presence(stems_dir: Path, selected_stems: list[str]) -> dict[str, int]: """Load each extracted stem WAV, compute mean absolute amplitude, normalize to 0-100. Only the stems that were selected (and therefore extracted) are measured; the rest are omitted from the returned dict.""" import numpy as np result: dict[str, int] = {} rms_values: dict[str, float] = {} for name in selected_stems: wav_path = stems_dir / f"{name}.wav" if not wav_path.is_file(): continue loaded = _load_audio_ffmpeg(wav_path, sr=22050, duration=180.0) if loaded is None: continue y, _ = loaded rms_values[name] = float(np.sqrt(np.mean(y**2))) if not rms_values: return result max_rms = max(rms_values.values()) if max_rms < 1e-9: return {name: 0 for name in rms_values} for name, rms in rms_values.items(): result[name] = max(0, min(100, round(rms / max_rms * 100))) return result def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: """Best-effort BPM and key detection. On failure, returns (None, None) and leaves job fields untouched -- the chips stay as placeholders.""" logger.info("analyze: entering for job %s, source=%s", job.id, source) _set(job, status="analyzing", progress=0.0, stage="Analyzing audio...") try: import librosa except ImportError: logger.warning("librosa not installed -- skipping BPM/key analysis") return None, None try: # Analyse the first 180 s. Decode via ffmpeg directly into numpy # to avoid librosa's deprecated audioread fallback for # .webm/.m4a/.opus inputs. loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0) if loaded is None: return None, None y, sr = loaded # Harmonic / percussive separation. Beat tracking sees a cleaner # onset envelope on the percussive component; chroma sees a # cleaner pitch profile on the harmonic component (no cymbal # smear, no kick fundamentals leaking in). y_harmonic, y_percussive = librosa.effects.hpss(y) tempo_arr, beat_frames = librosa.beat.beat_track(y=y_percussive, sr=sr) try: tempo = float(tempo_arr[0]) # type: ignore[index] except (TypeError, IndexError): tempo = float(tempo_arr) bpm = int(round(tempo)) if tempo > 0 else 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 # LUFS / peak. Computed on the same 22 kHz mono buffer; this # loses a few dB of accuracy vs full-sample-rate stereo, but # it's good enough for a UI display and adds ~50 ms to analyze. lufs, peak_db = _measure_loudness(y, sr) dynamic_range: float | None = None if lufs is not None and peak_db is not None: dynamic_range = round(peak_db - lufs, 1) # Beat interval coefficient of variation → stability 0-100. # CV = std/mean of inter-beat intervals; CV=0 is perfectly metronomic. tempo_stability: int | None = None import numpy as np beat_times = librosa.frames_to_time(beat_frames, sr=sr) if len(beat_times) > 2: intervals = np.diff(beat_times) mean_iv = float(intervals.mean()) if mean_iv > 0: cv = float(intervals.std() / mean_iv) tempo_stability = max(0, min(100, round((1 - min(cv, 1)) * 100))) _set( job, bpm=bpm, key=key, scale=scale, key_confidence=key_confidence, lufs=lufs, peak_db=peak_db, dynamic_range=dynamic_range, tempo_stability=tempo_stability, progress=1.0, stage="Analysis complete", ) return bpm, key except Exception as e: logger.exception("analyze failed for job %s", job.id) _set(job, stage=f"Analysis skipped ({e})") return None, None