Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
122 changes: 122 additions & 0 deletions services/analysis-engine/tests/test_key_detector.py
Original file line number Diff line number Diff line change
@@ -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
Loading