diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 070ca69f..6e2916d3 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -86,6 +86,43 @@ def test_role_extractor_basic() -> None: assert verse_graph[0]["handoff_to"] == [] +def test_role_extractor_prefers_first_detected_chord_on_ties() -> None: + """Ensure public role extraction keeps the first detected chord on ties.""" + audio_features = { + "stems": { + "bass": np.ones(100, dtype=np.float32), + "other": np.ones(100, dtype=np.float32), + }, + "sr": 10, + } + + with ( + patch("bandscope_analysis.chords.chord_recognizer.ChordRecognizer") as recognizer_cls, + patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker") as tracker_cls, + ): + tracker_cls.return_value.track.return_value = { + "lowest_note": "C2", + "highest_note": "C3", + } + recognizer_cls.return_value.recognize.side_effect = [ + [{"chord": "A"}, {"chord": "B"}, {"chord": "A"}, {"chord": "B"}], + [{"chord": "D"}, {"chord": "C"}, {"chord": "C"}], + ] + + result = RoleExtractor().extract([{"id": "intro"}], audio_features) + + roles_by_id = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + assert roles_by_id["bass-guitar"]["harmony"]["chord"] == "A" + assert roles_by_id["lead-vocal"]["harmony"]["chord"] == "C" + + +def test_extract_without_audio_features_sets_extraction_notes() -> None: + """Ensure extraction without audio features still reports computed handoff notes.""" + result = RoleExtractor().extract([{"id": "intro"}]) + + assert "computed handoffs" in result["extraction_notes"] + + def test_role_extractor_empty() -> None: """Test extractor with empty sections list.""" extractor = RoleExtractor()