Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ apps/desktop/src-tauri/target/
*.pyc
*.pyo
*.egg-info/
registered_agents.json
task_agent_mapping.json
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Chord analysis placeholders."""
"""Chord analysis and parsing."""

from .capo import detect_capo_and_tuning

__all__ = ["detect_capo_and_tuning"]
32 changes: 32 additions & 0 deletions services/analysis-engine/src/bandscope_analysis/chords/capo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Capo and tuning detection heuristics."""
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def detect_capo_and_tuning(chords: list[str]) -> dict[str, str | int | None]:
"""
Detect the most likely capo position and tuning based on a list of chords.

This is a basic heuristic that looks for common open chord shapes.

Args:
chords: A list of chord symbols (e.g., ['G', 'D', 'Em', 'C']).

Returns:
A dictionary containing 'capo' (int or None) and 'tuning' (str).
"""
if not chords:
return {"capo": None, "tuning": "Standard"}

chords_set = set(chords)

# Check for drop D indicators
if "D5" in chords_set:
return {"capo": 0, "tuning": "Drop D"}

# If we see Eb, Bb, Fm, Ab, a capo on 1st fret (playing D, A, Em, G shapes) is very common
flat_keys = {"Eb", "Bb", "Fm", "Ab"}

if len(chords_set.intersection(flat_keys)) >= 2:
return {"capo": 1, "tuning": "Standard"}

# Default fallback
return {"capo": 0, "tuning": "Standard"}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Role extraction and part graphing module."""
"""Role extraction and part graph models."""

from .extractor import RoleExtractor
from .model import (
Expand All @@ -10,14 +10,16 @@
RoleType,
SectionRoleTopology,
)
from .tuning import get_setup_note

__all__ = [
"RoleType",
"RehearsalPriority",
"RoleExtractor",
"CueAnchorKind",
"RehearsalRole",
"PartGraphNode",
"SectionRoleTopology",
"RehearsalPriority",
"RehearsalRole",
"RoleExtractionResult",
"RoleExtractor",
"RoleType",
"SectionRoleTopology",
"get_setup_note",
]
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SectionRoleTopology,
)
from .priority import calculate_rehearsal_priority
from .tuning import get_setup_note

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,7 +73,8 @@ def extract(
},
"rehearsalPriority": RehearsalPriority.HIGH, # to be replaced
"simplification": "Stay on roots if the chorus entrance gets muddy.",
"setupNote": "Keep the attack short so the verse breathes.",
"setupNote": get_setup_note("Bass Guitar", ["C#m7"])
or "Keep the attack short so the verse breathes.",
"manualOverrides": [],
"overlapWarnings": [
"Density warning: competing with Keyboard Left Hand in low register."
Expand Down Expand Up @@ -100,7 +102,8 @@ def extract(
},
"rehearsalPriority": RehearsalPriority.MEDIUM, # to be replaced
"simplification": "Omit if bass is covering the lower register.",
"setupNote": "Use a darker patch to avoid clashing with right hand.",
"setupNote": get_setup_note("Keyboard", ["C#"])
or "Use a darker patch to avoid clashing with right hand.",
"manualOverrides": [],
"overlapWarnings": ["Density warning: competing with Bass Guitar in low register."],
}
Expand All @@ -126,7 +129,8 @@ def extract(
},
"rehearsalPriority": RehearsalPriority.HIGH, # to be replaced
"simplification": "Drop top extension if the chorus turnaround feels busy.",
"setupNote": "Keep the patch bright enough to stay over the guitars.",
"setupNote": get_setup_note("Keyboard", ["Emaj7"])
or "Keep the patch bright enough to stay over the guitars.",
"manualOverrides": [],
"overlapWarnings": ["Melodic overlap: top notes conflict with Lead Vocal range."],
}
Expand All @@ -149,7 +153,8 @@ def extract(
},
"rehearsalPriority": RehearsalPriority.MEDIUM, # to be replaced
"simplification": "Keep sustained note centered; skip ad-lib on first pass.",
"setupNote": "Watch the breath before the last line of the verse.",
"setupNote": get_setup_note("Lead Vocal", ["C#m7"])
or "Watch the breath before the last line of the verse.",
"manualOverrides": [
{
"field": "harmony",
Expand All @@ -164,14 +169,44 @@ def extract(
"overlapWarnings": ["Melodic overlap: competing with Keyboard 1 Right Hand."],
}

for role in [bass_role, keys_left_role, keys_role, vocal_role]:
acoustic_guitar_role: RehearsalRole = {
"id": "acoustic-guitar",
"name": "Acoustic Guitar",
"roleType": RoleType.INSTRUMENT,
"harmony": {
"chord": "Eb",
"functionLabel": "I",
"source": "model",
},
"cue": {"kind": CueAnchorKind.TRANSITION, "value": "Strum on the downbeat."},
"range": {"lowestNote": "E2", "highestNote": "C#5"},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Standard open chords detected.",
},
"rehearsalPriority": RehearsalPriority.MEDIUM,
"simplification": "Simplify strumming pattern if rushing.",
"setupNote": get_setup_note("Acoustic Guitar", ["Eb", "Bb", "Fm", "Ab"])
or "Check tuning.",
"manualOverrides": [],
"overlapWarnings": [],
}

for role in [bass_role, keys_left_role, keys_role, vocal_role, acoustic_guitar_role]:
role["rehearsalPriority"] = calculate_rehearsal_priority(role)

active_roles = [bass_role]
active_roles = [bass_role, acoustic_guitar_role]

# Simple part graph for bass
# Simple part graph for bass and guitar
part_graph: list[PartGraphNode] = [
{"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []}
{"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []},
{
"role_id": "acoustic-guitar",
"is_active": True,
"handoff_to": [],
"handoff_from": [],
},
]

if i == 0:
Expand All @@ -198,8 +233,11 @@ def extract(
},
]
)
part_graph[0]["handoff_to"].append("lead-vocal")
part_graph[3]["handoff_from"].append("bass-guitar")
for node in part_graph:
if node["role_id"] == "bass-guitar":
node["handoff_to"].append("lead-vocal")
elif node["role_id"] == "lead-vocal":
node["handoff_from"].append("bass-guitar")
else:
part_graph.extend(
[
Expand Down
29 changes: 29 additions & 0 deletions services/analysis-engine/src/bandscope_analysis/roles/tuning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Tuning and setup note heuristics based on role and chords."""

from bandscope_analysis.chords.capo import detect_capo_and_tuning


def get_setup_note(role_name: str, chords: list[str]) -> str | None:
"""
Generate a setup note (like Capo fret) for a given role based on the chords.

Args:
role_name: The name of the role (e.g., 'Acoustic Guitar', 'Bass Guitar').
chords: A list of chords for the song or section.

Returns:
A setup string, or None if no specific setup is needed.
"""
role_lower = role_name.lower()

# Capo only makes sense for guitars usually
if "guitar" in role_lower and "bass" not in role_lower:
result = detect_capo_and_tuning(chords)
tuning = result["tuning"]

if isinstance(result["capo"], int) and result["capo"] > 0:
return f"Setup: {tuning} tuning, Capo {result['capo']}"
elif tuning != "Standard":
return f"Setup: {tuning} tuning"

return None
2 changes: 1 addition & 1 deletion services/analysis-engine/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"][3]["manualOverrides"][0]["value"]["source"] == "user"
assert song["sections"][0]["roles"][4]["manualOverrides"][0]["value"]["source"] == "user"


def test_run_analysis_job_returns_success_and_failure_envelopes() -> None:
Expand Down
31 changes: 31 additions & 0 deletions services/analysis-engine/tests/test_chords.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Tests for chord analysis heuristics."""

from bandscope_analysis.chords.capo import detect_capo_and_tuning


def test_detect_capo_standard():
"""Test standard tuning and no capo."""
result = detect_capo_and_tuning(["G", "D", "Em", "C"])
assert result["capo"] == 0
assert result["tuning"] == "Standard"


def test_detect_capo_fret1():
"""Test capo detection for flat keys."""
result = detect_capo_and_tuning(["Eb", "Bb", "Fm", "Ab"])
assert result["capo"] == 1
assert result["tuning"] == "Standard"


def test_detect_capo_empty():
"""Test empty chord list."""
result = detect_capo_and_tuning([])
assert result["capo"] is None
assert result["tuning"] == "Standard"


def test_detect_drop_d():
"""Test drop D tuning."""
result = detect_capo_and_tuning(["D5", "G5", "A5"])
assert result["capo"] == 0
assert result["tuning"] == "Drop D"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
14 changes: 8 additions & 6 deletions services/analysis-engine/tests/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_role_extractor_basic() -> None:
# Check intro section
intro_topology = result["topologies"][0]
assert intro_topology["section_id"] == "intro"
assert len(intro_topology["active_roles"]) == 4
assert len(intro_topology["active_roles"]) == 5

roles_by_id = {r["id"]: r for r in intro_topology["active_roles"]}
assert "bass-guitar" in roles_by_id
Expand All @@ -64,18 +64,20 @@ def test_role_extractor_basic() -> None:
# Check verse-1 section (only bass)
verse_topology = result["topologies"][1]
assert verse_topology["section_id"] == "verse-1"
assert len(verse_topology["active_roles"]) == 1
assert len(verse_topology["active_roles"]) == 2
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) == 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 len(verse_graph) == 5
assert verse_graph[1]["role_id"] == "acoustic-guitar"
assert verse_graph[1]["is_active"] is True
assert verse_graph[2]["role_id"] == "keys-left"
assert verse_graph[2]["is_active"] is False
assert verse_graph[3]["role_id"] == "keys-right"
assert verse_graph[3]["is_active"] is False
assert verse_graph[0]["role_id"] == "bass-guitar"
assert verse_graph[0]["handoff_to"] == []

Expand Down
34 changes: 34 additions & 0 deletions services/analysis-engine/tests/test_tuning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Tests for role tuning heuristics."""

from bandscope_analysis.roles.tuning import get_setup_note


def test_get_setup_note_acoustic_guitar():
"""Test setup note for acoustic guitar with flat keys."""
# Should suggest Capo 1
note = get_setup_note("Acoustic Guitar", ["Eb", "Bb", "Fm", "Ab"])
assert note == "Setup: Standard tuning, Capo 1"


def test_get_setup_note_bass_guitar():
"""Test that bass guitar ignores capo."""
note = get_setup_note("Bass Guitar", ["Eb", "Bb", "Fm", "Ab"])
assert note is None


def test_get_setup_note_keys():
"""Test that keys ignore capo."""
note = get_setup_note("Keyboard", ["Eb", "Bb", "Fm", "Ab"])
assert note is None


def test_get_setup_note_standard():
"""Test guitar in standard tuning, no capo."""
note = get_setup_note("Electric Guitar", ["G", "D", "Em", "C"])
assert note is None


def test_get_setup_note_drop_d():
"""Test drop D tuning detection."""
note = get_setup_note("Electric Guitar", ["D5", "G5", "A5"])
assert note == "Setup: Drop D tuning"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading