diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index 87fce60c..058d2ce5 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -87,6 +87,7 @@ class RehearsalRolePayload(TypedDict): simplification: str setupNote: str manualOverrides: list[ManualOverridePayload] + overlapWarnings: list[str] class PartGraphNodePayload(TypedDict): diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 34d7b376..bbada8fa 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -73,6 +73,35 @@ def extract( "simplification": "Stay on roots if the chorus entrance gets muddy.", "setupNote": "Keep the attack short so the verse breathes.", "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ], + } + + keys_left_role: RehearsalRole = { + "id": "keys-left", + "name": "Keyboard 1 Left Hand", + "roleType": RoleType.HAND, + "harmony": { + "chord": "C#", + "functionLabel": "Root reinforcement", + "source": "model", + }, + "cue": { + "kind": CueAnchorKind.TRANSITION, + "value": "Lock in with bass pedal.", + }, + "range": {"lowestNote": "C#2", "highestNote": "C#3"}, + "confidence": { + "level": "low", + "source": "model", + "notes": "Muddy frequency range, difficult to clearly separate from bass.", + }, + "rehearsalPriority": RehearsalPriority.MEDIUM, + "simplification": "Omit if bass is covering the lower register.", + "setupNote": "Use a darker patch to avoid clashing with right hand.", + "manualOverrides": [], + "overlapWarnings": ["Density warning: competing with Bass Guitar in low register."], } keys_role: RehearsalRole = { @@ -98,6 +127,7 @@ def extract( "simplification": "Drop top extension if the chorus turnaround feels busy.", "setupNote": "Keep the patch bright enough to stay over the guitars.", "manualOverrides": [], + "overlapWarnings": ["Melodic overlap: top notes conflict with Lead Vocal range."], } vocal_role: RehearsalRole = { @@ -130,6 +160,7 @@ def extract( "source": "user", } ], + "overlapWarnings": ["Melodic overlap: competing with Keyboard 1 Right Hand."], } active_roles = [bass_role] @@ -140,9 +171,15 @@ def extract( ] if i == 0: - active_roles.extend([keys_role, vocal_role]) + active_roles.extend([keys_left_role, keys_role, vocal_role]) part_graph.extend( [ + { + "role_id": "keys-left", + "is_active": True, + "handoff_to": [], + "handoff_from": [], + }, { "role_id": "keys-right", "is_active": True, @@ -158,10 +195,16 @@ def extract( ] ) part_graph[0]["handoff_to"].append("lead-vocal") - part_graph[2]["handoff_from"].append("bass-guitar") + part_graph[3]["handoff_from"].append("bass-guitar") else: part_graph.extend( [ + { + "role_id": "keys-left", + "is_active": False, + "handoff_to": [], + "handoff_from": [], + }, { "role_id": "keys-right", "is_active": False, diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index 06bac5a0..ea6fc144 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -82,6 +82,7 @@ class RehearsalRole(TypedDict): simplification: str setupNote: str manualOverrides: list[ManualOverride] + overlapWarnings: list[str] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 86ca448f..30211c3b 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -206,7 +206,7 @@ def test_build_demo_rehearsal_song_matches_expected_fixture() -> None: assert song["title"] == "Late Night Set" assert song["sections"][0]["roles"][0]["id"] == "bass-guitar" - assert song["sections"][0]["roles"][2]["manualOverrides"][0]["value"]["source"] == "user" + assert song["sections"][0]["roles"][3]["manualOverrides"][0]["value"]["source"] == "user" def test_run_analysis_job_returns_success_and_failure_envelopes() -> None: diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index a1ada7a0..9aced099 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -8,28 +8,28 @@ ) -def test_role_type_enum(): +def test_role_type_enum() -> None: """Verify RoleType enum values match the domain requirements.""" - assert RoleType.INSTRUMENT == "instrument" - assert RoleType.VOCAL == "vocal" - assert RoleType.HAND == "hand" + assert RoleType.INSTRUMENT.value == "instrument" + assert RoleType.VOCAL.value == "vocal" + assert RoleType.HAND.value == "hand" -def test_rehearsal_priority_enum(): +def test_rehearsal_priority_enum() -> None: """Verify RehearsalPriority enum values match.""" - assert RehearsalPriority.LOW == "low" - assert RehearsalPriority.MEDIUM == "medium" - assert RehearsalPriority.HIGH == "high" + assert RehearsalPriority.LOW.value == "low" + assert RehearsalPriority.MEDIUM.value == "medium" + assert RehearsalPriority.HIGH.value == "high" -def test_cue_anchor_kind_enum(): +def test_cue_anchor_kind_enum() -> None: """Verify CueAnchorKind enum values match.""" - assert CueAnchorKind.LYRIC == "lyric" - assert CueAnchorKind.COUNT == "count" - assert CueAnchorKind.TRANSITION == "transition" + assert CueAnchorKind.LYRIC.value == "lyric" + assert CueAnchorKind.COUNT.value == "count" + assert CueAnchorKind.TRANSITION.value == "transition" -def test_role_extractor_basic(): +def test_role_extractor_basic() -> None: """Test that RoleExtractor returns a valid topology structure.""" extractor = RoleExtractor() @@ -44,13 +44,15 @@ def test_role_extractor_basic(): # Check intro section intro_topology = result["topologies"][0] assert intro_topology["section_id"] == "intro" - assert len(intro_topology["active_roles"]) == 3 + assert len(intro_topology["active_roles"]) == 4 roles_by_id = {r["id"]: r for r in intro_topology["active_roles"]} assert "bass-guitar" in roles_by_id assert "lead-vocal" in roles_by_id assert "keys-right" in roles_by_id + assert "keys-left" in roles_by_id assert roles_by_id["lead-vocal"]["roleType"] == "vocal" + assert "Melodic overlap" in roles_by_id["lead-vocal"]["overlapWarnings"][0] intro_graph = intro_topology["part_graph"] graph_by_role = {n["role_id"]: n for n in intro_graph} @@ -66,23 +68,26 @@ def test_role_extractor_basic(): assert verse_topology["active_roles"][0]["id"] == "bass-guitar" assert verse_topology["active_roles"][0]["roleType"] == "instrument" assert verse_topology["active_roles"][0]["rehearsalPriority"] == "high" + assert "Density warning" in verse_topology["active_roles"][0]["overlapWarnings"][0] verse_graph = verse_topology["part_graph"] - assert len(verse_graph) == 3 - assert verse_graph[1]["role_id"] == "keys-right" + assert len(verse_graph) == 4 + assert verse_graph[1]["role_id"] == "keys-left" assert verse_graph[1]["is_active"] is False + assert verse_graph[2]["role_id"] == "keys-right" + assert verse_graph[2]["is_active"] is False assert verse_graph[0]["role_id"] == "bass-guitar" assert verse_graph[0]["handoff_to"] == [] -def test_role_extractor_empty(): +def test_role_extractor_empty() -> None: """Test extractor with empty sections list.""" extractor = RoleExtractor() result = extractor.extract([]) assert result["topologies"] == [] -def test_role_extractor_invalid_section(): +def test_role_extractor_invalid_section() -> None: """Test that RoleExtractor handles non-dict sections gracefully.""" extractor = RoleExtractor() sections = [{"id": "intro"}, "invalid-section-string"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..ce78d5f5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "bandscope-workspace" +version = "0.1.0" +source = { virtual = "." }