diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..12e625842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- Name tonight's first breakdown plan with the owning part when an active role is corroborated, the owned `breakdownPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, confirmed overrides, harmonic explanations, or confidence notes. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..1e8dc84da 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - tonight's first breakdown plan on the mounted map when section-level stem activity shows a corroborated density drop (previous graph ≥3 distinct sources, current graph 1–2 staying sources, no new entrance), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..62fbbd53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first breakdown plan in the mounted rehearsal workspace so the part that stays in after a density drop can hold the sparse texture on the map; real analyzed songs now receive this guidance only when section-level stem activity shows the previous graph with at least three distinct sources and the current graph holding one or two of those same sources, while heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..16fe6571b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first breakdown plan and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..b0fcf5c4a 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -191,6 +191,10 @@ pub struct RehearsalRolePayload { setup_note: String, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + breakdown_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + breakdown_plan_source: Option, } #[derive(Clone, Debug, Serialize)] diff --git a/apps/desktop/core/tests/breakdown_plan_contract.rs b/apps/desktop/core/tests/breakdown_plan_contract.rs new file mode 100644 index 000000000..5136dadd1 --- /dev/null +++ b/apps/desktop/core/tests/breakdown_plan_contract.rs @@ -0,0 +1,86 @@ +use bandscope_desktop_core::project_payload_from_content; +use serde_json::{json, Value}; + +fn song_with_breakdown_plan() -> Value { + json!({ + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Sparse half-time chorus", + "timeRange": { "start": 30, "end": 46 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Stem activity corroborates the breakdown." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Hold into the sparse chorus." + }, + "range": { + "lowestNote": "C#2", + "highestNote": "E3" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Bass remains active after the density drop." + }, + "rehearsalPriority": "high", + "simplification": "Stay on roots.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "breakdownPlan": "Hold this breakdown with Lead Vocal; keep it sparse until the drop.", + "breakdownPlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Lock the sparse chorus texture.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_breakdown_plan_provenance() { + let payload = song_with_breakdown_plan(); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project contract must accept shared breakdown-plan fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["breakdownPlan"], + payload["sections"][0]["roles"][0]["breakdownPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["breakdownPlanSource"], + json!("model") + ); +} diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..60c9b9fac --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.custom-guidance.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +const mountedTargets = new Set(); + +function songWithCustomBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-custom-breakdown"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Mute for four bars."; + bass.breakdownPlanSource = "user"; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + mountedTargets.add(timeline); +} + +describe("FirstBreakdownPlanCallout custom guidance", () => { + afterEach(() => { + for (const target of mountedTargets) { + target.remove(); + } + mountedTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("does not replace user-owned guidance with model-specific sparse instructions after Open", () => { + appendSongStructureTarget(); + render(); + + expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy(); + expect(screen.getByText("Mute for four bars.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + + expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy(); + expect(screen.getByText("Mute for four bars.")).toBeTruthy(); + expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.identity.test.tsx new file mode 100644 index 000000000..885b7ef7d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +it("gives co-mounted breakdown-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first breakdown plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.loaded-instance.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.loaded-instance.test.tsx new file mode 100644 index 000000000..de87346ee --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.loaded-instance.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +const mountedTargets = new Set(); + +function analyzedSongWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-loaded-instance"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + bass.breakdownPlanSource = "model"; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + mountedTargets.add(timeline); +} + +describe("FirstBreakdownPlanCallout loaded song identity", () => { + afterEach(() => { + for (const target of mountedTargets) { + target.remove(); + } + mountedTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("resets opened state when a distinct workspace instance reuses the engine id", () => { + const firstSong = analyzedSongWithBreakdownPlan(); + const nextSong = analyzedSongWithBreakdownPlan(); + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeNull(); + }); + + it("preserves opened state across immutable edits inside one workspace instance", () => { + const firstSong = analyzedSongWithBreakdownPlan(); + const updatedSong = structuredClone(firstSong); + updatedSong.sections[1]!.roles[0]!.practiceProgress = 25; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..212963ea7 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.memoization.test.tsx @@ -0,0 +1,42 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout resolver reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not rescan role metadata when a parent rerenders the same song object", () => { + const song = songWithBreakdownPlan(); + const role = song.sections[1]!.roles.find((candidate) => candidate.id === "bass-guitar")!; + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor"); + + const { rerender } = render(); + const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + expect(firstScanCount).toBeGreaterThan(0); + + rerender(); + const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + + expect(secondScanCount).toBe(firstScanCount); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..1a6d4a836 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.navigation-failure.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout navigation failure", () => { + it("tells the user when the named breakdown cannot be opened on the rendered map", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + + expect( + screen.getByText( + "Could not open this breakdown on the song map. Use the map below to find the section." + ) + ).toBeTruthy(); + expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop./)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.particle.test.tsx new file mode 100644 index 000000000..b23f73b60 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.particle.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithKoreanBreakdown( + breakdownPlan: string, + breakdownPlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + breakdownPlan, + ...(breakdownPlanSource ? { breakdownPlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the breakdown action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanBreakdown( + "Hold this breakdown; keep it sparse until the drop.", + "model" + ); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:30 코러스에서 피아노 파트가 브레이크다운을 유지합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 브레이크다운 열기" })); + + expect( + screen.getByText("0:30에서 피아노 파트로 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.provenance.test.tsx new file mode 100644 index 000000000..01abaf747 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.provenance.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithKoreanBreakdown( + breakdownPlan: string, + breakdownPlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + breakdownPlan, + ...(breakdownPlanSource ? { breakdownPlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout breakdown-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user breakdown guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Hold this breakdown; keep it sparse until the drop."; + const song = songWithKoreanBreakdown(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.")).toBeNull(); + }); + + it("does not infer model authority when persisted breakdown guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Hold this breakdown; keep it sparse until the drop."; + const song = songWithKoreanBreakdown(legacyPlan); + + render(); + + expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect(screen.queryByText("이 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.")).toBeNull(); + }); + + it("localizes model guidance from structured holding topology instead of display sentence wording", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanBreakdown( + "Hold this breakdown with Keyboard 1 Right Hand; keep it sparse until the drop.", + "model" + ); + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand 파트와 이 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.") + ).toBeTruthy(); + expect( + screen.queryByText("Hold this breakdown with Keyboard 1 Right Hand; keep it sparse until the drop.") + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..9392b453b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.reduced-motion.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.test.tsx new file mode 100644 index 000000000..cfc057315 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +const DEMO_BREAKDOWN_PLAN = "Hold this breakdown; keep it sparse until the drop."; +const appendedSongStructureTargets = new Set(); + +function songWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = DEMO_BREAKDOWN_PLAN; + bass.breakdownPlanSource = "model"; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstBreakdownPlanCallout", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "No breakdown plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithBreakdownPlan(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithBreakdownPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText( + "No breakdown plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same breakdown signature", () => { + const firstSong = songWithBreakdownPlan(); + const nextSong = songWithBreakdownPlan(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop./)).toBeNull(); + }); + + it("opens the named breakdown on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop./)).toBeTruthy(); + expect(screen.getByText(DEMO_BREAKDOWN_PLAN)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.tsx new file mode 100644 index 000000000..2d7ca4cf0 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.tsx @@ -0,0 +1,221 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { + formatBreakdownPlanTime, + resolveFirstBreakdownPlan, + type BreakdownPlanGuidance +} from "./firstBreakdownPlan"; + +/** Props for the first breakdown-plan rehearsal callout. */ +export interface FirstBreakdownPlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type BreakdownPlanCopyValues = Readonly>; +type BreakdownPlanSource = "model" | "user"; + +type OpenedBreakdownPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + breakdownPlan: string; + breakdownPlanSource: BreakdownPlanSource | null; + breakdownPlanGuidanceKind: BreakdownPlanGuidance["kind"] | null; + breakdownPlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableBreakdownPlanSongIdentity( + song: RehearsalSong, + workspaceInstanceKey: unknown +): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate breakdown-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatBreakdownPlanCopy(template: string, values: BreakdownPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof BreakdownPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model breakdown guidance from structured holding topology, never from display-copy grammar. */ +function localizedBreakdownPlan( + breakdownPlan: string, + breakdownPlanSource: BreakdownPlanSource | null, + guidance: BreakdownPlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (breakdownPlanSource !== "model" || guidance === null) { + return breakdownPlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredBreakdownPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveBreakdownPlanRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first breakdown plan and open the matching rendered map section. */ +export function FirstBreakdownPlanCallout({ + song, + workspaceInstanceKey +}: FirstBreakdownPlanCalloutProps) { + const calloutId = `workspace-surface-breakdown-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableBreakdownPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstBreakdownPlan(song), [song]); + const [openedBreakdownPlan, setOpenedBreakdownPlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.breakdownPlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.breakdownPlanGuidance?.kind === "role" + ? named.breakdownPlanGuidance.targetRoleName + : null; + + useEffect(() => { + setOpenedBreakdownPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.holdingRoleId, + named?.breakdownPlan, + named?.breakdownPlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedBreakdownPlan !== null && + openedBreakdownPlan.songIdentity === songIdentity && + openedBreakdownPlan.sectionId === named.sectionId && + openedBreakdownPlan.sectionIndex === named.sectionIndex && + openedBreakdownPlan.holdingRoleId === named.holdingRoleId && + openedBreakdownPlan.breakdownPlan === named.breakdownPlan && + openedBreakdownPlan.breakdownPlanSource === named.breakdownPlanSource && + openedBreakdownPlan.breakdownPlanGuidanceKind === guidanceKind && + openedBreakdownPlan.breakdownPlanTargetRoleName === guidanceTargetRoleName && + openedBreakdownPlan.atSeconds === named.atSeconds; + const at = formatBreakdownPlanTime(named.atSeconds); + const copyValues: BreakdownPlanCopyValues = { + role: named.holdingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatBreakdownPlanCopy(t("firstBreakdownPlanOpenAction"), copyValues); + const body = formatBreakdownPlanCopy(t("firstBreakdownPlanBody"), copyValues); + const armed = formatBreakdownPlanCopy(t("firstBreakdownPlanArmed"), copyValues); + const breakdownPlan = localizedBreakdownPlan( + named.breakdownPlan, + named.breakdownPlanSource, + named.breakdownPlanGuidance, + t("firstBreakdownPlanGeneratedGuidance"), + t("firstBreakdownPlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..baf7d12c6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +describe("FirstBreakdownPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English breakdown plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No breakdown plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean breakdown plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 브레이크다운 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..39dc3892f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBreakdownPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; + +function songWithBreakdownPlan(id: string) { + const song = createDemoRehearsalSong(); + song.id = id; + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = `${id}-chorus`; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +describe("FirstBreakdownPlanCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = songWithBreakdownPlan("first-workspace-song"); + const secondSong = songWithBreakdownPlan("second-workspace-song"); + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="1"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Bass Guitar breakdown at 0:30" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.breakdown-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.breakdown-state.test.tsx new file mode 100644 index 000000000..326c3fa05 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.breakdown-state.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView" +); + +function analyzedSongWithBreakdownPlan(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + verse.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + const chorus = structuredClone(verse); + chorus.id = "chorus-breakdown-state"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + bass.breakdownPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace breakdown state authority", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + }); + + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + }); + + it("keeps an opened breakdown armed after an immutable practice-progress update", () => { + const song = analyzedSongWithBreakdownPlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" })); + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..4dd12df83 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, useRef, memo, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -122,6 +127,18 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const localSongUpdateRef = useRef(null); + const workspaceInstanceRef = useRef(song); + const previousSongRef = useRef(song); + + if (song !== previousSongRef.current) { + const isLocalWorkspaceUpdate = song === localSongUpdateRef.current; + if (!isLocalWorkspaceUpdate) { + workspaceInstanceRef.current = song; + } + localSongUpdateRef.current = null; + previousSongRef.current = song; + } // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -164,6 +181,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ) : t("workspaceFirstRangeMissing"); + /** Preserve workspace-instance authority for immutable edits emitted by this workspace. */ + const commitSongUpdate = (nextSong: RehearsalSong) => { + if (!onSongUpdate) return; + localSongUpdateRef.current = nextSong; + onSongUpdate(nextSong); + }; + /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { if (!activeRole || !onSongUpdate) return; @@ -188,7 +212,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) }; - onSongUpdate(nextSong); + commitSongUpdate(nextSong); }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), @@ -310,6 +334,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ +

{t("workspaceSongTimelineLabel")}

@@ -505,11 +531,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.continuation.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.continuation.test.ts new file mode 100644 index 000000000..3aa44b67c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.continuation.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +const DEMO_BREAKDOWN_PLAN = "Hold this breakdown; keep it sparse until the drop."; + +describe("resolveFirstBreakdownPlan landing authority", () => { + it("does not name a breakdown when the previous graph is not dense enough", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const holdingRole = verse.roles.find((role) => role.id === "bass-guitar")!; + holdingRole.breakdownPlan = DEMO_BREAKDOWN_PLAN; + verse.roles = [holdingRole]; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === holdingRole.id + })); + + const intro = structuredClone(verse); + intro.id = "intro-thin"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: verse.timeRange.start }; + intro.roles = intro.roles.map((role) => { + const clone = { ...role }; + delete clone.breakdownPlan; + delete clone.breakdownPlanSource; + return clone; + }); + intro.partGraph = intro.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + song.sections = [intro, verse]; + + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("does not name a breakdown when the rest and landing windows leave a gap", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.roles[0]!.breakdownPlan = DEMO_BREAKDOWN_PLAN; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + const intro = structuredClone(verse); + intro.id = "intro-gap"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: verse.timeRange.start - 1 }; + intro.roles = intro.roles.map((role) => { + const clone = { ...role }; + delete clone.breakdownPlan; + delete clone.breakdownPlanSource; + return clone; + }); + intro.partGraph = intro.partGraph.map((node) => ({ ...node, is_active: true })); + song.sections = [intro, verse]; + + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.demo.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.demo.test.ts new file mode 100644 index 000000000..3b697ad83 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.demo.test.ts @@ -0,0 +1,40 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +const DEMO_BREAKDOWN_PLAN = "Hold this breakdown; keep it sparse until the drop."; + +describe("demo breakdown plan", () => { + it("does not name a demo breakdown without a predecessor dense graph", () => { + expect(resolveFirstBreakdownPlan(createDemoRehearsalSong())).toBeNull(); + }); + + it("keeps a seeded demo breakdown actionable across a real predecessor dense graph", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = chorus.roles.map((role) => { + const clone = { ...role }; + delete clone.breakdownPlan; + delete clone.breakdownPlanSource; + return clone; + }); + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = DEMO_BREAKDOWN_PLAN; + bass.breakdownPlanSource = "model"; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + + expect(resolveFirstBreakdownPlan(song)).toMatchObject({ + sectionId: "chorus-1", + holdingRoleId: "bass-guitar", + atSeconds: 30 + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..d052ca4e0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.inherited-metadata.test.ts @@ -0,0 +1,42 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +function songWithBreakdownPlan() { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(verse); + chorus.id = "chorus-own"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return { song, chorus, verse }; +} + +describe("resolveFirstBreakdownPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, chorus } = songWithBreakdownPlan(); + const inheritedSong = Object.create(song) as typeof song; + expect(resolveFirstBreakdownPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(chorus) as typeof chorus; + song.sections[1] = inheritedSection; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("rejects a role whose identity is inherited instead of owned", () => { + const { song, chorus } = songWithBreakdownPlan(); + const role = chorus.roles.find((candidate) => candidate.id === "bass-guitar")!; + const inheritedRole = Object.create(role) as typeof role; + chorus.roles = chorus.roles.map((candidate) => + candidate.id === role.id ? inheritedRole : candidate + ); + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.proxy-authority.test.ts new file mode 100644 index 000000000..0bf0ed72e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.proxy-authority.test.ts @@ -0,0 +1,95 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +const DEMO_BREAKDOWN_PLAN = "Hold this breakdown; keep it sparse until the drop."; + +function songWithPredecessorBreakdown() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = DEMO_BREAKDOWN_PLAN; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + return song; +} + +describe("resolveFirstBreakdownPlan own-data authority", () => { + it("uses the snapshotted own-data breakdown plan instead of a Proxy get trap", () => { + const song = songWithPredecessorBreakdown(); + const section = song.sections.find((candidate) => candidate.id === "chorus-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo breakdown-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "breakdownPlan") { + return "Injected proxy breakdown."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstBreakdownPlan(song)?.breakdownPlan).toBe(DEMO_BREAKDOWN_PLAN); + }); + + it("uses the snapshotted own-data time range instead of a Proxy get trap", () => { + const song = songWithPredecessorBreakdown(); + const section = song.sections.find((candidate) => candidate.id === "chorus-1"); + expect(section).toBeDefined(); + if (!section) { + throw new Error("Demo breakdown-plan fixture is missing the expected chorus section."); + } + const expectedStart = section.timeRange.start; + section.timeRange = new Proxy(section.timeRange, { + get(target, property, receiver) { + if (property === "start") { + return expectedStart + 15; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstBreakdownPlan(song)?.atSeconds).toBe(expectedStart); + }); + + it("returns snapshotted role identity and display copy instead of Proxy get values", () => { + const song = songWithPredecessorBreakdown(); + const section = song.sections.find((candidate) => candidate.id === "chorus-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo breakdown-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "id") { + return "proxy-id"; + } + if (property === "name") { + return "Proxy Name"; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstBreakdownPlan(song); + expect(resolved?.holdingRoleId).toBe("bass-guitar"); + expect(resolved?.holdingRoleName).toBe("Bass Guitar"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.section-label.test.ts new file mode 100644 index 000000000..d2407e473 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.section-label.test.ts @@ -0,0 +1,24 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +describe("resolveFirstBreakdownPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const bass = chorus.roles.find((role) => role.id === "bass-guitar")!; + bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" + })); + song.sections = [verse, chorus]; + (chorus as unknown as { label: string }).label = "chorus-legacy"; + + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.snapshot.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.snapshot.test.ts new file mode 100644 index 000000000..f6bbd6853 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.snapshot.test.ts @@ -0,0 +1,47 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +describe("resolveFirstBreakdownPlan descriptor snapshots", () => { + it("uses the breakdown-plan snapshot that admitted the role", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + const role = chorus.roles.find((candidate) => candidate.id === "bass-guitar")!; + const roleId = role.id; + let breakdownPlanDescriptorReads = 0; + const proxiedRole = new Proxy(role, { + getOwnPropertyDescriptor(target, key) { + if (key === "breakdownPlan") { + breakdownPlanDescriptorReads += 1; + return { + configurable: true, + enumerable: true, + writable: true, + value: + breakdownPlanDescriptorReads === 1 + ? "Hold this breakdown; keep it sparse until the drop." + : "Changed after validation." + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + } + }); + chorus.roles = chorus.roles.map((candidate) => + candidate.id === roleId ? proxiedRole : candidate + ); + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId + })); + song.sections = [verse, chorus]; + + expect(resolveFirstBreakdownPlan(song)?.breakdownPlan).toBe( + "Hold this breakdown; keep it sparse until the drop." + ); + expect(breakdownPlanDescriptorReads).toBe(1); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.test.ts new file mode 100644 index 000000000..a10c24a5c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatBreakdownPlanTime, resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +const DEMO_BREAKDOWN_PLAN = "Hold this breakdown; keep it sparse until the drop."; + +function withBreakdownSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + breakdownPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + wasActive?: boolean; + previousActiveCount?: 3 | 2; + keepCompanion?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 10; + const previousStart = overrides.previousStart ?? 0; + const roleId = overrides.roleId ?? "bass-guitar"; + const keys = structuredClone(verse.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(verse.roles.find((role) => role.id === "lead-vocal")!); + const bass = structuredClone(verse.roles.find((role) => role.id === "bass-guitar")!); + delete keys.breakdownPlan; + delete keys.breakdownPlanSource; + delete vocal.breakdownPlan; + delete vocal.breakdownPlanSource; + + const holding = { + ...(roleId === "keys-right" ? keys : roleId === "lead-vocal" ? vocal : bass), + id: roleId, + name: overrides.roleName ?? (roleId === "keys-right" ? "Keyboard 1 Right Hand" : roleId === "lead-vocal" ? "Lead Vocal" : "Bass Guitar"), + rehearsalPriority: overrides.priority ?? "high", + breakdownPlan: overrides.breakdownPlan ?? DEMO_BREAKDOWN_PLAN + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "verse-breakdown"; + current.label = overrides.label ?? "verse"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 20 }; + current.roles = overrides.keepCompanion ? [holding, keys] : [holding]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: roleId === "bass-guitar" ? (overrides.isActive ?? true) : Boolean(overrides.keepCompanion && roleId !== "bass-guitar"), handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: roleId === "keys-right" ? (overrides.isActive ?? true) : Boolean(overrides.keepCompanion), handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: roleId === "lead-vocal" ? (overrides.isActive ?? true) : false, handoff_to: [], handoff_from: [] } + ]; + if (overrides.keepCompanion && roleId === "bass-guitar") { + current.partGraph[1]!.is_active = true; + } + if (overrides.keepCompanion && roleId === "keys-right") { + current.partGraph[0]!.is_active = true; + current.roles = [holding, bass]; + } + + const previous = structuredClone(current); + previous.id = `${current.id}-full`; + previous.label = "intro"; + previous.timeRange = { start: previousStart, end: landingStart }; + previous.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + previous.roles.forEach((role) => { + delete role.breakdownPlan; + delete role.breakdownPlanSource; + }); + previous.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { + role_id: "lead-vocal", + is_active: overrides.previousActiveCount === 2 ? false : true, + handoff_to: [], + handoff_from: [] + } + ]; + if (overrides.wasActive === false) { + previous.partGraph = previous.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId ? false : node.is_active + })); + } + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstBreakdownPlan", () => { + it("picks the earliest breakdown plan and the part that holds the sparse texture", () => { + const resolved = resolveFirstBreakdownPlan(withBreakdownSection()); + expect(resolved?.section.id).toBe("verse-breakdown"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.breakdownPlan).toBe(DEMO_BREAKDOWN_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatBreakdownPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatBreakdownPlanTime(Number.NaN)).toBe("0:00"); + expect(formatBreakdownPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a breakdown plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withBreakdownSection(); + delete song.sections[1]!.roles[0]!.breakdownPlan; + song.sections[1]!.groove = "Straight eighths with a late snare feel"; + song.sections[1]!.roles[0]!.simplification = "Stay on roots if the chorus entrance gets muddy."; + song.sections[1]!.roles[0]!.setupNote = DEMO_BREAKDOWN_PLAN; + song.sections[1]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[1]!.roles[0] as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (song.sections[1]!.roles[0] as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (song.sections[1]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[1]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[1]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the pickup."; + (song.sections[1]!.roles[0] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (song.sections[1]!.roles[0] as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (song.sections[1]!.roles[0] as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (song.sections[1]!.roles[0] as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + (song.sections[1]!.roles[0] as { cutoffPlan?: string }).cutoffPlan = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + (song.sections[1]!.roles[0] as { turnaroundPlan?: string }).turnaroundPlan = + "Turn these last bars with Lead Vocal; land the downbeat together."; + (song.sections[1]!.roles[0] as { pickupPlan?: string }).pickupPlan = + "Play this pickup with Lead Vocal; land the downbeat together."; + song.sections[1]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[1]!.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + song.sections[1]!.roles[0]!.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + song.sections[1]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "user" + }; + song.sections[1]!.roles[0]!.harmonicExplanation = "The bass holds the vi center."; + song.sections[1]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[1]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: DEMO_BREAKDOWN_PLAN + }; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("skips a blank breakdown plan", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ breakdownPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line breakdown plan", () => { + expect( + resolveFirstBreakdownPlan( + withBreakdownSection({ breakdownPlan: "Keep the texture sparse.\nLeave the stack." }) + ) + ).toBeNull(); + }); + + it("prefers the earlier of two breakdown plans", () => { + const song = withBreakdownSection({ + id: "verse-late-breakdown", + start: 40, + end: 56, + previousStart: 24, + roleId: "keys-right", + breakdownPlan: "Late breakdown." + }); + const earlier = structuredClone(song.sections[1]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "low", + breakdownPlan: "Earlier breakdown." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + const earlierFull = structuredClone(song.sections[0]!); + earlierFull.id = "intro-before-early"; + earlierFull.timeRange = { start: 0, end: 8 }; + song.sections = [earlierFull, earlier, song.sections[0]!, song.sections[1]!]; + + const resolved = resolveFirstBreakdownPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.breakdownPlan).toBe("Earlier breakdown."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time breakdown-plan ties with locale-independent id ordering", () => { + const song = withBreakdownSection({ id: "ä-breakdown", start: 10, end: 26 }); + const umlautFull = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiFull = structuredClone(umlautFull); + asciiFull.id = "z-breakdown-full"; + const ascii = structuredClone(umlaut); + ascii.id = "z-breakdown"; + song.sections = [umlautFull, umlaut, asciiFull, ascii]; + + expect(resolveFirstBreakdownPlan(song)?.section.id).toBe("z-breakdown"); + }); + + it("prefers a high-priority holding part over a low-priority part in the same section", () => { + const song = withBreakdownSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + breakdownPlan: "Low-priority breakdown.", + keepCompanion: true + }); + const section = song.sections[1]!; + const highRole = { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high" as const, + breakdownPlan: "High-priority breakdown." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstBreakdownPlan(song)?.holdingRole.id).toBe("bass-guitar"); + expect(resolveFirstBreakdownPlan(song)?.breakdownPlan).toBe("High-priority breakdown."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withBreakdownSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high", keepCompanion: true }); + const section = song.sections[1]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + breakdownPlan: "ASCII breakdown." + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + const previous = song.sections[0]!; + previous.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstBreakdownPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstBreakdownPlan(song)?.breakdownPlan).toBe("ASCII breakdown."); + }); + + it("skips a breakdown plan whose graph node is inactive", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ isActive: false }))).toBeNull(); + }); + + it("skips a breakdown plan whose previous graph node was already inactive", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ wasActive: false }))).toBeNull(); + }); + + it("skips a breakdown whose previous graph had fewer than three sources", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ previousActiveCount: 2 }))).toBeNull(); + }); + + it("skips a breakdown plan whose rest and landing windows do not abut", () => { + const song = withBreakdownSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("skips a breakdown plan whose rehearsal window is unbounded", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a breakdown plan whose end precedes its start", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length breakdown-plan window", () => { + expect(resolveFirstBreakdownPlan(withBreakdownSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a breakdown plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstBreakdownPlan( + withBreakdownSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstBreakdownPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a holding part", () => { + const song = withBreakdownSection(); + song.sections[1]!.roles = [null as never, song.sections[1]!.roles[0]!]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstBreakdownPlan(song)?.holdingRole.id).toBe("bass-guitar"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withBreakdownSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("keeps the breakdown plan unnamed when role identities are duplicated", () => { + const song = withBreakdownSection(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("does not name a full stop as a breakdown", () => { + const song = withBreakdownSection(); + song.sections[1]!.partGraph = song.sections[1]!.partGraph.map((node) => ({ + ...node, + is_active: false + })); + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("does not name an unchanged dense texture as a breakdown", () => { + const song = withBreakdownSection(); + song.sections[1]!.partGraph = song.sections[0]!.partGraph.map((node) => ({ ...node })); + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); + + it("does not name a new entrance as a breakdown", () => { + const song = withBreakdownSection({ wasActive: false }); + song.sections[1]!.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstBreakdownPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.ts new file mode 100644 index 000000000..92b748ad1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.ts @@ -0,0 +1,451 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_BREAKDOWN_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const ACCOMPANIMENT_SOURCE_ROLE_IDS = new Set([ + "keys-left", + "keys-right", + "acoustic-guitar" +]); +const ACCOMPANIMENT_SOURCE_ID = "other"; +const BREAKDOWN_PLAN_SOLO = "Hold this breakdown; keep it sparse until the drop."; +const BREAKDOWN_PLAN_PREFIX = "Hold this breakdown with "; +const BREAKDOWN_PLAN_SUFFIX = "; keep it sparse until the drop."; + +type BreakdownPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated breakdown-plan copy. */ +export type BreakdownPlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedBreakdownPlan = Readonly<{ + text: string; + source: BreakdownPlanSource | null; + guidance: BreakdownPlanGuidance | null; +}>; + +/** Tonight's first breakdown plan: the earliest labeled density drop a staying part holds. */ +export type FirstBreakdownPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; + breakdownPlan: string; + breakdownPlanSource: BreakdownPlanSource | null; + breakdownPlanGuidance: BreakdownPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative breakdown-plan time as m:ss for rehearsal copy. */ +export function formatBreakdownPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Preserve the engine breakdown template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedBreakdownPlan(value: string): OwnedBreakdownPlan | null { + if (value === BREAKDOWN_PLAN_SOLO) { + return { text: BREAKDOWN_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(BREAKDOWN_PLAN_PREFIX) || !value.endsWith(BREAKDOWN_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(BREAKDOWN_PLAN_PREFIX.length, -BREAKDOWN_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(BREAKDOWN_PLAN_PREFIX + BREAKDOWN_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_BREAKDOWN_PLAN_CHARACTERS - fixedLength); + return { + text: `${BREAKDOWN_PLAN_PREFIX}${boundedTarget}${BREAKDOWN_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own breakdown plan and its explicit provenance, or null when malformed. */ +function ownedBreakdownPlan(role: unknown): OwnedBreakdownPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const breakdownPlan = ownDataValue(role, "breakdownPlan"); + const breakdownPlanSource = ownDataValue(role, "breakdownPlanSource"); + if (typeof breakdownPlan !== "string") { + return null; + } + if ( + breakdownPlanSource !== undefined && + breakdownPlanSource !== "model" && + breakdownPlanSource !== "user" + ) { + return null; + } + const trimmed = breakdownPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (breakdownPlanSource === "model") { + const generatedPlan = boundedGeneratedBreakdownPlan(trimmed); + if (generatedPlan !== null) { + return generatedPlan; + } + } + return { + text: truncateCodePoints(trimmed, MAX_BREAKDOWN_PLAN_CHARACTERS), + source: breakdownPlanSource ?? null, + guidance: null + }; +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Map canonical accompaniment roles back to their shared source-separation stem. */ +function breakdownSourceId(roleId: string): string { + return ACCOMPANIMENT_SOURCE_ROLE_IDS.has(roleId) ? ACCOMPANIMENT_SOURCE_ID : roleId; +} + +/** Prefer rehearsal priority, then a locale-independent stable id. */ +function pickHoldingRole(roles: Role[]): Role | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return unique graph role ids whose node is explicitly active or inactive. */ +function rankedGraphRoleIds( + section: RehearsalSection, + isActive: boolean +): Set { + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!partGraph) { + return new Set(); + } + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + return new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== isActive) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + if (!roles) { + return []; + } + const activeIds = rankedGraphRoleIds(section, true); + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Count distinct source-separation stems that are explicitly active. */ +function activeSourceCount(activeIds: Set): number { + return new Set([...activeIds].map((roleId) => breakdownSourceId(roleId))).size; +} + +/** Resolve a breakdown plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstBreakdownPlan(song: RehearsalSong): FirstBreakdownPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section) || sectionIndex === 0) { + return []; + } + const previousSection = sections[sectionIndex - 1]; + if (!isRuntimeObject(previousSection)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + const previousTimeRange = ownedBoundedTimeRange(previousSection as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null || + previousTimeRange === null || + previousTimeRange.end !== timeRange.start + ) { + return []; + } + + const previousActiveIds = rankedGraphRoleIds(previousSection as RehearsalSection, true); + const currentActiveIds = rankedGraphRoleIds(section as RehearsalSection, true); + const previousSourceCount = activeSourceCount(previousActiveIds); + const currentSourceCount = activeSourceCount(currentActiveIds); + if (previousSourceCount < 3 || currentSourceCount < 1 || currentSourceCount > 2) { + return []; + } + for (const roleId of currentActiveIds) { + if (!previousActiveIds.has(roleId)) { + return []; + } + } + let dropped = false; + for (const roleId of previousActiveIds) { + if (!currentActiveIds.has(roleId)) { + dropped = true; + break; + } + } + if (!dropped) { + return []; + } + + const holdingRole = pickHoldingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if (!previousActiveIds.has(metadata.id)) { + return []; + } + const breakdownPlan = ownedBreakdownPlan(metadata.role); + return breakdownPlan === null + ? [] + : [ + { + ...metadata, + breakdownPlan: breakdownPlan.text, + breakdownPlanSource: breakdownPlan.source, + breakdownPlanGuidance: breakdownPlan.guidance + } + ]; + }) + ); + if (!holdingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, + breakdownPlan: holdingRole.breakdownPlan, + breakdownPlanSource: holdingRole.breakdownPlanSource, + breakdownPlanGuidance: holdingRole.breakdownPlanGuidance, + atSeconds: timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named breakdown plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstBreakdownPlan(song: RehearsalSong): FirstBreakdownPlan | null { + try { + return resolveSafeFirstBreakdownPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/features/workspace/firstBreakdownPlan.user-provenance.test.ts b/apps/desktop/src/features/workspace/firstBreakdownPlan.user-provenance.test.ts new file mode 100644 index 000000000..05d4ed6b7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBreakdownPlan.user-provenance.test.ts @@ -0,0 +1,56 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBreakdownPlan } from "./firstBreakdownPlan"; + +const GENERATED_PREFIX = "Hold this breakdown with "; +const GENERATED_SUFFIX = "; keep it sparse until the drop."; + +function userPlanSong(breakdownPlan: string) { + const song = createDemoRehearsalSong(); + const current = structuredClone(song.sections[0]!); + current.id = "user-breakdown"; + current.timeRange = { start: 10, end: 30 }; + current.roles = [ + { + ...current.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + breakdownPlan, + breakdownPlanSource: "user" + } + ]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + + const previous = structuredClone(current); + previous.id = "user-breakdown-full"; + previous.label = "intro"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = previous.roles.map((role) => { + const clone = { ...role }; + delete clone.breakdownPlan; + delete clone.breakdownPlanSource; + return clone; + }); + previous.partGraph = previous.partGraph.map((node) => ({ ...node, is_active: true })); + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstBreakdownPlan user provenance", () => { + it("bounds user-authored generated-shape copy as user text instead of rewriting the target", () => { + const breakdownPlan = `${GENERATED_PREFIX}${"A".repeat(170)}${GENERATED_SUFFIX}`; + const expected = Array.from(breakdownPlan).slice(0, 180).join(""); + + const resolved = resolveFirstBreakdownPlan(userPlanSong(breakdownPlan)); + + expect(resolved?.breakdownPlanSource).toBe("user"); + expect(resolved?.breakdownPlan).toBe(expected); + expect(resolved?.breakdownPlan?.endsWith(GENERATED_SUFFIX)).toBe(false); + }); +}); diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..2560c229c 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,54 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-breakdown-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstBreakdownPlanOpenAction")).toBe("{at} {role} 브레이크다운 열기"); + expect(t("firstBreakdownPlanBody")).toBe("{at} {section}에서 {role} 파트가 브레이크다운을 유지합니다."); + expect(t("firstBreakdownPlanArmed")).toBe( + "{at}에서 {role} 파트로 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오." + ); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..adcb0ff65 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,13 @@ "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", "sectionRangeLabel": "Range", - "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." + "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.", + "firstBreakdownPlanLabel": "Tonight's first breakdown plan", + "firstBreakdownPlanOpenAction": "Open {role} breakdown at {at}", + "firstBreakdownPlanBody": "{role} holds the {section} breakdown at {at}.", + "firstBreakdownPlanArmed": "Keep {role} sparse at {at} until the drop.", + "firstBreakdownPlanGeneratedGuidance": "Hold this breakdown with {target}; keep it sparse until the drop.", + "firstBreakdownPlanGeneratedSoloGuidance": "Hold this breakdown; keep it sparse until the drop.", + "firstBreakdownPlanUnavailable": "No breakdown plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstBreakdownPlanNavigationFailed": "Could not open this breakdown on the song map. Use the map below to find the section." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..17c73e19d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,13 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstBreakdownPlanLabel": "오늘 첫 브레이크다운 계획", + "firstBreakdownPlanOpenAction": "{at} {role} 브레이크다운 열기", + "firstBreakdownPlanBody": "{at} {section}에서 {role} 파트가 브레이크다운을 유지합니다.", + "firstBreakdownPlanArmed": "{at}에서 {role} 파트로 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.", + "firstBreakdownPlanGeneratedGuidance": "{target} 파트와 이 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.", + "firstBreakdownPlanGeneratedSoloGuidance": "이 브레이크다운을 유지하세요. 드롭 전까지 얇게 가십시오.", + "firstBreakdownPlanUnavailable": "사용 가능한 브레이크다운 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstBreakdownPlanNavigationFailed": "곡 맵에서 이 브레이크다운을 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..3d7ef8c7e 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -143,6 +143,8 @@ export type RehearsalRole = { overlapWarnings: string[]; transcription?: TranscriptionNote[]; practiceProgress?: number; + breakdownPlan?: string; + breakdownPlanSource?: ProvenanceSource; }; /** Documented. */ @@ -1500,7 +1502,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "breakdownPlan", + "breakdownPlanSource" ], path ); @@ -1588,6 +1592,19 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if (value.breakdownPlan !== undefined && typeof value.breakdownPlan !== "string") { + return invalidField(`${path}.breakdownPlan`); + } + if ( + value.breakdownPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.breakdownPlanSource) + ) { + return invalidField(`${path}.breakdownPlanSource`); + } + if (value.breakdownPlanSource !== undefined && value.breakdownPlan === undefined) { + return invalidField(`${path}.breakdownPlanSource`); + } + return null; } diff --git a/packages/shared-types/test/breakdownPlanProvenance.test.ts b/packages/shared-types/test/breakdownPlanProvenance.test.ts new file mode 100644 index 000000000..74b5d1d47 --- /dev/null +++ b/packages/shared-types/test/breakdownPlanProvenance.test.ts @@ -0,0 +1,28 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("breakdownPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s breakdown plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + role.breakdownPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.breakdownPlanSource).toBe(source); + }); + + it("rejects an unknown breakdown plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.breakdownPlan = "Hold this breakdown; keep it sparse until the drop."; + role.breakdownPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/breakdownPlanSource/); + }); + + it("rejects a breakdown plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.breakdownPlan; + role.breakdownPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/breakdownPlanSource/); + }); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..64a432c72 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -1257,6 +1257,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].breakdownPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.breakdownPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..c77c801d4 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -22,6 +22,12 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_OTHER_STEM_SOURCE_LABEL = "Accompaniment" +_BREAKDOWN_PLAN_SOLO = "Hold this breakdown; keep it sparse until the drop." +_BREAKDOWN_PLAN_PREFIX = "Hold this breakdown with " +_BREAKDOWN_PLAN_SUFFIX = "; keep it sparse until the drop." + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -71,8 +77,13 @@ def extract( # Real activity-based topology current_activity = activity_maps[i] next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None + previous_activity = activity_maps[i - 1] if i > 0 else None topology = self._build_activity_topology( - section_id, roles, current_activity, next_activity + section_id, + roles, + current_activity, + next_activity, + previous_activity, ) else: # Fallback to heuristic-based topology @@ -330,12 +341,84 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _source_id(role_id: str) -> str: + """Collapse accompaniment stems onto one rehearsal source.""" + return "other" if role_id in _OTHER_STEM_ROLE_IDS else role_id + + @staticmethod + def _active_role_ids(role_activity: dict[str, bool]) -> set[str]: + """Return role ids whose activity flag is explicitly true.""" + return {role_id for role_id, is_active in role_activity.items() if is_active} + + @classmethod + def _source_count(cls, role_ids: set[str]) -> int: + """Count distinct source-separation stems among the given roles.""" + return len({cls._source_id(role_id) for role_id in role_ids}) + + def _activity_breakdown_plan( + self, + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool] | None, + ) -> str | None: + """Return bounded breakdown guidance only for a corroborated density drop. + + A breakdown plan is emitted only when real stem activity shows this role + remaining active after at least one other distinct source drops out, the + previous section had three or more distinct sources, and the current + section holds one or two. New entrances are not breakdowns. Heuristic + fallback topology and first-section (no previous activity) produce no + plan. A full stop is not a breakdown. The shared ``other`` stem may + corroborate density but never proves which keyboard or guitar part owns + the hold. + """ + if previous_role_activity is None: + return None + previous_active = self._active_role_ids(previous_role_activity) + current_active = self._active_role_ids(role_activity) + if role_id not in current_active or role_id not in previous_active: + return None + if role_id in _OTHER_STEM_ROLE_IDS: + return None + if current_active - previous_active: + return None + if not (previous_active - current_active): + return None + previous_sources = self._source_count(previous_active) + current_sources = self._source_count(current_active) + if previous_sources < 3 or current_sources < 1 or current_sources > 2: + return None + if current_sources == 1: + return _BREAKDOWN_PLAN_SOLO + + own_source = self._source_id(role_id) + partner_sources = sorted( + {self._source_id(candidate_id) for candidate_id in current_active} - {own_source} + ) + if len(partner_sources) != 1: + return None + partner_source = partner_sources[0] + other_name: str | None + if partner_source == "other": + other_name = _OTHER_STEM_SOURCE_LABEL + else: + other_name = next( + (role["name"] for role in roles.values() if role["id"] == partner_source), + None, + ) + if other_name is None: + return None + return f"{_BREAKDOWN_PLAN_PREFIX}{other_name}{_BREAKDOWN_PLAN_SUFFIX}" + def _build_activity_topology( self, section_id: str, roles: dict[str, RehearsalRole], role_activity: dict[str, bool], next_role_activity: dict[str, bool] | None, + previous_role_activity: dict[str, bool] | None = None, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -357,7 +440,18 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + breakdown_plan = self._activity_breakdown_plan( + role_id, + roles, + role_activity, + previous_role_activity, + ) + if breakdown_plan is not None: + role = role.copy() + role["breakdownPlan"] = breakdown_plan + role["breakdownPlanSource"] = "model" + active_roles.append(role) part_graph.append( { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..5389ab2ee 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -83,6 +83,8 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + breakdownPlan: NotRequired[str] + breakdownPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_breakdown_plan.py b/services/analysis-engine/tests/test_breakdown_plan.py new file mode 100644 index 000000000..e6878ad55 --- /dev/null +++ b/services/analysis-engine/tests/test_breakdown_plan.py @@ -0,0 +1,275 @@ +"""Tests for corroborated breakdown-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.roles.extractor import RoleExtractor +from bandscope_analysis.roles.model import RehearsalRole + +_SOLO_PLAN = "Hold this breakdown; keep it sparse until the drop." +_PREFIX = "Hold this breakdown with " +_SUFFIX = "; keep it sparse until the drop." + + +def _activity( + *, + bass: bool, + keys_right: bool, + vocal: bool, + keys_left: bool = False, + guitar: bool = False, +) -> dict[str, bool]: + """Return a complete role-activity map for one section.""" + return { + "bass-guitar": bass, + "keys-left": keys_left, + "keys-right": keys_right, + "lead-vocal": vocal, + "acoustic-guitar": guitar, + } + + +def _roles(extractor: RoleExtractor) -> dict[str, RehearsalRole]: + """Return canonical bass and vocal role fixtures for topology tests.""" + return extractor._build_roles( + "C#m7", + {"lowestNote": "C#2", "highestNote": "E3"}, + "C#m7", + {"lowestNote": "G#3", "highestNote": "C#5"}, + ) + + +def test_activity_breakdown_emits_solo_plan_for_a_three_to_one_drop() -> None: + """A dense verse dropping to one staying source names the sparse hold.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=False) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + bass = next(role for role in topology["active_roles"] if role["id"] == "bass-guitar") + assert bass["breakdownPlan"] == _SOLO_PLAN + assert bass["breakdownPlanSource"] == "model" + assert all( + "breakdownPlan" not in role or role["id"] == "bass-guitar" + for role in topology["active_roles"] + ) + + +def test_activity_breakdown_names_ambiguous_accompaniment_without_assigning_a_part() -> None: + """A shared other stem may corroborate a partner source but not a named part owner.""" + extractor = RoleExtractor() + previous = _activity( + bass=True, + keys_right=True, + vocal=True, + keys_left=True, + guitar=True, + ) + current = _activity( + bass=True, + keys_right=True, + vocal=False, + keys_left=True, + guitar=True, + ) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["bass-guitar"]["breakdownPlan"] == f"{_PREFIX}Accompaniment{_SUFFIX}" + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "breakdownPlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_breakdown_stays_unnamed_when_only_ambiguous_other_source_holds() -> None: + """The shared other stem cannot prove which keyboard or guitar part owns the hold.""" + extractor = RoleExtractor() + previous = _activity( + bass=True, + keys_right=True, + vocal=True, + keys_left=True, + guitar=True, + ) + current = _activity( + bass=False, + keys_right=True, + vocal=False, + keys_left=True, + guitar=True, + ) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + assert topology["active_roles"] + assert all("breakdownPlan" not in role for role in topology["active_roles"]) + + +def test_activity_breakdown_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a breakdown.""" + extractor = RoleExtractor() + current = _activity(bass=True, keys_right=False, vocal=False) + topology = extractor._build_activity_topology( + "verse-1", + _roles(extractor), + current, + None, + None, + ) + assert all("breakdownPlan" not in role for role in topology["active_roles"]) + + +def test_activity_breakdown_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a breakdown plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("breakdownPlan" not in role for role in topology["active_roles"]) + + +def test_activity_breakdown_stays_unnamed_for_a_full_stop() -> None: + """Zero remaining sources is a stop, not a breakdown.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=False, keys_right=False, vocal=False) + topology = extractor._build_activity_topology( + "stop-1", + _roles(extractor), + current, + None, + previous, + ) + assert topology["active_roles"] == [] + + +def test_activity_breakdown_stays_unnamed_when_a_new_entrance_arrives() -> None: + """A mixed entrance is not a staying sparse hold.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=False, guitar=True) + topology = extractor._build_activity_topology( + "mix-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("breakdownPlan" not in role for role in topology["active_roles"]) + + +def test_activity_breakdown_stays_unnamed_when_previous_graph_is_thin() -> None: + """Two previous sources are not dense enough to name a breakdown.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=False, vocal=False) + topology = extractor._build_activity_topology( + "thin-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("breakdownPlan" not in role for role in topology["active_roles"]) + + +def test_activity_breakdown_counts_accompaniment_stems_as_one_source() -> None: + """Keys and acoustic guitar share one accompaniment source for density.""" + extractor = RoleExtractor() + previous = _activity( + bass=True, + keys_right=True, + vocal=True, + keys_left=True, + guitar=True, + ) + current = _activity( + bass=True, + keys_right=False, + vocal=False, + keys_left=False, + guitar=False, + ) + topology = extractor._build_activity_topology( + "sparse-1", + _roles(extractor), + current, + None, + previous, + ) + bass = next(role for role in topology["active_roles"] if role["id"] == "bass-guitar") + assert bass["breakdownPlan"] == _SOLO_PLAN + + +def test_extract_emits_breakdown_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section evidence into breakdown emission.""" + extractor = RoleExtractor() + sr = 8 + bass = np.concatenate([np.ones(sr, dtype=np.float32), np.ones(sr, dtype=np.float32)]) + other = np.concatenate([np.ones(sr, dtype=np.float32), np.zeros(sr, dtype=np.float32)]) + vocal = np.concatenate([np.ones(sr, dtype=np.float32), np.zeros(sr, dtype=np.float32)]) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": bass, "other": other, "vocals": vocal}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + chorus: dict[str, Any] = result["topologies"][1] + bass_role = next(role for role in chorus["active_roles"] if role["id"] == "bass-guitar") + assert bass_role.get("breakdownPlan") == _SOLO_PLAN + assert result["topologies"][0]["active_roles"] + assert all("breakdownPlan" not in role for role in result["topologies"][0]["active_roles"]) + + +def test_activity_breakdown_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-source hold without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_breakdown_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=False, vocal=True), + _activity(bass=True, keys_right=True, vocal=True), + ) + assert plan is None + + +def test_activity_breakdown_stays_unnamed_when_two_source_partners_are_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A two-source count with no other-source partner stays unnamed.""" + extractor = RoleExtractor() + + def _forced_two_current_sources(_cls: type[RoleExtractor], role_ids: set[str]) -> int: + """Keep previous density valid while reporting two current sources.""" + return 3 if len(role_ids) >= 3 else 2 + + monkeypatch.setattr(RoleExtractor, "_source_count", classmethod(_forced_two_current_sources)) + plan = extractor._activity_breakdown_plan( + "bass-guitar", + _roles(extractor), + _activity(bass=True, keys_right=False, vocal=False), + _activity(bass=True, keys_right=True, vocal=True), + ) + assert plan is None