diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 4d8f4e1..a20df3a 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -70,7 +70,10 @@ function succeededResult() { rehearsalPriority: "high", simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", - manualOverrides: [] + manualOverrides: [], + overlapWarnings: [ + "Density warning: competing with Keyboard Left Hand in low register." + ] }, { id: "lead-vocal", @@ -97,8 +100,13 @@ function succeededResult() { }, source: "user" } - ] + ], + overlapWarnings: [] } + ], + partGraph: [ + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] } ] } ], diff --git a/apps/desktop/src/features/chords/index.tsx b/apps/desktop/src/features/chords/index.tsx index 4561fff..e9d0c01 100644 --- a/apps/desktop/src/features/chords/index.tsx +++ b/apps/desktop/src/features/chords/index.tsx @@ -1,4 +1,79 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; + /** Documented. */ -export function ChordsFeature(props: { title: string }) { - return

{props.title}

; +export function ChordsFeature(props: { title: string; song?: RehearsalSong | null }) { + const { title, song } = props; + + if (!song) { + return ( +
+

{title}

+

No song loaded. Start an analysis to see chord data.

+
+ ); + } + + // Collect unique chords across all sections and roles + const chordsBySectionLabel = new Map(); + for (const section of song.sections) { + const entries: { chord: string; functionLabel: string; source: string; roleName: string }[] = []; + for (const role of section.roles) { + entries.push({ + chord: role.harmony.chord, + functionLabel: role.harmony.functionLabel, + source: role.harmony.source, + roleName: role.name, + }); + } + chordsBySectionLabel.set(section.label, entries); + } + + return ( +
+

{title}

+
+ {song.sections.map((section) => ( +
+

+ {section.label} +

+ {section.roles.map((role) => ( +
+
+ {role.harmony.chord} + {role.harmony.source === "user" && ( + (User) + )} +
+
+ {role.harmony.functionLabel} +
+
+ {role.name} +
+
+ ))} +
+ ))} +
+
+ ); } diff --git a/apps/desktop/src/features/home/index.tsx b/apps/desktop/src/features/home/index.tsx index 6cfa28c..6a91115 100644 --- a/apps/desktop/src/features/home/index.tsx +++ b/apps/desktop/src/features/home/index.tsx @@ -1,4 +1,45 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; + /** Documented. */ -export function HomeFeature(props: { title: string }) { - return

{props.title}

; +export function HomeFeature(props: { title: string; song?: RehearsalSong | null }) { + const { title, song } = props; + + return ( +
+

{title}

+ {song ? ( +
+

+ 🎵 {song.title} +

+
+
+
Sections
+
{song.sections.length}
+
+
+
Roles
+
+ {new Set(song.sections.flatMap(s => s.roles.map(r => r.id))).size} +
+
+
+
Export
+
{song.exportSummary.format}
+
+
+ {song.exportSummary.headline && ( +

+ {song.exportSummary.headline} +

+ )} +
+ ) : ( +
+

🎵 Choose a local audio file or import from YouTube to get started.

+

BandScope will analyze harmony, form, groove, and player cues for your rehearsal.

+
+ )} +
+ ); } diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index d725bec..37bc12f 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,4 +1,56 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; + /** Documented. */ -export function PlayerFeature(props: { title: string }) { - return

{props.title}

; +export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { + const { title, song } = props; + + if (!song) { + return ( +
+

{title}

+

No song loaded. Start an analysis to use the player.

+
+ ); + } + + return ( +
+

{title}

+
+
+ {song.title} + + {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + +
+
+ {song.sections.map((section) => ( + + {section.label} + + ))} +
+
+ Audio playback requires the desktop app with a local audio source. +
+
+
+ ); } diff --git a/apps/desktop/src/features/ranges/index.tsx b/apps/desktop/src/features/ranges/index.tsx index 5adc959..4dedd78 100644 --- a/apps/desktop/src/features/ranges/index.tsx +++ b/apps/desktop/src/features/ranges/index.tsx @@ -1,4 +1,66 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; + /** Documented. */ -export function RangesFeature(props: { title: string }) { - return

{props.title}

; +export function RangesFeature(props: { title: string; song?: RehearsalSong | null }) { + const { title, song } = props; + + if (!song) { + return ( +
+

{title}

+

No song loaded. Start an analysis to see range data.

+
+ ); + } + + return ( +
+

{title}

+ {song.sections.map((section) => ( +
+

{section.label}

+
+ {section.roles.map((role) => ( +
+
+ {role.name} +
+
+ 🎵 {role.range.lowestNote} — {role.range.highestNote} +
+ {role.overlapWarnings.length > 0 && ( +
+ {role.overlapWarnings.map((warning, wIndex) => ( +
+ ⚠️ {warning} +
+ ))} +
+ )} +
+ ))} +
+
+ ))} +
+ ); } diff --git a/apps/desktop/src/features/settings/index.tsx b/apps/desktop/src/features/settings/index.tsx index 3e1c303..b524e0d 100644 --- a/apps/desktop/src/features/settings/index.tsx +++ b/apps/desktop/src/features/settings/index.tsx @@ -1,4 +1,50 @@ +import { SUPPORTED_AUDIO_FORMATS } from "@bandscope/shared-types"; + /** Documented. */ export function SettingsFeature(props: { title: string }) { - return

{props.title}

; + const { title } = props; + + return ( +
+

{title}

+
+
+

Supported Audio Formats

+
+ {SUPPORTED_AUDIO_FORMATS.map((format) => ( + + .{format} + + ))} +
+
+ +
+

Analysis Pipeline

+
    +
  • Decode audio source
  • +
  • Draft section and role extraction
  • +
  • Separate stems by category
  • +
  • Persist analysis results
  • +
+
+ +
+

About

+

+ BandScope is a local-first rehearsal prep tool. All analysis runs on your device. +

+
+
+
+ ); } diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 9d29eaa..4c2c71f 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -132,6 +132,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma ✨ {role.simplification} )} + {role.overlapWarnings.length > 0 && ( +
+ {role.overlapWarnings.map((warning, wIdx) => ( +
+ ⚠️ {warning} +
+ ))} +
+ )} ))} diff --git a/apps/desktop/src/lib/export.test.ts b/apps/desktop/src/lib/export.test.ts index fb32408..415c778 100644 --- a/apps/desktop/src/lib/export.test.ts +++ b/apps/desktop/src/lib/export.test.ts @@ -45,8 +45,12 @@ describe("export generation", () => { rehearsalPriority: "high", simplification: "simple", setupNote: "setup", - manualOverrides: [] + manualOverrides: [], + overlapWarnings: [] } + ], + partGraph: [ + { role_id: "r1", is_active: true, handoff_to: [], handoff_from: [] } ] } ] diff --git a/package-lock.json b/package-lock.json index ee10cf6..48ff549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3458,7 +3458,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3480,7 +3479,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3502,7 +3500,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3524,7 +3521,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3546,7 +3542,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3568,7 +3563,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3590,7 +3584,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3612,7 +3605,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3634,7 +3626,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3656,7 +3647,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3678,7 +3668,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 023b563..cf5132a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -83,6 +83,15 @@ export type RehearsalRole = { simplification: string; setupNote: string; manualOverrides: ManualOverride[]; + overlapWarnings: string[]; +}; + +/** Documented. */ +export type PartGraphNode = { + role_id: string; + is_active: boolean; + handoff_to: string[]; + handoff_from: string[]; }; /** Documented. */ @@ -92,6 +101,7 @@ export type RehearsalSection = { groove: string; confidence: ConfidenceMarker; roles: RehearsalRole[]; + partGraph: PartGraphNode[]; }; /** Documented. */ @@ -256,7 +266,10 @@ const demoRehearsalSongSeed: RehearsalSong = { rehearsalPriority: "high", simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", - manualOverrides: [] + manualOverrides: [], + overlapWarnings: [ + "Density warning: competing with Keyboard Left Hand in low register." + ] }, { id: "keys-right", @@ -283,7 +296,10 @@ const demoRehearsalSongSeed: RehearsalSong = { 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: [] + manualOverrides: [], + overlapWarnings: [ + "Melodic overlap: top notes conflict with Lead Vocal range." + ] }, { id: "lead-vocal", @@ -320,8 +336,16 @@ const demoRehearsalSongSeed: RehearsalSong = { }, source: "user" } + ], + overlapWarnings: [ + "Melodic overlap: competing with Keyboard 1 Right Hand." ] } + ], + partGraph: [ + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] } ] } ], @@ -757,7 +781,8 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "rehearsalPriority", "simplification", "setupNote", - "manualOverrides" + "manualOverrides", + "overlapWarnings" ], path ); @@ -812,6 +837,49 @@ function validateRehearsalRole(value: unknown, path: string): string | null { return overrideError; } } + if (!isDenseArray(value.overlapWarnings)) { + return invalidField(`${path}.overlapWarnings`); + } + for (const [index, warning] of value.overlapWarnings.entries()) { + if (typeof warning !== "string") { + return invalidField(`${path}.overlapWarnings[${index}]`); + } + } + + return null; +} + +/** Documented. */ +function validatePartGraphNode(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + const extraKey = unexpectedKey(value, ["role_id", "is_active", "handoff_to", "handoff_from"], path); + if (extraKey) { + return extraKey; + } + if (typeof value.role_id !== "string") { + return invalidField(`${path}.role_id`); + } + if (typeof value.is_active !== "boolean") { + return invalidField(`${path}.is_active`); + } + if (!isDenseArray(value.handoff_to)) { + return invalidField(`${path}.handoff_to`); + } + for (const [index, handoff] of value.handoff_to.entries()) { + if (typeof handoff !== "string") { + return invalidField(`${path}.handoff_to[${index}]`); + } + } + if (!isDenseArray(value.handoff_from)) { + return invalidField(`${path}.handoff_from`); + } + for (const [index, handoff] of value.handoff_from.entries()) { + if (typeof handoff !== "string") { + return invalidField(`${path}.handoff_from[${index}]`); + } + } return null; } @@ -821,7 +889,7 @@ function validateRehearsalSection(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); } - const extraKey = unexpectedKey(value, ["id", "label", "groove", "confidence", "roles"], path); + const extraKey = unexpectedKey(value, ["id", "label", "groove", "confidence", "roles", "partGraph"], path); if (extraKey) { return extraKey; } @@ -850,6 +918,16 @@ function validateRehearsalSection(value: unknown, path: string): string | null { } } + if (!isDenseArray(value.partGraph)) { + return invalidField(`${path}.partGraph`); + } + for (const [index, node] of value.partGraph.entries()) { + const nodeError = validatePartGraphNode(node, `${path}.partGraph[${index}]`); + if (nodeError) { + return nodeError; + } + } + return null; } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 3d21896..a4be98e 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -793,6 +793,72 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.manualOverrides = new Array(1) as never; }) }, + { + message: "sections[0].roles[0].overlapWarnings", + payload: createInvalidSong((song) => { + (song.sections[0]!.roles[0] as unknown as Record).overlapWarnings = "not-an-array"; + }) + }, + { + message: "sections[0].roles[0].overlapWarnings[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.overlapWarnings = [42 as never]; + }) + }, + { + message: "sections[0].partGraph", + payload: createInvalidSong((song) => { + (song.sections[0] as unknown as Record).partGraph = "not-an-array"; + }) + }, + { + message: "sections[0].partGraph[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph = [null as never]; + }) + }, + { + message: "sections[0].partGraph[0].role_id", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.role_id = 42 as never; + }) + }, + { + message: "sections[0].partGraph[0].is_active", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.is_active = "yes" as never; + }) + }, + { + message: "sections[0].partGraph[0].handoff_to", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.handoff_to = "not-an-array" as never; + }) + }, + { + message: "sections[0].partGraph[0].handoff_to[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.handoff_to = [42 as never]; + }) + }, + { + message: "sections[0].partGraph[0].handoff_from", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.handoff_from = "not-an-array" as never; + }) + }, + { + message: "sections[0].partGraph[0].handoff_from[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.partGraph[0]!.handoff_from = [42 as never]; + }) + }, + { + message: "sections[0].partGraph[0].extraField", + payload: createInvalidSong((song) => { + (song.sections[0]!.partGraph[0] as unknown as Record).extraField = true; + }) + }, { message: "exportSummary.focusSections", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/chords/__init__.py b/services/analysis-engine/src/bandscope_analysis/chords/__init__.py index 18f8f8e..5f92827 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/__init__.py @@ -1 +1,11 @@ -"""Chord analysis placeholders.""" +"""Chord analysis module for extracting harmonic content from sections.""" + +from .analyzer import ChordAnalyzer +from .model import ChordAnalysisResult, ChordLabel, SectionChordSummary + +__all__ = [ + "ChordAnalyzer", + "ChordAnalysisResult", + "ChordLabel", + "SectionChordSummary", +] diff --git a/services/analysis-engine/src/bandscope_analysis/chords/analyzer.py b/services/analysis-engine/src/bandscope_analysis/chords/analyzer.py new file mode 100644 index 0000000..84db7e8 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/chords/analyzer.py @@ -0,0 +1,128 @@ +"""Chord analysis logic for extracting harmonic content from sections.""" + +from __future__ import annotations + +import logging +from typing import Any, Literal + +from .model import ChordAnalysisResult, ChordLabel, SectionChordSummary + +logger = logging.getLogger(__name__) + +# Default key center when no harmonic context is available +_DEFAULT_KEY_CENTER = "C" + + +class ChordAnalyzer: + """Analyzes chord progressions from section and role data. + + Security Notes: + - Processes untrusted input: chord symbols, function labels, and source + fields from role harmony data. + - Input validation: all values are coerced to str via str(); no eval or exec. + - Safe failure: missing or malformed harmony data is skipped silently. + - Trust boundary: chord and functionLabel are treated as opaque strings; + they are stored but not interpreted or executed. + - Allowlist: source field is passed through as-is; the upstream validator + constrains it to 'model' | 'user'. + """ + + def __init__(self) -> None: + """Initialize the chord analyzer.""" + pass + + def analyze( + self, + sections: list[dict[str, Any]], + roles_by_section: dict[str, list[dict[str, Any]]] | None = None, + ) -> ChordAnalysisResult: + """Analyze chord content for the given sections. + + Args: + sections: List of section dicts (must contain 'id'). + roles_by_section: Optional mapping of section_id to roles with harmony data. + + Returns: + ChordAnalysisResult containing per-section chord summaries. + """ + summaries: list[SectionChordSummary] = [] + + 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}") + + chords: list[ChordLabel] = [] + key_center = _DEFAULT_KEY_CENTER + + # Extract chords from roles if available + section_roles = (roles_by_section or {}).get(section_id, []) + seen_chords: set[str] = set() + for role in section_roles: + harmony = role.get("harmony") + if isinstance(harmony, dict) and "chord" in harmony: + chord_name = str(harmony["chord"]) + if chord_name not in seen_chords: + seen_chords.add(chord_name) + chords.append( + { + "chord": chord_name, + "functionLabel": str(harmony.get("functionLabel", "")), + "source": harmony.get("source", "model"), + } + ) + + # Infer key center from the first chord if available + if chords: + key_center = _infer_key_center(chords[0]["chord"]) + + confidence_level: Literal["low", "medium", "high"] = "medium" if chords else "low" + confidence_source: Literal["model", "user"] = "model" + + # If any chord has user source, mark as user-sourced + for chord in chords: + if chord["source"] == "user": + confidence_source = "user" + confidence_level = "high" + break + + summaries.append( + { + "section_id": section_id, + "chords": chords, + "key_center": key_center, + "confidence_level": confidence_level, + "confidence_source": confidence_source, + } + ) + + return { + "sections": summaries, + "analysis_notes": f"Analyzed chords for {len(summaries)} sections.", + } + + +def _infer_key_center(chord: str) -> str: + """Infer a key center from a chord symbol. + + Extracts the root note from a chord symbol by taking the first + character (and optional sharp/flat modifier). + + Args: + chord: A chord symbol like 'C#m7', 'Bb', 'G'. + + Returns: + The root note as a key center string. + """ + if not chord: + return _DEFAULT_KEY_CENTER + root = chord[0] + if len(chord) > 1 and chord[1] in ("#", "b"): + root += chord[1] + return root diff --git a/services/analysis-engine/src/bandscope_analysis/chords/model.py b/services/analysis-engine/src/bandscope_analysis/chords/model.py new file mode 100644 index 0000000..392f230 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/chords/model.py @@ -0,0 +1,30 @@ +"""Domain model for chord analysis.""" + +from __future__ import annotations + +from typing import Literal, TypedDict + + +class ChordLabel(TypedDict): + """A single chord label attached to a section or role context.""" + + chord: str + functionLabel: str + source: Literal["model", "user"] + + +class SectionChordSummary(TypedDict): + """Chord summary for a single section.""" + + section_id: str + chords: list[ChordLabel] + key_center: str + confidence_level: Literal["low", "medium", "high"] + confidence_source: Literal["model", "user"] + + +class ChordAnalysisResult(TypedDict): + """Result returned by the chord analysis pipeline.""" + + sections: list[SectionChordSummary] + analysis_notes: str diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/__init__.py b/services/analysis-engine/src/bandscope_analysis/ranges/__init__.py index bc82945..a00e4a4 100644 --- a/services/analysis-engine/src/bandscope_analysis/ranges/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/ranges/__init__.py @@ -1 +1,17 @@ -"""Range analysis placeholders.""" +"""Range analysis module for detecting pitch ranges and overlaps.""" + +from .analyzer import RangeAnalyzer +from .model import ( + RangeAnalysisResult, + RangeInfo, + RangeOverlap, + SectionRangeSummary, +) + +__all__ = [ + "RangeAnalyzer", + "RangeAnalysisResult", + "RangeInfo", + "RangeOverlap", + "SectionRangeSummary", +] diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py new file mode 100644 index 0000000..f5d2c24 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py @@ -0,0 +1,252 @@ +"""Range analysis logic for detecting pitch ranges and overlaps.""" + +from __future__ import annotations + +import logging +from typing import Any, Literal + +from .model import ( + RangeAnalysisResult, + RangeInfo, + RangeOverlap, + SectionRangeSummary, +) + +logger = logging.getLogger(__name__) + +# Chromatic note order for comparison (octave-independent). +_NOTE_ORDER = [ + "C", + "C#", + "Db", + "D", + "D#", + "Eb", + "E", + "F", + "F#", + "Gb", + "G", + "G#", + "Ab", + "A", + "A#", + "Bb", + "B", +] + + +def _parse_note(note: str) -> tuple[str, int]: + """Parse a note string like 'C#4' into (name, octave). + + Security Notes: + - Input is untrusted string from role range data. + - Safe failure: returns default ('C', 4) for empty or malformed input. + - No exec or eval; only character-level parsing with int conversion. + - Bounded input: only processes single note strings. + + Args: + note: A note string such as 'C4', 'G#3', 'Bb2'. + + Returns: + A tuple of (note_name, octave). + """ + if not note: + return ("C", 4) + # Find the boundary between note name and octave number by scanning + # from the end of the string. Octave digits appear at the tail. + for i in range(len(note) - 1, -1, -1): + if note[i].isdigit() or (note[i] == "-" and i == len(note) - 1): + # Still in the octave portion; continue scanning left. + pass + else: + # Found the last non-digit character; split here. + name = note[: i + 1] + octave_str = note[i + 1 :] + if octave_str and (octave_str.isdigit() or (octave_str[0] == "-")): + return (name, int(octave_str)) + return (name, 4) + # Entire string was digits (edge case); return as-is with default octave. + return (note, 4) + + +def _note_to_midi(note: str) -> int: + """Convert a note string to an approximate MIDI number for comparison. + + Args: + note: A note string such as 'C4', 'G#3'. + + Returns: + An integer MIDI-like value for ordering purposes. + """ + name, octave = _parse_note(note) + + # Normalize enharmonics + note_values = { + "C": 0, + "C#": 1, + "Db": 1, + "D": 2, + "D#": 3, + "Eb": 3, + "E": 4, + "F": 5, + "F#": 6, + "Gb": 6, + "G": 7, + "G#": 8, + "Ab": 8, + "A": 9, + "A#": 10, + "Bb": 10, + "B": 11, + } + + semitone = note_values.get(name, 0) + return (octave + 1) * 12 + semitone + + +def _ranges_overlap(low_a: str, high_a: str, low_b: str, high_b: str) -> bool: + """Check if two note ranges overlap. + + Args: + low_a: Lowest note of range A. + high_a: Highest note of range A. + low_b: Lowest note of range B. + high_b: Highest note of range B. + + Returns: + True if the ranges overlap. + """ + midi_low_a = _note_to_midi(low_a) + midi_high_a = _note_to_midi(high_a) + midi_low_b = _note_to_midi(low_b) + midi_high_b = _note_to_midi(high_b) + return midi_low_a <= midi_high_b and midi_low_b <= midi_high_a + + +def _overlap_severity( + low_a: str, high_a: str, low_b: str, high_b: str +) -> Literal["low", "medium", "high"]: + """Determine severity of range overlap. + + Args: + low_a: Lowest note of range A. + high_a: Highest note of range A. + low_b: Lowest note of range B. + high_b: Highest note of range B. + + Returns: + Severity level: 'low', 'medium', or 'high'. + """ + midi_low_a = _note_to_midi(low_a) + midi_high_a = _note_to_midi(high_a) + midi_low_b = _note_to_midi(low_b) + midi_high_b = _note_to_midi(high_b) + + overlap_low = max(midi_low_a, midi_low_b) + overlap_high = min(midi_high_a, midi_high_b) + overlap_size = overlap_high - overlap_low + + range_a_size = midi_high_a - midi_low_a + range_b_size = midi_high_b - midi_low_b + min_range = min(range_a_size, range_b_size) if min(range_a_size, range_b_size) > 0 else 1 + + ratio = overlap_size / min_range + if ratio > 0.5: + return "high" + if ratio > 0.25: + return "medium" + return "low" + + +class RangeAnalyzer: + """Analyzes pitch ranges and detects overlaps between roles.""" + + def __init__(self) -> None: + """Initialize the range analyzer.""" + pass + + def analyze( + self, + sections: list[dict[str, Any]], + roles_by_section: dict[str, list[dict[str, Any]]] | None = None, + ) -> RangeAnalysisResult: + """Analyze ranges for roles in each section. + + Args: + sections: List of section dicts (must contain 'id'). + roles_by_section: Optional mapping of section_id to roles with range data. + + Returns: + RangeAnalysisResult containing per-section range summaries. + """ + summaries: list[SectionRangeSummary] = [] + + 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}") + + section_roles = (roles_by_section or {}).get(section_id, []) + ranges: list[RangeInfo] = [] + overlaps: list[RangeOverlap] = [] + + for role in section_roles: + role_range = role.get("range") + if isinstance(role_range, dict): + ranges.append( + { + "role_id": str(role.get("id", "")), + "role_name": str(role.get("name", "")), + "lowestNote": str(role_range.get("lowestNote", "")), + "highestNote": str(role_range.get("highestNote", "")), + } + ) + + # Detect overlaps between all pairs of ranges + for a_idx in range(len(ranges)): + for b_idx in range(a_idx + 1, len(ranges)): + r_a = ranges[a_idx] + r_b = ranges[b_idx] + if _ranges_overlap( + r_a["lowestNote"], + r_a["highestNote"], + r_b["lowestNote"], + r_b["highestNote"], + ): + severity = _overlap_severity( + r_a["lowestNote"], + r_a["highestNote"], + r_b["lowestNote"], + r_b["highestNote"], + ) + overlaps.append( + { + "role_a": r_a["role_id"], + "role_b": r_b["role_id"], + "overlap_region": ( + f"{r_a['role_name']} and {r_b['role_name']} overlap" + ), + "severity": severity, + } + ) + + summaries.append( + { + "section_id": section_id, + "ranges": ranges, + "overlaps": overlaps, + } + ) + + return { + "sections": summaries, + "analysis_notes": f"Analyzed ranges for {len(summaries)} sections.", + } diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/model.py b/services/analysis-engine/src/bandscope_analysis/ranges/model.py new file mode 100644 index 0000000..eea8127 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/ranges/model.py @@ -0,0 +1,38 @@ +"""Domain model for range analysis.""" + +from __future__ import annotations + +from typing import Literal, TypedDict + + +class RangeInfo(TypedDict): + """Range information for a single role.""" + + role_id: str + role_name: str + lowestNote: str + highestNote: str + + +class RangeOverlap(TypedDict): + """Describes a range overlap between two roles.""" + + role_a: str + role_b: str + overlap_region: str + severity: Literal["low", "medium", "high"] + + +class SectionRangeSummary(TypedDict): + """Range summary for a single section.""" + + section_id: str + ranges: list[RangeInfo] + overlaps: list[RangeOverlap] + + +class RangeAnalysisResult(TypedDict): + """Result returned by the range analysis pipeline.""" + + sections: list[SectionRangeSummary] + analysis_notes: str diff --git a/services/analysis-engine/src/bandscope_analysis/separation/__init__.py b/services/analysis-engine/src/bandscope_analysis/separation/__init__.py index 9224641..e88672c 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/__init__.py @@ -1 +1,11 @@ -"""Source-separation placeholders.""" +"""Source separation module for categorizing roles into stem groups.""" + +from .model import SeparationResult, StemCategory, StemDescriptor +from .separator import StemSeparator + +__all__ = [ + "StemSeparator", + "StemCategory", + "StemDescriptor", + "SeparationResult", +] diff --git a/services/analysis-engine/src/bandscope_analysis/separation/model.py b/services/analysis-engine/src/bandscope_analysis/separation/model.py new file mode 100644 index 0000000..ad527e4 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/separation/model.py @@ -0,0 +1,33 @@ +"""Domain model for source separation.""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal, TypedDict + + +class StemCategory(str, Enum): + """Canonical stem categories for source separation.""" + + VOCALS = "vocals" + BASS = "bass" + DRUMS = "drums" + KEYS = "keys" + GUITAR = "guitar" + OTHER = "other" + + +class StemDescriptor(TypedDict): + """Descriptor for a single stem extracted from a mix.""" + + stem_id: str + category: str + label: str + confidence: Literal["low", "medium", "high"] + + +class SeparationResult(TypedDict): + """Result returned by the source separation pipeline.""" + + stems: list[StemDescriptor] + separation_notes: str diff --git a/services/analysis-engine/src/bandscope_analysis/separation/separator.py b/services/analysis-engine/src/bandscope_analysis/separation/separator.py new file mode 100644 index 0000000..10980ca --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/separation/separator.py @@ -0,0 +1,113 @@ +"""Source separation logic for categorizing stems from roles.""" + +from __future__ import annotations + +import logging +from typing import Any, Literal + +from .model import SeparationResult, StemCategory, StemDescriptor + +logger = logging.getLogger(__name__) + +# Mapping of common role type keywords to stem categories. +_ROLE_TO_STEM: dict[str, StemCategory] = { + "vocal": StemCategory.VOCALS, + "bass": StemCategory.BASS, + "drum": StemCategory.DRUMS, + "keys": StemCategory.KEYS, + "keyboard": StemCategory.KEYS, + "piano": StemCategory.KEYS, + "guitar": StemCategory.GUITAR, +} + + +def _categorize_role(role_id: str, role_name: str, role_type: str) -> StemCategory: + """Determine the stem category for a role based on its metadata. + + Args: + role_id: The role identifier. + role_name: The human-readable role name. + role_type: The role type (instrument, vocal, hand). + + Returns: + The inferred StemCategory. + """ + if role_type == "vocal": + return StemCategory.VOCALS + + search_text = f"{role_id} {role_name}".lower() + for keyword, category in _ROLE_TO_STEM.items(): + if keyword in search_text: + return category + + return StemCategory.OTHER + + +class StemSeparator: + """Categorizes roles into stem groups for source separation. + + Security Notes: + - Processes untrusted input: role IDs, names, and role type strings. + - Input validation: all values are coerced to str via str(); no eval or exec. + - Safe failure: non-dict roles are skipped with a warning log. + - Allowlist: role categorization uses a fixed keyword map (_ROLE_TO_STEM); + unrecognized roles fall through to StemCategory.OTHER. + - Trust boundary: role names and IDs are treated as opaque labels; they are + stored but not interpreted or executed. + """ + + def __init__(self) -> None: + """Initialize the stem separator.""" + pass + + def separate( + self, + roles: list[dict[str, Any]], + ) -> SeparationResult: + """Categorize roles into stem descriptors. + + Args: + roles: List of role dicts with 'id', 'name', and 'roleType' fields. + + Returns: + SeparationResult with stem descriptors and notes. + """ + stems: list[StemDescriptor] = [] + seen_ids: set[str] = set() + + for i, role in enumerate(roles): + if not isinstance(role, dict): + logger.warning( + "Invalid role format at index %d; expected dict, got %s", + i, + type(role).__name__, + ) + continue + + role_id = str(role.get("id", f"role-{i}")) + if role_id in seen_ids: + continue + seen_ids.add(role_id) + + role_name = str(role.get("name", "")) + role_type = str(role.get("roleType", "")) + category = _categorize_role(role_id, role_name, role_type) + + # Confidence based on role type specificity + confidence: Literal["low", "medium", "high"] = ( + "high" if role_type in ("vocal", "instrument") else "medium" + ) + + stems.append( + { + "stem_id": f"stem-{role_id}", + "category": category.value, + "label": role_name or role_id, + "confidence": confidence, + } + ) + + return { + "stems": stems, + "separation_notes": f"Categorized {len(stems)} roles into stems.", + } diff --git a/services/analysis-engine/tests/test_chords.py b/services/analysis-engine/tests/test_chords.py new file mode 100644 index 0000000..765d0fc --- /dev/null +++ b/services/analysis-engine/tests/test_chords.py @@ -0,0 +1,133 @@ +"""Tests for the chord analysis module.""" + +from bandscope_analysis.chords.analyzer import ChordAnalyzer, _infer_key_center +from bandscope_analysis.chords.model import ChordAnalysisResult + + +def test_chord_analyzer_empty_sections() -> None: + """Test analyzer with empty sections list.""" + analyzer = ChordAnalyzer() + result = analyzer.analyze([]) + assert result["sections"] == [] + assert "0 sections" in result["analysis_notes"] + + +def test_chord_analyzer_no_roles() -> None: + """Test analyzer with sections but no role data.""" + analyzer = ChordAnalyzer() + result = analyzer.analyze([{"id": "verse-1"}, {"id": "chorus-1"}]) + assert len(result["sections"]) == 2 + assert result["sections"][0]["section_id"] == "verse-1" + assert result["sections"][0]["chords"] == [] + assert result["sections"][0]["key_center"] == "C" + assert result["sections"][0]["confidence_level"] == "low" + + +def test_chord_analyzer_with_roles() -> None: + """Test analyzer extracts chords from role harmony data.""" + analyzer = ChordAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"harmony": {"chord": "C#m7", "functionLabel": "vi pedal anchor", "source": "model"}}, + {"harmony": {"chord": "Emaj7", "functionLabel": "Imaj7 color", "source": "model"}}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert len(result["sections"]) == 1 + summary = result["sections"][0] + assert summary["section_id"] == "verse-1" + assert len(summary["chords"]) == 2 + assert summary["chords"][0]["chord"] == "C#m7" + assert summary["chords"][1]["chord"] == "Emaj7" + assert summary["key_center"] == "C#" + assert summary["confidence_level"] == "medium" + + +def test_chord_analyzer_deduplicates_chords() -> None: + """Test analyzer deduplicates identical chords within a section.""" + analyzer = ChordAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"harmony": {"chord": "C#m7", "functionLabel": "vi", "source": "model"}}, + {"harmony": {"chord": "C#m7", "functionLabel": "vi repeated", "source": "model"}}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert len(result["sections"][0]["chords"]) == 1 + + +def test_chord_analyzer_user_source_confidence() -> None: + """Test that user-sourced chords raise confidence to high.""" + analyzer = ChordAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"harmony": {"chord": "Dm", "functionLabel": "ii", "source": "user"}}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + summary = result["sections"][0] + assert summary["confidence_level"] == "high" + assert summary["confidence_source"] == "user" + + +def test_chord_analyzer_invalid_section() -> None: + """Test analyzer handles non-dict sections gracefully.""" + analyzer = ChordAnalyzer() + result = analyzer.analyze([{"id": "verse-1"}, "invalid"]) + assert len(result["sections"]) == 2 + assert result["sections"][0]["section_id"] == "verse-1" + assert result["sections"][1]["section_id"] == "section-1" + + +def test_chord_analyzer_missing_section_id() -> None: + """Test analyzer generates section id when missing.""" + analyzer = ChordAnalyzer() + result = analyzer.analyze([{}]) + assert result["sections"][0]["section_id"] == "section-0" + + +def test_chord_analyzer_roles_missing_harmony() -> None: + """Test analyzer skips roles without harmony data.""" + analyzer = ChordAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"id": "bass", "name": "Bass"}, + {"id": "vocal", "harmony": "not-a-dict"}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert result["sections"][0]["chords"] == [] + + +def test_chord_analyzer_harmony_missing_function_label() -> None: + """Test analyzer handles harmony without functionLabel.""" + analyzer = ChordAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"harmony": {"chord": "G", "source": "model"}}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert result["sections"][0]["chords"][0]["functionLabel"] == "" + + +def test_infer_key_center_basic() -> None: + """Test key center inference from common chords.""" + assert _infer_key_center("C#m7") == "C#" + assert _infer_key_center("Bb") == "Bb" + assert _infer_key_center("G") == "G" + assert _infer_key_center("") == "C" + assert _infer_key_center("Am") == "A" + + +def test_chord_analysis_result_structure() -> None: + """Test that result conforms to ChordAnalysisResult type structure.""" + analyzer = ChordAnalyzer() + result: ChordAnalysisResult = analyzer.analyze([{"id": "intro-1"}]) + assert "sections" in result + assert "analysis_notes" in result diff --git a/services/analysis-engine/tests/test_ranges.py b/services/analysis-engine/tests/test_ranges.py new file mode 100644 index 0000000..b570a90 --- /dev/null +++ b/services/analysis-engine/tests/test_ranges.py @@ -0,0 +1,198 @@ +"""Tests for the range analysis module.""" + +from bandscope_analysis.ranges.analyzer import ( + RangeAnalyzer, + _note_to_midi, + _overlap_severity, + _parse_note, + _ranges_overlap, +) +from bandscope_analysis.ranges.model import RangeAnalysisResult + + +def test_parse_note_basic() -> None: + """Test basic note parsing.""" + assert _parse_note("C4") == ("C", 4) + assert _parse_note("G#3") == ("G#", 3) + assert _parse_note("Bb2") == ("Bb", 2) + assert _parse_note("") == ("C", 4) + + +def test_parse_note_without_octave() -> None: + """Test note parsing without explicit octave.""" + assert _parse_note("C") == ("C", 4) + + +def test_parse_note_all_digits() -> None: + """Test note parsing when input is all digits (edge case).""" + assert _parse_note("4") == ("4", 4) + + +def test_note_to_midi() -> None: + """Test MIDI number conversion for note comparison.""" + assert _note_to_midi("C4") == 60 + assert _note_to_midi("C#4") == 61 + assert _note_to_midi("D4") == 62 + assert _note_to_midi("C5") > _note_to_midi("C4") + assert _note_to_midi("G#3") < _note_to_midi("C4") + + +def test_ranges_overlap_true() -> None: + """Test overlapping ranges are detected.""" + assert _ranges_overlap("C2", "E3", "C#2", "C#3") is True + + +def test_ranges_overlap_false() -> None: + """Test non-overlapping ranges are correctly identified.""" + assert _ranges_overlap("C2", "E2", "A4", "C5") is False + + +def test_overlap_severity_high() -> None: + """Test high severity overlap detection.""" + # Ranges almost completely overlap + result = _overlap_severity("C3", "C5", "C3", "C5") + assert result == "high" + + +def test_overlap_severity_low() -> None: + """Test low severity overlap detection.""" + # Ranges barely overlap + result = _overlap_severity("C2", "G4", "F#4", "C6") + assert result == "low" + + +def test_overlap_severity_medium() -> None: + """Test medium severity overlap detection.""" + # C3-C5 = 24 semitones, A3-G6 = 34 semitones. + # Overlap is A3-C5 = 15 semitones. ratio = 15/24 ≈ 0.625 -> not medium + # Need ranges with overlap ratio between 0.25 and 0.5 + # E.g. C3(48)-C5(72) = 24 semitones, A4(69)-A6(93) = 24 semitones + # Overlap = A4(69)-C5(72) = 3 semitones. ratio = 3/24 = 0.125 -> low + # Try C3-G4(67) = 19 and E4(64)-G6 = 31. overlap = E4(64)-G4(67) = 3, ratio 3/19=0.15 -> low + # Try C3-C5(72) = 24, G4(67)-E5(76) = 9. Overlap = G4(67)-C5(72) = 5, ratio 5/9 = 0.55 -> high + # For medium: 0.25 < ratio <= 0.5. Need overlap/min_range in (0.25, 0.5] + # C3(48)-C5(72) = 24, A4(69)-C6(84) = 15. Overlap = A4(69)-C5(72)= 3. ratio = 3/15 = 0.2 -> low + # C3(48)-G5(79)=31, E5(76)-E6(88)=12. Overlap = E5(76)-G5(79)=3. ratio 3/12=0.25 -> low (<=0.25) + # C3(48)-G5(79)=31, D5(74)-E6(88)=14. Overlap = D5(74)-G5(79)=5. ratio 5/14=0.357 -> medium + result = _overlap_severity("C3", "G5", "D5", "E6") + assert result == "medium" + + +def test_range_analyzer_empty() -> None: + """Test analyzer with empty sections.""" + analyzer = RangeAnalyzer() + result = analyzer.analyze([]) + assert result["sections"] == [] + assert "0 sections" in result["analysis_notes"] + + +def test_range_analyzer_no_roles() -> None: + """Test analyzer with sections but no role data.""" + analyzer = RangeAnalyzer() + result = analyzer.analyze([{"id": "verse-1"}]) + assert len(result["sections"]) == 1 + assert result["sections"][0]["ranges"] == [] + assert result["sections"][0]["overlaps"] == [] + + +def test_range_analyzer_with_roles() -> None: + """Test analyzer extracts ranges from role data.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + { + "id": "bass", + "name": "Bass Guitar", + "range": {"lowestNote": "C#2", "highestNote": "E3"}, + }, + { + "id": "vocal", + "name": "Lead Vocal", + "range": {"lowestNote": "G#3", "highestNote": "C#5"}, + }, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert len(result["sections"][0]["ranges"]) == 2 + assert result["sections"][0]["ranges"][0]["role_id"] == "bass" + assert result["sections"][0]["ranges"][1]["role_id"] == "vocal" + + +def test_range_analyzer_detects_overlap() -> None: + """Test analyzer detects overlapping ranges.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"id": "bass", "name": "Bass", "range": {"lowestNote": "C#2", "highestNote": "E3"}}, + { + "id": "keys-left", + "name": "Keys Left", + "range": {"lowestNote": "C#2", "highestNote": "C#3"}, + }, + ] + } + result = analyzer.analyze(sections, roles_by_section) + overlaps = result["sections"][0]["overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["role_a"] == "bass" + assert overlaps[0]["role_b"] == "keys-left" + + +def test_range_analyzer_no_overlap() -> None: + """Test analyzer correctly finds no overlaps when ranges are disjoint.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + { + "id": "bass", + "name": "Bass", + "range": {"lowestNote": "C2", "highestNote": "E2"}, + }, + { + "id": "vocal", + "name": "Vocal", + "range": {"lowestNote": "A4", "highestNote": "C6"}, + }, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert result["sections"][0]["overlaps"] == [] + + +def test_range_analyzer_invalid_section() -> None: + """Test analyzer handles non-dict sections gracefully.""" + analyzer = RangeAnalyzer() + result = analyzer.analyze([{"id": "verse-1"}, "invalid"]) + assert len(result["sections"]) == 2 + assert result["sections"][1]["section_id"] == "section-1" + + +def test_range_analyzer_missing_section_id() -> None: + """Test analyzer generates section id when missing.""" + analyzer = RangeAnalyzer() + result = analyzer.analyze([{}]) + assert result["sections"][0]["section_id"] == "section-0" + + +def test_range_analyzer_role_missing_range() -> None: + """Test analyzer skips roles without range data.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + {"id": "bass", "name": "Bass"}, + ] + } + result = analyzer.analyze(sections, roles_by_section) + assert result["sections"][0]["ranges"] == [] + + +def test_range_analysis_result_structure() -> None: + """Test that result conforms to RangeAnalysisResult type structure.""" + analyzer = RangeAnalyzer() + result: RangeAnalysisResult = analyzer.analyze([{"id": "intro-1"}]) + assert "sections" in result + assert "analysis_notes" in result diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py new file mode 100644 index 0000000..eb8ce9b --- /dev/null +++ b/services/analysis-engine/tests/test_separation.py @@ -0,0 +1,125 @@ +"""Tests for the source separation module.""" + +from bandscope_analysis.separation.model import StemCategory +from bandscope_analysis.separation.separator import StemSeparator, _categorize_role + + +def test_stem_category_enum() -> None: + """Verify StemCategory enum values match the domain requirements.""" + assert StemCategory.VOCALS.value == "vocals" + assert StemCategory.BASS.value == "bass" + assert StemCategory.DRUMS.value == "drums" + assert StemCategory.KEYS.value == "keys" + assert StemCategory.GUITAR.value == "guitar" + assert StemCategory.OTHER.value == "other" + + +def test_categorize_role_vocal() -> None: + """Test vocal role type is categorized correctly.""" + assert _categorize_role("lead-vocal", "Lead Vocal", "vocal") == StemCategory.VOCALS + + +def test_categorize_role_bass() -> None: + """Test bass instrument role is categorized correctly.""" + assert _categorize_role("bass-guitar", "Bass Guitar", "instrument") == StemCategory.BASS + + +def test_categorize_role_keys() -> None: + """Test keyboard role is categorized correctly.""" + assert _categorize_role("keys-right", "Keyboard 1 Right Hand", "hand") == StemCategory.KEYS + + +def test_categorize_role_piano() -> None: + """Test piano role is categorized correctly.""" + assert _categorize_role("piano-1", "Piano", "instrument") == StemCategory.KEYS + + +def test_categorize_role_guitar() -> None: + """Test guitar role is categorized correctly.""" + assert _categorize_role("guitar-1", "Electric Guitar", "instrument") == StemCategory.GUITAR + + +def test_categorize_role_drums() -> None: + """Test drum role is categorized correctly.""" + assert _categorize_role("drum-kit", "Drum Kit", "instrument") == StemCategory.DRUMS + + +def test_categorize_role_other() -> None: + """Test unknown role type is categorized as other.""" + assert _categorize_role("synth-pad", "Synth Pad", "instrument") == StemCategory.OTHER + + +def test_stem_separator_empty() -> None: + """Test separator with empty roles list.""" + separator = StemSeparator() + result = separator.separate([]) + assert result["stems"] == [] + assert "0 roles" in result["separation_notes"] + + +def test_stem_separator_basic() -> None: + """Test separator with typical roles.""" + separator = StemSeparator() + roles = [ + {"id": "bass-guitar", "name": "Bass Guitar", "roleType": "instrument"}, + {"id": "lead-vocal", "name": "Lead Vocal", "roleType": "vocal"}, + {"id": "keys-right", "name": "Keyboard Right Hand", "roleType": "hand"}, + ] + result = separator.separate(roles) + assert len(result["stems"]) == 3 + stems_by_id = {s["stem_id"]: s for s in result["stems"]} + assert stems_by_id["stem-bass-guitar"]["category"] == "bass" + assert stems_by_id["stem-lead-vocal"]["category"] == "vocals" + assert stems_by_id["stem-keys-right"]["category"] == "keys" + + +def test_stem_separator_deduplicates() -> None: + """Test separator deduplicates roles by id.""" + separator = StemSeparator() + roles = [ + {"id": "bass-guitar", "name": "Bass Guitar", "roleType": "instrument"}, + {"id": "bass-guitar", "name": "Bass Guitar", "roleType": "instrument"}, + ] + result = separator.separate(roles) + assert len(result["stems"]) == 1 + + +def test_stem_separator_invalid_role() -> None: + """Test separator handles non-dict roles gracefully.""" + separator = StemSeparator() + result = separator.separate( + [{"id": "bass", "name": "Bass", "roleType": "instrument"}, "invalid"] + ) + assert len(result["stems"]) == 1 + + +def test_stem_separator_confidence() -> None: + """Test confidence levels based on role types.""" + separator = StemSeparator() + roles = [ + {"id": "bass-guitar", "name": "Bass Guitar", "roleType": "instrument"}, + {"id": "keys-left", "name": "Keys Left", "roleType": "hand"}, + ] + result = separator.separate(roles) + # instrument gets high, hand gets medium + assert result["stems"][0]["confidence"] == "high" + assert result["stems"][1]["confidence"] == "medium" + + +def test_stem_separator_missing_role_fields() -> None: + """Test separator handles roles with missing fields.""" + separator = StemSeparator() + roles = [{"id": "unknown-1"}] + result = separator.separate(roles) + assert len(result["stems"]) == 1 + assert result["stems"][0]["category"] == "other" + # When name is missing, label falls back to role id + assert result["stems"][0]["label"] == "unknown-1" + + +def test_stem_separator_keyboard_name_match() -> None: + """Test separator categorizes keyboard by name even without keys in id.""" + separator = StemSeparator() + roles = [{"id": "synth-1", "name": "Keyboard Part", "roleType": "instrument"}] + result = separator.separate(roles) + assert result["stems"][0]["category"] == "keys"