Skip to content
92 changes: 7 additions & 85 deletions services/analysis-engine/src/bandscope_analysis/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Literal, NotRequired, TypedDict

from bandscope_analysis.health import HealthReport, build_health_report
from bandscope_analysis.roles import RoleExtractor
from bandscope_analysis.sections import extract_sections


Expand Down Expand Up @@ -211,6 +212,11 @@ def build_demo_rehearsal_song() -> RehearsalSong:
arrangement = [{"label": "verse", "groove": "Straight eighths with a late snare feel"}]
extraction_result = extract_sections(arrangement)
verse_section = extraction_result["sections"][0]

# Extract roles
extractor = RoleExtractor()
role_result = extractor.extract([verse_section])
verse_roles = role_result["topologies"][0]["active_roles"]

return {
"id": "demo-song",
Expand All @@ -225,91 +231,7 @@ def build_demo_rehearsal_song() -> RehearsalSong:
"source": "model",
"notes": "Double-check the pickup into the chorus.",
},
"roles": [
{
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": "instrument",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi pedal anchor",
"source": "model",
},
"cue": {
"kind": "transition",
"value": "Hold through the pickup before the downbeat.",
},
"range": {"lowestNote": "C#2", "highestNote": "E3"},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Watch the slide into the turnaround.",
},
"rehearsalPriority": "high",
"simplification": "Stay on roots if the chorus entrance gets muddy.",
"setupNote": "Keep the attack short so the verse breathes.",
"manualOverrides": [],
},
{
"id": "keys-right",
"name": "Keyboard 1 Right Hand",
"roleType": "hand",
"harmony": {
"chord": "Emaj7",
"functionLabel": "Imaj7 color",
"source": "model",
},
"cue": {
"kind": "count",
"value": "Enter on beat 2 after the pickup.",
},
"range": {"lowestNote": "B3", "highestNote": "G#5"},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Top note voicing may need a quick ear check.",
},
"rehearsalPriority": "high",
"simplification": (
"Drop the top extension if the chorus turnaround still feels busy."
),
"setupNote": "Keep the patch bright enough to stay over the guitars.",
"manualOverrides": [],
},
{
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": "vocal",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi melodic pull",
"source": "model",
},
"cue": {"kind": "lyric", "value": "city lights"},
"range": {"lowestNote": "G#3", "highestNote": "C#5"},
"confidence": {
"level": "high",
"source": "user",
"notes": "Singer confirmed the pickup phrasing in rehearsal notes.",
},
"rehearsalPriority": "medium",
"simplification": (
"Keep the sustained note centered; skip the ad-lib on the first pass."
),
"setupNote": "Watch the breath before the last line of the verse.",
"manualOverrides": [
{
"field": "harmony",
"value": {
"chord": "C#m11",
"functionLabel": "vi suspended lift",
"source": "user",
},
"source": "user",
}
],
},
],
"roles": verse_roles,
}
],
"exportSummary": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Role extraction and part graphing module."""

from .extractor import RoleExtractor
from .model import (
CueAnchorKind,
PartGraphNode,
RehearsalPriority,
RehearsalRole,
RoleExtractionResult,
RoleType,
SectionRoleTopology,
)

__all__ = [
"RoleType",
"RehearsalPriority",
"CueAnchorKind",
"RehearsalRole",
"PartGraphNode",
"SectionRoleTopology",
"RoleExtractionResult",
"RoleExtractor",
]
165 changes: 165 additions & 0 deletions services/analysis-engine/src/bandscope_analysis/roles/extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Role extractor implementation."""

from __future__ import annotations

import logging
from typing import Any

from .model import (
CueAnchorKind,
PartGraphNode,
RehearsalPriority,
RehearsalRole,
RoleExtractionResult,
RoleType,
SectionRoleTopology,
)

logger = logging.getLogger(__name__)


class RoleExtractor:
"""Extracts roles and builds the part graph for song sections."""

def __init__(self) -> None:
"""Initialize the role extractor."""
pass

def extract(
self,
sections: list[dict[str, Any]],
audio_features: dict[str, Any] | None = None,
) -> RoleExtractionResult:
"""Extract roles and their topology per section.

Args:
sections: List of section dicts (must contain 'id').
audio_features: Optional audio features to inform extraction.

Returns:
RoleExtractionResult containing topologies and notes.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
topologies: list[SectionRoleTopology] = []

# Simple mock implementation for testing/demonstration purposes
for i, section in enumerate(sections):
section_id = section.get("id", f"section-{i}")

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Create a mock bass role
bass_role: RehearsalRole = {
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": RoleType.INSTRUMENT.value,
"harmony": {"chord": "C#m7", "functionLabel": "vi pedal anchor", "source": "model"},
"cue": {
"kind": CueAnchorKind.TRANSITION.value,
"value": "Hold through the pickup before the downbeat.",
},
"range": {"lowestNote": "C#2", "highestNote": "E3"},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Watch the slide into the turnaround.",
},
"rehearsalPriority": RehearsalPriority.HIGH.value,
"simplification": "Stay on roots if the chorus entrance gets muddy.",
"setupNote": "Keep the attack short so the verse breathes.",
"manualOverrides": [],
}

keys_role: RehearsalRole = {
"id": "keys-right",
"name": "Keyboard 1 Right Hand",
"roleType": RoleType.HAND.value,
"harmony": {
"chord": "Emaj7",
"functionLabel": "Imaj7 color",
"source": "model",
},
"cue": {
"kind": CueAnchorKind.COUNT.value,
"value": "Enter on beat 2 after the pickup.",
},
"range": {"lowestNote": "B3", "highestNote": "G#5"},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Top note voicing may need a quick ear check.",
},
"rehearsalPriority": RehearsalPriority.HIGH.value,
"simplification": "Drop top extension if the chorus turnaround feels busy.",
"setupNote": "Keep the patch bright enough to stay over the guitars.",
"manualOverrides": [],
}

vocal_role: RehearsalRole = {
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": RoleType.VOCAL.value,
"harmony": {
"chord": "C#m7",
"functionLabel": "vi melodic pull",
"source": "model",
},
"cue": {"kind": CueAnchorKind.LYRIC.value, "value": "city lights"},
"range": {"lowestNote": "G#3", "highestNote": "C#5"},
"confidence": {
"level": "high",
"source": "user",
"notes": "Singer confirmed the pickup phrasing in rehearsal notes.",
},
"rehearsalPriority": RehearsalPriority.MEDIUM.value,
"simplification": "Keep sustained note centered; skip ad-lib on first pass.",
"setupNote": "Watch the breath before the last line of the verse.",
"manualOverrides": [
{
"field": "harmony",
"value": {
"chord": "C#m11",
"functionLabel": "vi suspended lift",
"source": "user",
},
"source": "user",
}
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

active_roles = [bass_role]

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

if i == 0:
active_roles.extend([keys_role, vocal_role])
part_graph.extend(
[
{
"role_id": "keys-right",
"is_active": True,
"handoff_to": [],
"handoff_from": [],
},
{
"role_id": "lead-vocal",
"is_active": True,
"handoff_to": [],
"handoff_from": [],
},
]
)
part_graph[0]["handoff_to"].append("lead-vocal")
part_graph[2]["handoff_from"].append("bass-guitar")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

topology: SectionRoleTopology = {
"section_id": section_id,
"active_roles": active_roles,
"part_graph": part_graph,
}
topologies.append(topology)

return {
"topologies": topologies,
"extraction_notes": "Extracted roles and computed handoffs.",
}
Loading
Loading