diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..562ea8878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,14 @@ ### Changed - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. +- Vectorized chord observation construction and reduced the NumPy Viterbi parity oracle to rolling scores while preserving exact dense-oracle and Rust numerical behavior. ### Fixed - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Corrected the HMM relative-key transition prior so major chords favor their true relative minor and minor chords favor their true relative major instead of unrelated pitch-class targets. +- Neutralize non-finite chromagram, similarity, and RMS metadata in the chord observation path so corrupt DSP frames stay finite and do not get treated as silence. +- Reconciled draft PR #732 with the protected dependency-security baseline merged by #783 so exact-head npm audit and Trivy evaluate the patched PDF.js, Nanoid, and Undici graph instead of the obsolete pre-#783 lock. ## [0.1.3] - 2026-04-29 diff --git a/docs/doctoring/chord-observation-finiteness.md b/docs/doctoring/chord-observation-finiteness.md new file mode 100644 index 000000000..cd7bd8319 --- /dev/null +++ b/docs/doctoring/chord-observation-finiteness.md @@ -0,0 +1,39 @@ +# Chord observation finiteness + +## Status + +**Active Draft PR evidence.** This record documents the observation-probability robustness contract on PR #732. It is not protected-`develop` shipped truth until the implementation is merged and revalidated on the protected branch. + +## Buyer-visible problem + +The chord HMM consumes per-frame observation probabilities. A single non-finite chromagram bin, template-similarity score, or RMS value turns the corresponding Viterbi column into `NaN`. The decoder then emits an arbitrary or empty rehearsal chord where the player needed either a labeled chord or an honest unknown frame. + +Missing metadata and invalid metadata are different events: + +- missing similarity or RMS is unknown evidence and must stay neutral; +- non-finite similarity invalidates the whole frame and must fall back to a uniform chord distribution; +- non-finite RMS or chromagram variance is unknown, not evidence of silence; and +- confidence computation treats non-finite similarity as unknown evidence and returns low confidence without performing invalid entropy arithmetic. + +The next action for a player is unchanged: read the surviving chord label, or treat a neutralized frame as “listen again” rather than as a forced no-chord cut. + +## Research rationale + +Rabiner (1989) defines HMM decoding over valid observation probabilities; a non-finite column is outside that model. Lee and Slaney (2006) treat frame-level chord observations as the input to Viterbi, so observation integrity is an accuracy precondition rather than a presentation detail. Mauch and Dixon (2010) likewise keep the observation model separate from the transition prior, which is why this repair stays inside probability construction and confidence reporting and does not change the 25-state vocabulary or the Rust production decoder. + +## Test-first verification contract + +- a frame with any non-finite similarity stays finite and column-normalized; +- that frame uses a uniform 24-chord fallback and does not raise the no-chord state above the chord mass; +- non-finite RMS or chromagram variance does not satisfy the silence thresholds; +- confidence on NaN or infinite similarity falls back to `low` without emitting a NumPy `RuntimeWarning` from invalid arithmetic; +- the vectorized implementation matches an independent framewise scalar oracle on the same corrupt inputs; and +- `recognize()` still forwards the full audio array to the harmonic separator, so duration policy stays upstream. + +## References + +Lee, K., & Slaney, M. (2006). Automatic chord recognition from audio using an HMM with supervised learning. In *Proceedings of the 7th International Conference on Music Information Retrieval (ISMIR 2006)* (pp. 133–137). University of Victoria. https://ismir.net/conferences/ismir-2006/ + +Mauch, M., & Dixon, S. (2010). Approximate note transcription for the improved identification of difficult chords. In *Proceedings of the 11th International Society for Music Information Retrieval Conference (ISMIR 2010)* (pp. 135–140). International Society for Music Information Retrieval. https://ismir.net/conferences/ismir-2010/ + +Rabiner, L. R. (1989). A tutorial on hidden Markov models and selected applications in speech recognition. *Proceedings of the IEEE, 77*(2), 257–286. https://doi.org/10.1109/5.18626 diff --git a/docs/doctoring/chord-transition-prior.md b/docs/doctoring/chord-transition-prior.md new file mode 100644 index 000000000..ca52d746d --- /dev/null +++ b/docs/doctoring/chord-transition-prior.md @@ -0,0 +1,48 @@ +# Chord-transition prior evidence + +## Status + +**Active Draft PR evidence.** This record documents the chord-transition correctness repair developed on PR #732. It is not protected-`develop` shipped truth until the implementation is merged and revalidated on the protected branch. + +## Buyer-visible problem + +BandScope smooths frame-level chord evidence with a 25-state major/minor/no-chord HMM before emitting rehearsal chord segments. The transition matrix therefore influences which chord label survives when acoustic evidence is ambiguous. A wrong harmonic prior can turn a plausible rehearsal progression into an unrelated chord change even when the frame observations themselves are sound. + +The existing relative-key calculation was reversed: + +- a major chord root used `+3` semitones for its relative minor target; and +- a minor chord root used `+9` semitones for its relative major target. + +In pitch-class arithmetic that maps C major toward D♯ minor instead of A minor, and A minor toward F♯ major instead of C major. The corrected invariant is: + +- major root → relative minor root: `+9 mod 12` semitones; +- minor root → relative major root: `+3 mod 12` semitones. + +The repair changes only the relation used to populate the existing heuristic transition prior. It does not change the 24-major/minor-plus-no-chord vocabulary, observation model, Viterbi algorithm, Rust/NumPy parity boundary, filesystem/network authority, or persistence format. + +## Research rationale + +Lee and Slaney (2006) established supervised-HMM automatic chord recognition as a sequence-decoding problem in which transition probabilities are part of the model rather than presentation-only metadata. Masada and Bunescu (2019) likewise identify chord-transition information as useful harmonic-syntax evidence and note that transition distributions depend on tonal context. Gotham et al. (2023) provide a large, reproducible functional-harmony meta-corpus suitable for measuring repertoire-dependent transition behavior instead of assuming that chord transitions are equiprobable. + +These sources support two engineering conclusions. First, a transition-prior defect is an accuracy defect because the prior participates directly in decoding. Second, the current hand-authored prior should be treated as a bounded baseline rather than a universal model of harmony: future accuracy work should estimate or calibrate transition probabilities against documented corpora and stratify by musical context instead of silently increasing heuristic complexity. + +## Test-first verification contract + +The regression test names musically recognizable relative pairs and compares them with the exact unrelated pairs favored by the old formula: + +- `C → Am` must have greater transition probability than `C → D#m`; +- `Am → C` must have greater transition probability than `Am → F#`. + +This catches the old formula in both directions without depending on an implementation constant such as the raw `related_prob`. The production correction is then one bounded pitch-class formula change. Because both the native Rust Viterbi path and the NumPy reference consume the same Python-built transition matrix, the relation is shared across the two numerical paths and remains subject to the repository's exact Rust-to-NumPy parity tests. + +## Accuracy follow-up + +A commercial-quality next step is corpus-backed calibration rather than additional unvalidated hand rules. Candidate evaluation should compare the current heuristic prior with learned/key-conditioned priors on an openly documented chord corpus and report sequence metrics such as chord-symbol recall/weighted chord symbol recall together with transition-specific confusion. Any learned prior must retain deterministic fixtures for common progressions and must not reduce the existing native/reference numerical parity or fail-closed behavior. + +## References + +Gotham, M., Micchi, G., López, N. N., & Sailor, M. (2023). When in Rome: A meta-corpus of functional harmony. *Transactions of the International Society for Music Information Retrieval, 6*(1), 150–166. https://doi.org/10.5334/tismir.165 + +Lee, K., & Slaney, M. (2006). Automatic chord recognition from audio using an HMM with supervised learning. In *Proceedings of the 7th International Conference on Music Information Retrieval (ISMIR 2006)* (pp. 133–137). University of Victoria. https://ismir.net/conferences/ismir-2006/ + +Masada, K., & Bunescu, R. C. (2019). Chord recognition in symbolic music: A segmental CRF model, segment-level features, and comparative evaluations on classical and popular music. *Transactions of the International Society for Music Information Retrieval, 2*(1), 1–13. https://doi.org/10.5334/tismir.18 diff --git a/docs/doctoring/pr-732-security-baseline-reconciliation.md b/docs/doctoring/pr-732-security-baseline-reconciliation.md new file mode 100644 index 000000000..1d20e0ea0 --- /dev/null +++ b/docs/doctoring/pr-732-security-baseline-reconciliation.md @@ -0,0 +1,25 @@ +# PR #732 security-baseline reconciliation RCA + +## Scope and exact failing evidence + +BandScope draft PR #732 at exact head `830dd4c982b12e1dcdf896e3e951363f1ec16a15` was based on `develop@acdbea6344fe1231c39535b575f4de35e4c607c9`, before the canonical JavaScript dependency-security repair landed. + +Exact-head `security-audit` run `31964307186`, job `95221704766`, completed checkout and tool setup, installed the committed graph successfully, and then failed `npm audit --workspaces --audit-level=high` on three HIGH dependency classes: `nanoid <3.3.18`, `pdfjs-dist >=5.6.83 <6.2.108`, and `undici 7.0.0-7.28.0`. The aggregate `Security Scan` run `31964307197`, job `95221730550`, independently completed Trivy execution, produced non-empty SARIF, and failed closed on HIGH `CVE-2026-16633` for `pdfjs-dist` at `package-lock.json:6370`; SARIF upload itself succeeded. + +This evidence distinguishes the failure from a runner/setup race, network/provider outage, missing permissions, missing scanner output, stale status-only evidence, or an LLM/reviewer availability failure. The checks were correctly rejecting a vulnerable dependency baseline. They must not be bypassed or suppressed. + +## Causal owner and cross-repository/dependency inspection + +PR #732 changes only chord-recognition Python code, numerical regressions, doctoring, and the changelog relative to its historical base; it does not own the JavaScript dependency graph. The canonical causal owner was the shared BandScope dependency baseline repaired by PR #783, which protected-merged as `7ad56cf0065d068ec6463d92726de4855a6e201d` on 2026-08-25. That repair pins `pdfjs-dist` 6.2.108, `nanoid` 3.3.18 in the resolved graph, `undici` 7.29.0, and the reviewed npm/Corepack lock-generation provenance. Current protected `develop@749511c3ad4000090048718f685c6bee6b3d2c25` contains that repair. + +A three-way file-set inspection from the old PR base showed that #732's product delta and the protected-base security repair overlap only in `CHANGELOG.md`; the source, test, dependency, lockfile, and workflow ownership boundaries are disjoint. The changelog is reconciled additively while preserving the protected branch's later correction to the 0.1.4 testing claim. + +## Repair + +The safe downstream repair is a non-destructive merge of current protected `develop` into the existing #732 branch. This preserves #732's exact chord implementation and tests, inherits the already-reviewed canonical dependency graph instead of duplicating it, and avoids force-push or destructive rebase. No security, review, coverage, provenance, or vulnerability gate is weakened; no status is manufactured; no failing security evidence is treated as passing. + +## Verification contract + +The merge commit itself is not success evidence. Fresh workflows must bind to the resulting unchanged PR head. In particular, `security-audit` and the aggregate `Security Scan`/`trivy-fs` must complete terminal-success against the inherited patched dependency graph. Queued, skipped, pending, cancelled, predecessor-head, or stale same-head evidence remains non-passing. If a fresh failure appears, its exact job logs must be diagnosed before any further source change. + +The repository does not currently contain `docs/product-technical-gap-baseline.md`, so this RCA is recorded in the owning repository's doctoring documentation and `CHANGELOG.md`. \ No newline at end of file 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..25fdf7c4c 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -97,7 +97,9 @@ def _build_transition_matrix(self) -> np.ndarray: # Fifth relationship (e.g. C -> G, Am -> Em) fifth = (root + 7) % 12 fourth = (root + 5) % 12 - relative = (root + 3) % 12 if i < 12 else (root + 9) % 12 + # Relative minor is nine semitones above a major root (C -> A), + # while relative major is three semitones above a minor root (A -> C). + relative = (root + 9) % 12 if i < 12 else (root + 3) % 12 # Same root major/minor interchange parallel = (i + 12) % 24 @@ -168,25 +170,19 @@ def _viterbi_decode_reference(self, observation_probs: np.ndarray) -> np.ndarray log_trans = np.log(self._transition_matrix + 1e-12) log_obs = np.log(observation_probs + 1e-12) - # Uniform initial probability - log_pi = np.full(n_states, np.log(1.0 / n_states)) - - # Viterbi tables - viterbi = np.zeros((n_states, n_frames)) + # Viterbi tables initialized with uniform initial probability + viterbi = np.full(n_states, np.log(1.0 / n_states)) + log_obs[:, 0] backpointer = np.zeros((n_states, n_frames), dtype=np.intp) - # Initialization - viterbi[:, 0] = log_pi + log_obs[:, 0] - - # Forward pass (Vectorized over states for ~7x speedup) + # Forward pass (Vectorized over states) for t in range(1, n_frames): - trans_probs = viterbi[:, t - 1, np.newaxis] + log_trans + trans_probs = viterbi[:, np.newaxis] + log_trans backpointer[:, t] = np.argmax(trans_probs, axis=0) - viterbi[:, t] = np.max(trans_probs, axis=0) + log_obs[:, t] + viterbi = np.max(trans_probs, axis=0) + log_obs[:, t] # Backtrace states = np.zeros(n_frames, dtype=np.intp) - states[-1] = int(np.argmax(viterbi[:, -1])) + states[-1] = int(np.argmax(viterbi)) for t in range(n_frames - 2, -1, -1): states[t] = backpointer[states[t + 1], t + 1] @@ -206,6 +202,8 @@ def _compute_confidence(self, similarity: np.ndarray, best_state: int) -> str: """ if best_state == _NO_CHORD_STATE: return "low" + if similarity.size == 0 or not np.all(np.isfinite(similarity)): + return "low" # Normalize similarities to probability distribution sim_shifted = similarity - similarity.max() @@ -296,27 +294,70 @@ def _build_observation_probs( n_frames = chromagram.shape[1] obs_probs = np.zeros((_NUM_CHORD_STATES, n_frames)) - # Chord observation likelihoods from template similarity - # Normalize similarity per frame to get valid probability-like values - sim_max = similarity.max(axis=0, keepdims=True) - sim_shifted = similarity - sim_max - exp_sim = np.exp(sim_shifted * 2.0) - sim_sum = exp_sim.sum(axis=0, keepdims=True) + 1e-12 - obs_probs[:24, :] = exp_sim / sim_sum + # Chord observation likelihoods from template similarity. A frame with + # any non-finite similarity is unknown evidence: neutralize the entire + # frame rather than allowing partial/corrupt DSP metadata into Viterbi. + n_sim_frames = similarity.shape[1] + valid_similarity_frames = np.all(np.isfinite(similarity), axis=0) + safe_similarity = np.where( + valid_similarity_frames[np.newaxis, :], + similarity, + 0.0, + ) + + if n_sim_frames > 0: + sim_max = safe_similarity.max(axis=0, keepdims=True) + sim_shifted = safe_similarity - sim_max + exp_sim = np.exp(sim_shifted * 2.0) + sim_sum = exp_sim.sum(axis=0, keepdims=True) + 1e-12 + if n_sim_frames >= n_frames: + obs_probs[:24, :] = (exp_sim / sim_sum)[:, :n_frames] + else: + obs_probs[:24, :n_sim_frames] = exp_sim / sim_sum + + # Default uniform probability for any missing frames + if n_sim_frames < n_frames: + obs_probs[:24, n_sim_frames:] = 1.0 / 24.0 - # N (no-chord) observation probability based on noise indicators + # N (no-chord) observation probability based on noise indicators. + # Non-finite chroma variance is unknown, not evidence of a flat signal. 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 + chroma_vars = np.where(np.isfinite(chroma_vars), chroma_vars, 1.0) + + # Missing or non-finite RMS is unknown rather than evidence of silence. + rms_vals = ( + rms[:n_frames] + if len(rms) >= n_frames + else np.pad( + rms, + (0, n_frames - len(rms)), + constant_values=1.0, + ) + ) + rms_vals = np.where(np.isfinite(rms_vals), rms_vals, 1.0) + + # Max similarity per frame: handle array length mismatches explicitly. + # Invalid similarity frames remain neutral for no-chord detection. + if n_sim_frames == 0: + max_sims = np.full(n_frames, 1.0) + else: + sim_max_raw = safe_similarity.max(axis=0) + sim_max_raw = np.where(valid_similarity_frames, sim_max_raw, 1.0) + if n_sim_frames >= n_frames: + max_sims = sim_max_raw[:n_frames] else: - obs_probs[_NO_CHORD_STATE, i] = 0.05 + max_sims = np.pad( + sim_max_raw, + (0, n_frames - n_sim_frames), + constant_values=1.0, + ) + + # Vectorized condition for high N probability when signal is low/flat + mask = (max_sims < 0.3) | (rms_vals < 0.01) | (chroma_vars < 0.02) + + obs_probs[:24, mask] *= 0.1 + obs_probs[_NO_CHORD_STATE, mask] = 0.9 + obs_probs[_NO_CHORD_STATE, ~mask] = 0.05 # Normalize columns col_sums = obs_probs.sum(axis=0, keepdims=True) + 1e-12 @@ -344,12 +385,15 @@ def _create_chord_segments( current_confidence = "low" start_frame = 0 + n_sim_frames = similarity.shape[1] + missing_similarity = np.zeros(similarity.shape[0], dtype=similarity.dtype) for i in range(n_frames): state = int(decoded_states[i]) chord_label = self.chord_labels[state] # Compute per-frame confidence from the similarity distribution - frame_confidence = self._compute_confidence(similarity[:, i], state) + sim_frame = similarity[:, i] if i < n_sim_frames else missing_similarity + frame_confidence = self._compute_confidence(sim_frame, state) if current_chord is None: current_chord = chord_label diff --git a/services/analysis-engine/tests/test_chord_confidence_finiteness.py b/services/analysis-engine/tests/test_chord_confidence_finiteness.py new file mode 100644 index 000000000..f1a4f56e5 --- /dev/null +++ b/services/analysis-engine/tests/test_chord_confidence_finiteness.py @@ -0,0 +1,27 @@ +"""Regression tests for finite-only chord-confidence arithmetic.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer + + +@pytest.mark.parametrize( + "invalid_value", + [np.nan, np.inf, -np.inf], +) +def test_non_finite_similarity_confidence_falls_back_without_runtime_warning( + invalid_value: float, +) -> None: + """Unknown similarity evidence must yield low confidence without invalid arithmetic.""" + recognizer = ChordRecognizer() + similarity = np.linspace(0.1, 0.9, 24, dtype=float) + similarity[5] = invalid_value + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + assert recognizer._compute_confidence(similarity, best_state=0) == "low" diff --git a/services/analysis-engine/tests/test_chord_recognizer.py b/services/analysis-engine/tests/test_chord_recognizer.py index 20a6dcf78..d5392dfba 100644 --- a/services/analysis-engine/tests/test_chord_recognizer.py +++ b/services/analysis-engine/tests/test_chord_recognizer.py @@ -20,6 +20,20 @@ def test_chord_recognizer_empty_audio() -> None: assert result == [] +def test_chord_recognizer_does_not_apply_feature_local_duration_policy() -> None: + """Delegate duration policy upstream instead of silently truncating one feature.""" + recognizer = ChordRecognizer() + sample_rate = 1 + audio = np.arange(601, dtype=np.float32) + + with patch.object(recognizer, "_separate_harmonic", return_value=np.array([])) as separate: + result = recognizer.recognize(audio, sr=sample_rate) + + separate.assert_called_once() + np.testing.assert_array_equal(separate.call_args.args[0], audio) + assert result == [] + + def test_chord_recognizer_unvoiced_audio() -> None: """Test chord recognition with noise.""" recognizer = ChordRecognizer() diff --git a/services/analysis-engine/tests/test_chord_recognizer_opt.py b/services/analysis-engine/tests/test_chord_recognizer_opt.py new file mode 100644 index 000000000..309c07c1d --- /dev/null +++ b/services/analysis-engine/tests/test_chord_recognizer_opt.py @@ -0,0 +1,143 @@ +"""Tests for edge cases in the chord recognizer observation probability building.""" + +import numpy as np + +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer + + +def test_chord_recognizer_build_observation_probs_edge_cases(): + """Test edge cases for _build_observation_probs.""" + recognizer = ChordRecognizer() + + # 1. Empty similarity (n_sim_frames == 0) + chromagram = np.zeros((12, 10)) + similarity = np.zeros((24, 0)) + rms = np.zeros(10) + + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + assert probs.shape == (25, 10) + + # 2. Similarity shorter than chromagram (n_sim_frames < n_frames) + similarity = np.zeros((24, 5)) + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + assert probs.shape == (25, 10) + + # 3. Similarity longer than chromagram (n_sim_frames > n_frames) + chromagram = np.zeros((12, 10)) + similarity = np.zeros((24, 15)) + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + assert probs.shape == (25, 10) + + # 4. rms shorter than n_frames + chromagram = np.zeros((12, 10)) + similarity = np.zeros((24, 10)) + rms = np.zeros(5) + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + assert probs.shape == (25, 10) + + +def test_create_chord_segments_handles_short_similarity(monkeypatch): + """Keep confidence generation aligned when observation frames are padded.""" + recognizer = ChordRecognizer() + chromagram = np.zeros((12, 4)) + similarity = np.zeros((24, 1)) + rms = np.ones(4) + monkeypatch.setattr( + recognizer, + "_viterbi_decode", + lambda observation_probs: np.zeros(observation_probs.shape[1], dtype=np.intp), + ) + + segments = recognizer._create_chord_segments(chromagram, similarity, rms, 22_050) + + assert len(segments) == 1 + assert segments[0]["chord"] == "C" + assert segments[0]["confidence"] == "low" + + +def test_missing_similarity_fallback_vector_is_allocated_once(monkeypatch): + """Reuse one neutral vector instead of allocating it for every padded frame.""" + recognizer = ChordRecognizer() + chromagram = np.zeros((12, 4)) + similarity = np.zeros((24, 1)) + rms = np.ones(4) + original_zeros = np.zeros + fallback_allocations = 0 + + def tracked_zeros(shape, *args, **kwargs): + """Count only one-dimensional chord-template fallback allocations.""" + nonlocal fallback_allocations + if shape in {24, (24,)}: + fallback_allocations += 1 + return original_zeros(shape, *args, **kwargs) + + monkeypatch.setattr(np, "zeros", tracked_zeros) + monkeypatch.setattr( + recognizer, + "_viterbi_decode", + lambda observation_probs: original_zeros(observation_probs.shape[1], dtype=np.intp), + ) + + recognizer._create_chord_segments(chromagram, similarity, rms, 22_050) + + assert fallback_allocations == 1 + + +def test_observation_probability_edge_cases_are_normalized(): + """Keep finite normalized columns across observation length mismatches.""" + recognizer = ChordRecognizer() + chromagram = np.zeros((12, 10)) + cases = ( + (np.zeros((24, 0)), np.zeros(10)), + (np.zeros((24, 5)), np.zeros(10)), + (np.zeros((24, 15)), np.zeros(10)), + (np.zeros((24, 10)), np.zeros(5)), + ) + + for similarity, rms in cases: + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + assert probs.shape == (25, 10) + assert np.all(np.isfinite(probs)) + assert np.allclose(probs.sum(axis=0), 1.0) + + +def test_non_finite_observation_metadata_is_neutral_and_finite(): + """Invalid DSP metadata must not poison Viterbi probabilities or imply silence.""" + recognizer = ChordRecognizer() + chromagram = np.ones((12, 2), dtype=float) + chromagram[0, 1] = np.nan + similarity = np.zeros((24, 2), dtype=float) + similarity[3, 1] = np.nan + rms = np.array([1.0, np.nan]) + + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + + assert np.all(np.isfinite(probs)) + assert np.allclose(probs.sum(axis=0), 1.0) + assert np.allclose(probs[:24, 1], probs[0, 1]) + assert probs[24, 1] < probs[:24, 1].sum() + + +def test_missing_observation_metadata_does_not_force_no_chord(): + """Keep uniform chord fallback neutral when similarity and RMS are absent.""" + recognizer = ChordRecognizer() + chromagram = np.zeros((12, 3)) + chromagram[0, :] = 1.0 + similarity = np.zeros((24, 0)) + rms = np.array([], dtype=float) + + probs = recognizer._build_observation_probs(chromagram, similarity, rms) + + assert np.allclose(probs.sum(axis=0), 1.0) + assert np.allclose(probs[24], 0.05 / 1.05) + assert np.allclose(probs[:24], 1.0 / (24.0 * 1.05)) + + +def test_transition_prior_prefers_true_relative_major_minor_pairs(): + """Prefer C↔Am over the unrelated D♯m/F♯ pairs in the HMM transition prior.""" + recognizer = ChordRecognizer() + labels = {label: index for index, label in enumerate(recognizer.chord_labels)} + transition = recognizer._transition_matrix + + assert transition[labels["C"], labels["Am"]] > transition[labels["C"], labels["D#m"]] + assert transition[labels["Am"], labels["C"]] > transition[labels["Am"], labels["F#"]] diff --git a/services/analysis-engine/tests/test_chord_recognizer_reference_oracles.py b/services/analysis-engine/tests/test_chord_recognizer_reference_oracles.py new file mode 100644 index 000000000..9425792ca --- /dev/null +++ b/services/analysis-engine/tests/test_chord_recognizer_reference_oracles.py @@ -0,0 +1,182 @@ +"""Independent numerical oracles for the NumPy chord-recognition reference path.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer + +_NO_CHORD_STATE = 24 + + +def _scalar_observation_oracle( + chromagram: np.ndarray, + similarity: np.ndarray, + rms: np.ndarray, +) -> np.ndarray: + """Compute observation probabilities one frame at a time without vectorization.""" + frame_count = chromagram.shape[1] + similarity_frame_count = similarity.shape[1] + result = np.zeros((25, frame_count), dtype=float) + + for frame_index in range(frame_count): + if frame_index < similarity_frame_count: + frame_similarity = similarity[:, frame_index] + if np.all(np.isfinite(frame_similarity)): + shifted = frame_similarity - np.max(frame_similarity) + exponentiated = np.exp(shifted * 2.0) + chord_probabilities = exponentiated / (np.sum(exponentiated) + 1e-12) + maximum_similarity = float(np.max(frame_similarity)) + else: + chord_probabilities = np.full(24, 1.0 / 24.0) + maximum_similarity = 1.0 + else: + chord_probabilities = np.full(24, 1.0 / 24.0) + maximum_similarity = 1.0 + + if frame_index < len(rms) and np.isfinite(rms[frame_index]): + rms_value = float(rms[frame_index]) + else: + rms_value = 1.0 + chroma_variance = float(np.var(chromagram[:, frame_index])) + if not np.isfinite(chroma_variance): + chroma_variance = 1.0 + no_chord = maximum_similarity < 0.3 or rms_value < 0.01 or chroma_variance < 0.02 + if no_chord: + chord_probabilities = chord_probabilities * 0.1 + no_chord_probability = 0.9 + else: + no_chord_probability = 0.05 + + result[:24, frame_index] = chord_probabilities + result[_NO_CHORD_STATE, frame_index] = no_chord_probability + result[:, frame_index] /= np.sum(result[:, frame_index]) + 1e-12 + + return result + + +def _dense_viterbi_oracle( + transition_matrix: np.ndarray, + observation_probabilities: np.ndarray, +) -> np.ndarray: + """Decode with the original dense score table used before rolling storage.""" + state_count, frame_count = observation_probabilities.shape + if frame_count == 0: + return np.array([], dtype=np.intp) + + log_transition = np.log(transition_matrix + 1e-12) + log_observation = np.log(observation_probabilities + 1e-12) + score_table = np.zeros((state_count, frame_count), dtype=float) + backpointer = np.zeros((state_count, frame_count), dtype=np.intp) + score_table[:, 0] = np.log(1.0 / state_count) + log_observation[:, 0] + + for frame_index in range(1, frame_count): + candidate_scores = score_table[:, frame_index - 1, np.newaxis] + log_transition + backpointer[:, frame_index] = np.argmax(candidate_scores, axis=0) + score_table[:, frame_index] = ( + np.max(candidate_scores, axis=0) + log_observation[:, frame_index] + ) + + states = np.zeros(frame_count, dtype=np.intp) + states[-1] = int(np.argmax(score_table[:, -1])) + for frame_index in range(frame_count - 2, -1, -1): + states[frame_index] = backpointer[states[frame_index + 1], frame_index + 1] + return states + + +def _frame_distinguishable_similarity(frame_count: int) -> np.ndarray: + """Return non-uniform frames whose strongest chord changes deterministically.""" + similarity = np.empty((24, frame_count), dtype=float) + baseline = np.linspace(0.31, 0.54, 24) + for frame_index in range(frame_count): + similarity[:, frame_index] = baseline + frame_index * 0.017 + similarity[(frame_index * 7 + 3) % 24, frame_index] = 1.7 + frame_index + return similarity + + +@pytest.mark.parametrize("similarity_frame_count", [0, 3, 5, 8]) +def test_vectorized_observations_match_scalar_oracle_across_length_mismatches( + similarity_frame_count: int, +) -> None: + """Prove padding and truncation against an independent framewise calculation.""" + recognizer = ChordRecognizer() + frame_count = 5 + chromagram = np.arange(60, dtype=float).reshape(12, frame_count) / 10.0 + similarity = _frame_distinguishable_similarity(similarity_frame_count) + rms = np.array([0.8, 0.0, 0.6], dtype=float) + + actual = recognizer._build_observation_probs(chromagram, similarity, rms) + expected = _scalar_observation_oracle(chromagram, similarity, rms) + + assert actual.shape == (25, frame_count) + assert np.all(np.isfinite(actual)) + assert np.allclose(actual.sum(axis=0), 1.0) + assert np.allclose(actual, expected) + + for frame_index in range(min(frame_count, similarity_frame_count)): + assert int(np.argmax(actual[:24, frame_index])) == (frame_index * 7 + 3) % 24 + if similarity_frame_count < frame_count: + padded = actual[:24, similarity_frame_count:] + assert np.allclose(padded, padded[0:1, :]) + + +def test_vectorized_observations_match_scalar_oracle_for_non_finite_metadata() -> None: + """Corrupt DSP metadata must stay finite, normalized, and oracle-aligned.""" + recognizer = ChordRecognizer() + chromagram = np.ones((12, 3), dtype=float) + chromagram[0, 1] = np.nan + similarity = _frame_distinguishable_similarity(3) + similarity[5, 1] = np.inf + rms = np.array([0.8, np.nan, -np.inf], dtype=float) + + actual = recognizer._build_observation_probs(chromagram, similarity, rms) + expected = _scalar_observation_oracle(chromagram, similarity, rms) + + assert np.all(np.isfinite(actual)) + assert np.allclose(actual.sum(axis=0), 1.0) + assert np.allclose(actual, expected) + assert np.allclose(actual[:24, 1], actual[0, 1]) + assert actual[24, 1] < actual[:24, 1].sum() + + +def test_no_chord_evidence_and_missing_metadata_remain_distinguishable() -> None: + """Silence evidence selects no-chord while absent metadata stays neutral.""" + recognizer = ChordRecognizer() + chromagram = np.arange(48, dtype=float).reshape(12, 4) / 7.0 + similarity = _frame_distinguishable_similarity(2) + rms = np.array([0.7, 0.0], dtype=float) + + probabilities = recognizer._build_observation_probs(chromagram, similarity, rms) + + assert int(np.argmax(probabilities[:, 0])) != _NO_CHORD_STATE + assert int(np.argmax(probabilities[:, 1])) == _NO_CHORD_STATE + assert np.allclose( + probabilities[_NO_CHORD_STATE, 2:], + 0.05 / 1.05, + ) + assert np.allclose( + probabilities[:24, 2:], + 1.0 / (24.0 * 1.05), + ) + + +@pytest.mark.parametrize("frame_count", [0, 1, 7, 31]) +def test_rolling_viterbi_matches_independent_dense_oracle( + frame_count: int, +) -> None: + """Prove that rolling scores preserve the complete dense Viterbi sequence.""" + recognizer = ChordRecognizer() + random_generator = np.random.default_rng(20260807 + frame_count) + observations = random_generator.random((25, frame_count)) + 0.01 + if frame_count: + observations /= observations.sum(axis=0, keepdims=True) + + expected = _dense_viterbi_oracle( + recognizer._transition_matrix, + observations, + ) + actual = recognizer._viterbi_decode_reference(observations) + + assert actual.dtype == np.intp + assert np.array_equal(actual, expected)