Skip to content
106 changes: 20 additions & 86 deletions services/analysis-engine/src/bandscope_analysis/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from __future__ import annotations

from typing import Literal, NotRequired, TypedDict
from typing import Any, Literal, NotRequired, TypedDict, cast

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 @@ -88,6 +89,15 @@ class RehearsalRolePayload(TypedDict):
manualOverrides: list[ManualOverridePayload]


class PartGraphNodePayload(TypedDict):
"""Typed part-graph node payload nested inside sections."""

role_id: str
is_active: bool
handoff_to: list[str]
handoff_from: list[str]


class RehearsalSectionPayload(TypedDict):
"""Typed rehearsal section payload nested inside songs."""

Expand All @@ -96,6 +106,7 @@ class RehearsalSectionPayload(TypedDict):
groove: str
confidence: ConfidencePayload
roles: list[RehearsalRolePayload]
partGraph: list[PartGraphNodePayload]


class ExportSummaryPayload(TypedDict):
Expand Down Expand Up @@ -212,6 +223,12 @@ def build_demo_rehearsal_song() -> RehearsalSong:
extraction_result = extract_sections(arrangement)
verse_section = extraction_result["sections"][0]

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

return {
"id": "demo-song",
"title": "Late Night Set",
Expand All @@ -225,91 +242,8 @@ 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": cast(list[RehearsalRolePayload], verse_roles),
"partGraph": cast(Any, verse_topology["part_graph"]),
}
],
"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",
]
190 changes: 190 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,190 @@
"""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[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):
if not isinstance(section, dict):
logger.warning(
"Invalid section format at index %d; expected dict, got %s",
i,
type(section).__name__,
)
section_id = f"section-{i}"
else:
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,
"harmony": {"chord": "C#m7", "functionLabel": "vi pedal anchor", "source": "model"},
"cue": {
"kind": CueAnchorKind.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": RehearsalPriority.HIGH,
"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,
"harmony": {
"chord": "Emaj7",
"functionLabel": "Imaj7 color",
"source": "model",
},
"cue": {
"kind": CueAnchorKind.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": RehearsalPriority.HIGH,
"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,
"harmony": {
"chord": "C#m7",
"functionLabel": "vi melodic pull",
"source": "model",
},
"cue": {"kind": CueAnchorKind.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": RehearsalPriority.MEDIUM,
"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.
else:
part_graph.extend(
[
{
"role_id": "keys-right",
"is_active": False,
"handoff_to": [],
"handoff_from": [],
},
{
"role_id": "lead-vocal",
"is_active": False,
"handoff_to": [],
"handoff_from": [],
},
]
)

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