diff --git a/.jules/bolt.md b/.jules/bolt.md index d54cf10fc..a13b4b1a1 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -61,3 +61,7 @@ ## 2026-07-13 - Array.from mapping optimization **Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components. **Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations. + +## 2026-08-23 - Vectorize Python loop over audio frames in probability matrix construction +**Learning:** Computing observation probabilities per frame using a `for i in range(n_frames)` loop in pure Python generates significant overhead when handling audio segments (e.g. 50k+ frames), creating a slow bottleneck before Viterbi decoding. +**Action:** Replace sequential per-frame condition checks with fully vectorized NumPy operations (`np.pad`, `|` boolean mask, and array slicing assignments) to compute logic globally over the time axis, changing slow Python loops into fast C-level operations. diff --git a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py index 8f6466924..124760416 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -294,6 +294,13 @@ def _build_observation_probs( Observation probabilities of shape (25, n_frames). """ n_frames = chromagram.shape[1] + + if similarity.shape != (24, n_frames): + raise ValueError( + f"similarity shape {similarity.shape} does not match " + f"expected shape (24, {n_frames})" + ) + obs_probs = np.zeros((_NUM_CHORD_STATES, n_frames)) # Chord observation likelihoods from template similarity @@ -306,17 +313,20 @@ def _build_observation_probs( # N (no-chord) observation probability based on noise indicators chroma_vars = np.var(chromagram, axis=0) - for i in range(n_frames): - rms_val = rms[i] if i < len(rms) else 0.0 - chroma_var = chroma_vars[i] - max_sim = similarity[:, i].max() if similarity.shape[1] > i else 0.0 - - # High N probability when signal is low/flat - if max_sim < 0.3 or rms_val < 0.01 or chroma_var < 0.02: - obs_probs[:24, i] *= 0.1 - obs_probs[_NO_CHORD_STATE, i] = 0.9 - else: - obs_probs[_NO_CHORD_STATE, i] = 0.05 + + rms_vals = np.pad(rms, (0, max(0, n_frames - len(rms))))[:n_frames] + max_sims = ( + similarity[:, :n_frames].max(axis=0) + if similarity.shape[1] >= n_frames + else np.pad(similarity[:, :].max(axis=0), (0, max(0, n_frames - similarity.shape[1]))) + ) + + condition = (max_sims < 0.3) | (rms_vals < 0.01) | (chroma_vars[:n_frames] < 0.02) + + # High N probability when signal is low/flat + obs_probs[:24, condition] *= 0.1 + obs_probs[_NO_CHORD_STATE, condition] = 0.9 + obs_probs[_NO_CHORD_STATE, ~condition] = 0.05 # Normalize columns col_sums = obs_probs.sum(axis=0, keepdims=True) + 1e-12 diff --git a/services/analysis-engine/tests/test_chord_observation_contract.py b/services/analysis-engine/tests/test_chord_observation_contract.py new file mode 100644 index 000000000..748d63a5c --- /dev/null +++ b/services/analysis-engine/tests/test_chord_observation_contract.py @@ -0,0 +1,28 @@ +"""Test observation probability contracts for the chord recognizer.""" + +import numpy as np +import pytest + +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer + + +def test_observation_probs_reject_similarity_frame_mismatch() -> None: + """Reject similarity arrays that would otherwise broadcast across frames.""" + recognizer = ChordRecognizer() + chromagram = np.ones((12, 5), dtype=np.float64) + similarity = np.ones((24, 1), dtype=np.float64) + rms = np.ones(5, dtype=np.float64) + + with pytest.raises(ValueError, match="similarity shape"): + recognizer._build_observation_probs(chromagram, similarity, rms) + + +def test_observation_probs_reject_similarity_chord_state_mismatch() -> None: + """Reject similarity arrays that do not contain all 24 chord templates.""" + recognizer = ChordRecognizer() + chromagram = np.ones((12, 5), dtype=np.float64) + similarity = np.ones((23, 5), dtype=np.float64) + rms = np.ones(5, dtype=np.float64) + + with pytest.raises(ValueError, match="similarity shape"): + recognizer._build_observation_probs(chromagram, similarity, rms)