From c0a2310252c9c59393d0f2b6c2ad54b0ea8990d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 09:28:27 +0900 Subject: [PATCH] feat(chords): Krumhansl-Schmuckler key detection Add a KeyDetector that estimates the musical key of a mono audio array using the standard Krumhansl-Schmuckler method: a CQT chromagram averaged into a 12-bin pitch-class profile is Pearson-correlated (computed with numpy, no scipy) against the 24 rotated Krumhansl-Kessler major/minor key profiles. Confidence blends the clamped best correlation with the gap to the runner-up, bounded to [0, 1]. Degenerate input (empty audio, empty or all-zero chromagram) returns an empty result and no exception escapes detect(). chroma_cqt is called with tuning=0.0 to keep detection deterministic and avoid an unstable native pitch-track path on pure synthetic tones. Tests synthesize known-key audio: a C-major scale detects "C major", an A-minor scale detects "A minor", and empty audio returns the empty result. New module has 100% line coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RjGVapDZ3k7V7zKYk16P4C --- .../bandscope_analysis/chords/key_detector.py | 153 ++++++++++++++++++ .../tests/test_key_detector.py | 122 ++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 services/analysis-engine/src/bandscope_analysis/chords/key_detector.py create mode 100644 services/analysis-engine/tests/test_key_detector.py diff --git a/services/analysis-engine/src/bandscope_analysis/chords/key_detector.py b/services/analysis-engine/src/bandscope_analysis/chords/key_detector.py new file mode 100644 index 00000000..a2f3216c --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/chords/key_detector.py @@ -0,0 +1,153 @@ +"""Musical key detection using the Krumhansl-Schmuckler algorithm.""" + +import logging +from typing import TypedDict + +import librosa +import numpy as np + +logger = logging.getLogger(__name__) + +# Pitch-class names ordered from C upward. +_NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + +# Krumhansl-Kessler experimental key profiles for the major and minor modes. +_MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]) +_MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]) + + +class KeyResult(TypedDict): + """Result of key detection for an audio excerpt.""" + + key: str + tonic: str + mode: str + confidence: float + + +def _empty_result() -> KeyResult: + """Return the canonical empty result used for degenerate or failed input.""" + return {"key": "", "tonic": "", "mode": "", "confidence": 0.0} + + +def _pearson(a: np.ndarray, b: np.ndarray) -> float: + """Compute the Pearson correlation coefficient of two equal-length vectors. + + Returns 0.0 when either vector has zero variance, since correlation is + undefined in that case. + """ + a_centered = a - a.mean() + b_centered = b - b.mean() + denominator = float(np.sqrt(np.sum(a_centered**2) * np.sum(b_centered**2))) + if denominator == 0.0: + return 0.0 + return float(np.sum(a_centered * b_centered) / denominator) + + +class KeyDetector: + """Estimate the musical key of audio via Krumhansl-Schmuckler profile matching. + + The detector builds a 12-bin pitch-class profile from a constant-Q + chromagram, then correlates it against the 24 rotated Krumhansl-Kessler + major and minor key profiles. The rotation with the highest Pearson + correlation names the key. Confidence is derived from the gap between the + best and second-best correlations (see ``detect``), giving a bounded value + in ``[0.0, 1.0]``. + + Security Notes: + - Operates on untrusted in-memory audio arrays only. + - No file, network, or shell access of any kind. + - Bounded by the size of the passed input array. + - Safe failure: degenerate input returns an empty result and no exception + is allowed to escape ``detect``. + """ + + def detect(self, audio: np.ndarray, sr: int) -> KeyResult: + """Detect the musical key of a mono audio signal. + + Args: + audio: Mono audio samples as a 1-D float numpy array. + sr: Sample rate of ``audio`` in hertz. + + Returns: + A mapping with ``key`` (e.g. ``"C major"``), ``tonic``, ``mode`` + (``"major"`` or ``"minor"``) and ``confidence`` in ``[0.0, 1.0]``. + Degenerate or failing input yields the empty result + ``{"key": "", "tonic": "", "mode": "", "confidence": 0.0}``. + """ + if audio.size == 0: + return _empty_result() + + try: + # tuning=0.0 skips librosa's internal tuning estimation, which keeps + # detection deterministic and avoids an unstable native pitch-track + # code path on pure synthetic tones. + chroma = librosa.feature.chroma_cqt(y=audio, sr=sr, tuning=0.0) + except Exception: # noqa: BLE001 - safe failure: never raise to caller. + logger.exception("chroma_cqt failed during key detection") + return _empty_result() + + if chroma.size == 0: + return _empty_result() + + # Average the chromagram over time into a 12-bin pitch-class profile. + profile = np.asarray(chroma, dtype=np.float64).mean(axis=1) + + total = float(profile.sum()) + if total <= 0.0: + return _empty_result() + profile = profile / total + + return self._match_profile(profile) + + def _match_profile(self, profile: np.ndarray) -> KeyResult: + """Correlate a pitch-class profile against all 24 key profiles. + + Args: + profile: A normalized 12-bin pitch-class profile. + + Returns: + The best-matching key as a :class:`KeyResult`. + """ + correlations: list[tuple[float, str, str]] = [] + for tonic_index in range(12): + major_rotated = np.roll(_MAJOR_PROFILE, tonic_index) + minor_rotated = np.roll(_MINOR_PROFILE, tonic_index) + correlations.append( + (_pearson(profile, major_rotated), _NOTE_NAMES[tonic_index], "major") + ) + correlations.append( + (_pearson(profile, minor_rotated), _NOTE_NAMES[tonic_index], "minor") + ) + + correlations.sort(key=lambda item: item[0], reverse=True) + best_corr, tonic, mode = correlations[0] + second_corr = correlations[1][0] + + return { + "key": f"{tonic} {mode}", + "tonic": tonic, + "mode": mode, + "confidence": self._confidence(best_corr, second_corr), + } + + @staticmethod + def _confidence(best_corr: float, second_corr: float) -> float: + """Map correlation scores to a bounded confidence in ``[0.0, 1.0]``. + + Confidence blends how strong the best correlation is with how clearly + it separates from the runner-up. It is the mean of the clamped best + correlation (negative correlations clamped to zero) and the clamped + gap to the second-best correlation, keeping the result within + ``[0.0, 1.0]``. + + Args: + best_corr: Pearson correlation of the winning key profile. + second_corr: Pearson correlation of the runner-up key profile. + + Returns: + A confidence score bounded to ``[0.0, 1.0]``. + """ + strength = min(max(best_corr, 0.0), 1.0) + gap = min(max(best_corr - second_corr, 0.0), 1.0) + return float((strength + gap) / 2.0) diff --git a/services/analysis-engine/tests/test_key_detector.py b/services/analysis-engine/tests/test_key_detector.py new file mode 100644 index 00000000..9164b9f0 --- /dev/null +++ b/services/analysis-engine/tests/test_key_detector.py @@ -0,0 +1,122 @@ +"""Tests for the Krumhansl-Schmuckler key detector.""" + +from unittest.mock import patch + +import numpy as np + +from bandscope_analysis.chords.key_detector import ( + KeyDetector, + _empty_result, + _pearson, +) + +SAMPLE_RATE = 22050 + +# Concert-pitch fundamental frequencies (fourth octave) by note name. +_NOTE_FREQS = { + "C": 261.63, + "C#": 277.18, + "D": 293.66, + "D#": 311.13, + "E": 329.63, + "F": 349.23, + "F#": 369.99, + "G": 392.00, + "G#": 415.30, + "A": 440.00, + "A#": 466.16, + "B": 493.88, +} + + +def _tone(freq: float, duration: float, sr: int = SAMPLE_RATE, amp: float = 1.0) -> np.ndarray: + """Synthesize a single sine tone of the given frequency and duration.""" + t = np.linspace(0, duration, int(sr * duration), endpoint=False) + return amp * np.sin(2 * np.pi * freq * t) + + +def _scale(notes: list[tuple[str, float]], sr: int = SAMPLE_RATE) -> np.ndarray: + """Synthesize a melody from (note name, duration) pairs concatenated in time.""" + return np.concatenate([_tone(_NOTE_FREQS[name], dur, sr) for name, dur in notes]) + + +def test_detect_c_major_scale() -> None: + """A C-major scale is detected as C major.""" + notes = [ + ("C", 0.6), + ("D", 0.3), + ("E", 0.3), + ("F", 0.3), + ("G", 0.3), + ("A", 0.3), + ("B", 0.3), + ("C", 0.6), + ] + result = KeyDetector().detect(_scale(notes), SAMPLE_RATE) + assert result["tonic"] == "C" + assert result["mode"] == "major" + assert result["key"] == "C major" + assert 0.0 <= result["confidence"] <= 1.0 + assert result["confidence"] > 0.0 + + +def test_detect_a_minor_scale() -> None: + """An A-minor scale with an emphasized tonic is detected in the minor mode on A.""" + notes = [ + ("A", 0.9), + ("B", 0.3), + ("C", 0.3), + ("D", 0.3), + ("E", 0.6), + ("F", 0.3), + ("G", 0.3), + ("A", 0.9), + ] + result = KeyDetector().detect(_scale(notes), SAMPLE_RATE) + assert result["mode"] == "minor" + assert result["tonic"] == "A" + assert result["key"] == "A minor" + assert 0.0 <= result["confidence"] <= 1.0 + + +def test_detect_empty_audio() -> None: + """Empty audio returns the empty result with zero confidence.""" + result = KeyDetector().detect(np.array([], dtype=np.float64), SAMPLE_RATE) + assert result == {"key": "", "tonic": "", "mode": "", "confidence": 0.0} + + +def test_detect_chroma_cqt_exception() -> None: + """A failure inside chroma_cqt yields the empty result and never raises.""" + audio = _tone(_NOTE_FREQS["C"], 1.0) + with patch("librosa.feature.chroma_cqt", side_effect=RuntimeError("boom")): + result = KeyDetector().detect(audio, SAMPLE_RATE) + assert result == _empty_result() + + +def test_detect_empty_chroma() -> None: + """An empty chromagram yields the empty result.""" + audio = _tone(_NOTE_FREQS["C"], 1.0) + with patch("librosa.feature.chroma_cqt", return_value=np.empty((12, 0))): + result = KeyDetector().detect(audio, SAMPLE_RATE) + assert result == _empty_result() + + +def test_detect_all_zero_chroma() -> None: + """An all-zero (degenerate) chromagram yields the empty result.""" + audio = _tone(_NOTE_FREQS["C"], 1.0) + with patch("librosa.feature.chroma_cqt", return_value=np.zeros((12, 4))): + result = KeyDetector().detect(audio, SAMPLE_RATE) + assert result == _empty_result() + + +def test_pearson_zero_variance() -> None: + """Pearson correlation of a constant vector is defined as zero.""" + constant = np.ones(12) + varying = np.arange(12, dtype=np.float64) + assert _pearson(constant, varying) == 0.0 + + +def test_pearson_perfect_correlation() -> None: + """Pearson correlation of identical varying vectors is 1.0.""" + varying = np.arange(12, dtype=np.float64) + assert _pearson(varying, varying) == 1.0