From fa27cdf9731a47364b68a5dcbbe472661be50114 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:32:47 +0000 Subject: [PATCH 01/43] feat(workspace): name tonight's first drop plan on the map Name the earliest corroborated full-band arrival after a thin texture so the entering part can land together when the map fills. Heuristic demo topology stays unnamed until real stem activity proves the drop. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/core/src/lib.rs | 4 + apps/desktop/core/tests/drop_plan_contract.rs | 86 ++++ ...stDropPlanCallout.custom-guidance.test.tsx | 45 ++ .../FirstDropPlanCallout.identity.test.tsx | 21 + ...ropPlanCallout.navigation-failure.test.tsx | 33 ++ .../FirstDropPlanCallout.particle.test.tsx | 82 ++++ .../FirstDropPlanCallout.provenance.test.tsx | 87 ++++ ...rstDropPlanCallout.reduced-motion.test.tsx | 55 +++ .../workspace/FirstDropPlanCallout.test.tsx | 129 +++++ .../workspace/FirstDropPlanCallout.tsx | 213 +++++++++ ...tDropPlanCallout.unavailable-copy.test.tsx | 32 ++ .../workspace/Workspace.drop-state.test.tsx | 72 +++ .../src/features/workspace/Workspace.tsx | 36 +- .../workspace/firstDropPlan.demo.test.ts | 9 + .../firstDropPlan.proxy-authority.test.ts | 31 ++ .../features/workspace/firstDropPlan.test.ts | 386 +++++++++++++++ .../src/features/workspace/firstDropPlan.ts | 442 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 53 ++- apps/desktop/src/i18n/index.ts | 34 ++ apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- packages/shared-types/src/index.ts | 19 +- .../test/dropPlanProvenance.test.ts | 28 ++ packages/shared-types/test/index.test.ts | 6 + .../src/bandscope_analysis/roles/extractor.py | 94 +++- .../src/bandscope_analysis/roles/model.py | 4 +- .../analysis-engine/tests/test_drop_plan.py | 303 ++++++++++++ 31 files changed, 2316 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/core/tests/drop_plan_contract.rs create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.navigation-failure.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropPlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/Workspace.drop-state.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.ts create mode 100644 packages/shared-types/test/dropPlanProvenance.test.ts create mode 100644 services/analysis-engine/tests/test_drop_plan.py diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..7b3d4af3b 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 drop plan with the owning part when an entering role is corroborated, the owned `dropPlan` 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, breakdown 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..1fb06abef 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 drop plan on the mounted map when section-level stem activity shows a corroborated density fill (previous graph 1–2 distinct sources, current graph ≥3 sources, previous sources stay, new entrance), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-breakdown, 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..00205c26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first drop plan in the mounted rehearsal workspace so the part that enters after a thin texture can land the full-band arrival on the map; real analyzed songs now receive this guidance only when section-level stem activity shows the previous graph with one or two distinct sources and the current graph holding at least three sources with those previous sources staying and a new entrance, 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..86623d6aa 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 drop 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, breakdown plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-breakdown, 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..fe91486d8 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")] + drop_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + drop_plan_source: Option, } #[derive(Clone, Debug, Serialize)] diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs new file mode 100644 index 000000000..ebf128670 --- /dev/null +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -0,0 +1,86 @@ +use bandscope_desktop_core::project_payload_from_content; +use serde_json::{json, Value}; + +fn song_with_drop_plan() -> Value { + json!({ + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Filled chorus downbeat", + "timeRange": { "start": 30, "end": 46 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Stem activity corroborates the drop." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Come in on the filled chorus." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal enters when the texture fills." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "dropPlan": "Hit this drop; come in together when the texture fills.", + "dropPlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Land the chorus drop together.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_drop_plan_provenance() { + let payload = song_with_drop_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 drop-plan fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["dropPlan"], + payload["sections"][0]["roles"][0]["dropPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["dropPlanSource"], + json!("model") + ); +} diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..56031c6f9 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithCustomDropPlan(source: "model" | "user" | undefined, text: string) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.dropPlan = text; + if (source) { + vocal.dropPlanSource = source; + } + song.sections = [verse, chorus]; + return song; +} + +describe("FirstDropPlanCallout custom guidance", () => { + it("preserves user-authored drop guidance verbatim", () => { + render( + + ); + expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy(); + }); + + it("preserves custom copy without model provenance instead of rewriting it", () => { + render( + + ); + expect(screen.getByText("Stack the last bar and land together.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.identity.test.tsx new file mode 100644 index 000000000..9fe14b8a6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +it("gives co-mounted drop-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first drop 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/FirstDropPlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..64f52f02b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.navigation-failure.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.dropPlan = "Hit this drop; come in together when the texture fills."; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstDropPlanCallout navigation failure", () => { + it("names the next action when the rendered map target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect( + screen.getByRole("status").textContent + ).toBe("Could not open this drop on the song map. Use the map below to find the section."); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx new file mode 100644 index 000000000..4ae23638e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithKoreanDrop( + dropPlan: string, + dropPlanSource?: "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", + dropPlan, + ...(dropPlanSource ? { dropPlanSource } : {}) + } + ]; + chorus.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: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: false, 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("FirstDropPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the drop action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanDrop( + "Hit this drop; come in together when the texture fills.", + "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(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx new file mode 100644 index 000000000..efb83f3e8 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithKoreanDrop( + dropPlan: string, + dropPlanSource?: "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", + dropPlan, + ...(dropPlanSource ? { dropPlanSource } : {}) + } + ]; + chorus.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: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: false, 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("FirstDropPlanCallout drop-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user drop guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Hit this drop; come in together when the texture fills."; + const song = songWithKoreanDrop(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.")).toBeNull(); + }); + + it("does not infer model authority when persisted drop guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Hit this drop; come in together when the texture fills."; + const song = songWithKoreanDrop(legacyPlan); + + render(); + + expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.")).toBeNull(); + }); + + it("localizes model guidance from structured landing topology instead of display sentence wording", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanDrop( + "Hit this drop with Keyboard 1 Right Hand; come in together when the texture fills.", + "model" + ); + + render(); + + expect( + screen.getByText( + "Keyboard 1 Right Hand 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오." + ) + ).toBeTruthy(); + expect( + screen.queryByText( + "Hit this drop with Keyboard 1 Right Hand; come in together when the texture fills." + ) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..74bdaf69b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.dropPlan = DEMO_DROP_PLAN; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstDropPlanCallout reduced motion", () => { + afterEach(() => { + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => { + node.parentElement?.remove(); + }); + vi.unstubAllGlobals(); + }); + + it("uses immediate scrolling when the operating system requests reduced motion", () => { + 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); + document.body.appendChild(grid); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion: reduce"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx new file mode 100644 index 000000000..442357a88 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; +const appendedSongStructureTargets = new Set(); + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ + ...node, + is_active: true + })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.dropPlan = DEMO_DROP_PLAN; + vocal.dropPlanSource = "model"; + 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("FirstDropPlanCallout", () => { + 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 drop 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 = songWithDropPlan(); + 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 Lead Vocal drop at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithDropPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No drop 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 drop signature", () => { + const firstSong = songWithDropPlan(); + const nextSong = songWithDropPlan(); + 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 Lead Vocal drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("opens the named drop on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + expect(screen.getByText(DEMO_DROP_PLAN)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx new file mode 100644 index 000000000..adb6068d2 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx @@ -0,0 +1,213 @@ +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 { + formatDropPlanTime, + resolveFirstDropPlan, + type DropPlanGuidance +} from "./firstDropPlan"; + +/** Props for the first drop-plan rehearsal callout. */ +export interface FirstDropPlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type DropPlanCopyValues = Readonly>; +type DropPlanSource = "model" | "user"; + +type OpenedDropPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + landingRoleId: string; + dropPlan: string; + dropPlanSource: DropPlanSource | null; + dropPlanGuidanceKind: DropPlanGuidance["kind"] | null; + dropPlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableDropPlanSongIdentity(song: RehearsalSong, workspaceInstanceKey: unknown): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate drop-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatDropPlanCopy(template: string, values: DropPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof DropPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model drop guidance from structured landing topology, never from display-copy grammar. */ +function localizedDropPlan( + dropPlan: string, + dropPlanSource: DropPlanSource | null, + guidance: DropPlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (dropPlanSource !== "model" || guidance === null) { + return dropPlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredDropPlanScrollBehavior(): 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 resolveDropPlanRenderer(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 drop plan and open the matching rendered map section. */ +export function FirstDropPlanCallout({ song, workspaceInstanceKey }: FirstDropPlanCalloutProps) { + const calloutId = `workspace-surface-drop-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableDropPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstDropPlan(song), [song]); + const [openedDropPlan, setOpenedDropPlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.dropPlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.dropPlanGuidance?.kind === "role" ? named.dropPlanGuidance.targetRoleName : null; + + useEffect(() => { + setOpenedDropPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.landingRoleId, + named?.dropPlan, + named?.dropPlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedDropPlan !== null && + openedDropPlan.songIdentity === songIdentity && + openedDropPlan.sectionId === named.sectionId && + openedDropPlan.sectionIndex === named.sectionIndex && + openedDropPlan.landingRoleId === named.landingRoleId && + openedDropPlan.dropPlan === named.dropPlan && + openedDropPlan.dropPlanSource === named.dropPlanSource && + openedDropPlan.dropPlanGuidanceKind === guidanceKind && + openedDropPlan.dropPlanTargetRoleName === guidanceTargetRoleName && + openedDropPlan.atSeconds === named.atSeconds; + const at = formatDropPlanTime(named.atSeconds); + const copyValues: DropPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatDropPlanCopy(t("firstDropPlanOpenAction"), copyValues); + const body = formatDropPlanCopy(t("firstDropPlanBody"), copyValues); + const armed = formatDropPlanCopy(t("firstDropPlanArmed"), copyValues); + const dropPlan = localizedDropPlan( + named.dropPlan, + named.dropPlanSource, + named.dropPlanGuidance, + t("firstDropPlanGeneratedGuidance"), + t("firstDropPlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..fc1745aa2 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +describe("FirstDropPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English drop plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No drop plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean drop plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 드롭 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.drop-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.drop-state.test.tsx new file mode 100644 index 000000000..99adcee6d --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.drop-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 analyzedSongWithDropPlan(): 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: false, handoff_to: [], handoff_from: [] } + ]; + + const chorus = structuredClone(verse); + chorus.id = "chorus-drop-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: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.dropPlan = "Hit this drop; come in together when the texture fills."; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace drop 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 drop armed after an immutable practice-progress update", () => { + const song = analyzedSongWithDropPlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills\./)).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..2f42692f0 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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; 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,7 +531,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts new file mode 100644 index 000000000..9d76cb902 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +describe("resolveFirstDropPlan demo topology", () => { + it("keeps heuristic demo topology unnamed until real stem activity corroborates a drop", () => { + expect(resolveFirstDropPlan(createDemoRehearsalSong())).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts new file mode 100644 index 000000000..ffcd25a28 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +describe("resolveFirstDropPlan proxy authority", () => { + it("does not read inherited or Proxy-substituted dropPlan as rehearsal copy", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + const hostile = new Proxy(vocal, { + get(_target, property) { + if (property === "dropPlan") { + return "Hit this drop; come in together when the texture fills."; + } + return Reflect.get(_target, property); + } + }); + chorus.roles = chorus.roles.map((role) => (role.id === "lead-vocal" ? hostile : role)); + song.sections = [verse, chorus]; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.test.ts new file mode 100644 index 000000000..f5dcfac42 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatDropPlanTime, resolveFirstDropPlan } from "./firstDropPlan"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +function withDropSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + dropPlan?: 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?: 1 | 2 | 3; + keepCompanion?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 10; + const previousStart = overrides.previousStart ?? 0; + const roleId = overrides.roleId ?? "lead-vocal"; + 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 as { dropPlan?: string }).dropPlan; + delete (keys as { dropPlanSource?: string }).dropPlanSource; + delete (vocal as { dropPlan?: string }).dropPlan; + delete (vocal as { dropPlanSource?: string }).dropPlanSource; + delete (bass as { dropPlan?: string }).dropPlan; + delete (bass as { dropPlanSource?: string }).dropPlanSource; + + const landing = { + ...(roleId === "keys-right" ? keys : roleId === "bass-guitar" ? bass : vocal), + id: roleId, + name: + overrides.roleName ?? + (roleId === "keys-right" + ? "Keyboard 1 Right Hand" + : roleId === "bass-guitar" + ? "Bass Guitar" + : "Lead Vocal"), + rehearsalPriority: overrides.priority ?? "high", + dropPlan: overrides.dropPlan ?? DEMO_DROP_PLAN + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-drop"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 20 }; + current.roles = overrides.keepCompanion ? [landing, bass] : [landing, bass, keys]; + current.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: roleId === "lead-vocal" ? (overrides.isActive ?? true) : Boolean(overrides.keepCompanion), + handoff_to: [], + handoff_from: [] + } + ]; + if (roleId === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.partGraph[2]!.is_active = true; + current.roles = [landing, bass, vocal]; + } + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.partGraph[2]!.is_active = true; + current.roles = [landing, keys, vocal]; + } + + const previous = structuredClone(current); + previous.id = `${current.id}-thin`; + previous.label = "verse"; + previous.timeRange = { start: previousStart, end: landingStart }; + previous.roles = [structuredClone(bass), structuredClone(keys)]; + previous.roles.forEach((role) => { + delete (role as { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + }); + const previousVocalActive = overrides.previousActiveCount === 3; + const previousKeysActive = overrides.previousActiveCount !== 1; + previous.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { + role_id: "keys-right", + is_active: previousKeysActive, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: previousVocalActive, + handoff_to: [], + handoff_from: [] + } + ]; + if (overrides.wasActive === true) { + previous.partGraph = previous.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId ? true : node.is_active + })); + } + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstDropPlan", () => { + it("picks the earliest drop plan and the part that lands the filled texture", () => { + const resolved = resolveFirstDropPlan(withDropSection()); + expect(resolved?.section.id).toBe("chorus-drop"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe(DEMO_DROP_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatDropPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatDropPlanTime(Number.NaN)).toBe("0:00"); + expect(formatDropPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a drop 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, breakdown plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withDropSection(); + delete song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.dropPlan; + const landing = song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!; + song.sections[1]!.groove = "Straight eighths with a late snare feel"; + landing.simplification = "Stay on roots if the chorus entrance gets muddy."; + landing.setupNote = DEMO_DROP_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + (landing as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (landing as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (landing as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (landing as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (landing as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the pickup."; + (landing as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (landing as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (landing as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (landing as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + (landing as { cutoffPlan?: string }).cutoffPlan = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + (landing as { turnaroundPlan?: string }).turnaroundPlan = + "Turn these last bars with Lead Vocal; land the downbeat together."; + (landing as { pickupPlan?: string }).pickupPlan = + "Play this pickup with Lead Vocal; land the downbeat together."; + (landing as { breakdownPlan?: string }).breakdownPlan = + "Hold this breakdown; keep it sparse until the drop."; + landing.cue = { kind: "lyric", value: "city lights" }; + landing.range = { lowestNote: "G#3", highestNote: "C#5" }; + landing.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + landing.harmony = { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "user" + }; + landing.harmonicExplanation = "The vocal lands the chorus center."; + landing.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + landing.confidence = { + level: "high", + source: "user", + notes: DEMO_DROP_PLAN + }; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("skips a blank drop plan", () => { + expect(resolveFirstDropPlan(withDropSection({ dropPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line drop plan", () => { + expect( + resolveFirstDropPlan(withDropSection({ dropPlan: "Come in together.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two drop plans", () => { + const song = withDropSection({ + id: "chorus-late-drop", + start: 40, + end: 56, + previousStart: 24, + roleId: "lead-vocal", + dropPlan: "Late drop." + }); + const earlier = structuredClone(song.sections[1]!); + earlier.id = "chorus-early"; + earlier.roles = [ + { + ...earlier.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + dropPlan: "Earlier drop." + }, + ...earlier.roles.filter((role) => role.id !== "lead-vocal") + ]; + earlier.timeRange = { start: 8, end: 24 }; + const earlierThin = structuredClone(song.sections[0]!); + earlierThin.id = "verse-before-early"; + earlierThin.timeRange = { start: 0, end: 8 }; + song.sections[0]!.timeRange = { start: 24, end: 40 }; + song.sections = [earlierThin, earlier, song.sections[0]!, song.sections[1]!]; + + const resolved = resolveFirstDropPlan(song); + expect(resolved?.section.id).toBe("chorus-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe("Earlier drop."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time drop-plan ties with locale-independent id ordering", () => { + const song = withDropSection({ id: "ä-drop", start: 10, end: 26 }); + const umlautThin = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiThin = structuredClone(umlautThin); + asciiThin.id = "z-drop-thin"; + const ascii = structuredClone(umlaut); + ascii.id = "z-drop"; + song.sections = [umlautThin, umlaut, asciiThin, ascii]; + + expect(resolveFirstDropPlan(song)?.section.id).toBe("z-drop"); + }); + + it("prefers a high-priority landing part over a low-priority part in the same section", () => { + const song = withDropSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + dropPlan: "Low-priority drop." + }); + const section = song.sections[1]!; + const highRole = { + ...section.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + dropPlan: "High-priority drop." + }; + section.roles = [...section.roles.filter((role) => role.id !== "lead-vocal"), highRole]; + section.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: [] } + ]; + + expect(resolveFirstDropPlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstDropPlan(song)?.dropPlan).toBe("High-priority drop."); + }); + + it("skips a drop plan whose graph node is inactive", () => { + expect(resolveFirstDropPlan(withDropSection({ isActive: false }))).toBeNull(); + }); + + it("skips a drop plan whose previous graph node was already active", () => { + expect(resolveFirstDropPlan(withDropSection({ wasActive: true, previousActiveCount: 3 }))).toBeNull(); + }); + + it("skips a drop whose previous graph already had three sources", () => { + expect(resolveFirstDropPlan(withDropSection({ previousActiveCount: 3 }))).toBeNull(); + }); + + it("skips a drop plan whose rest and landing windows do not abut", () => { + const song = withDropSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("skips a drop plan whose rehearsal window is unbounded", () => { + expect(resolveFirstDropPlan(withDropSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a drop plan whose end precedes its start", () => { + expect(resolveFirstDropPlan(withDropSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length drop-plan window", () => { + expect(resolveFirstDropPlan(withDropSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a drop plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstDropPlan( + withDropSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstDropPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withDropSection(); + song.sections[1]!.roles = [null as never, ...song.sections[1]!.roles]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstDropPlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withDropSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("keeps the drop plan unnamed when role identities are duplicated", () => { + const song = withDropSection(); + const role = song.sections[1]!.roles.find((item) => item.id === "lead-vocal")!; + 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(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name a full stop as a drop", () => { + const song = withDropSection(); + song.sections[1]!.partGraph = song.sections[1]!.partGraph.map((node) => ({ + ...node, + is_active: false + })); + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name an unchanged dense texture as a drop", () => { + const song = withDropSection({ previousActiveCount: 3 }); + song.sections[1]!.partGraph = song.sections[0]!.partGraph.map((node) => ({ ...node })); + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name a density drop as a drop", () => { + const song = withDropSection(); + song.sections[0]!.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: [] } + ]; + song.sections[1]!.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: [] } + ]; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.ts b/apps/desktop/src/features/workspace/firstDropPlan.ts new file mode 100644 index 000000000..4890242b9 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.ts @@ -0,0 +1,442 @@ +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_DROP_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 DROP_PLAN_SOLO = "Hit this drop; come in together when the texture fills."; +const DROP_PLAN_PREFIX = "Hit this drop with "; +const DROP_PLAN_SUFFIX = "; come in together when the texture fills."; + +type DropPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated drop-plan copy. */ +export type DropPlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedDropPlan = Readonly<{ + text: string; + source: DropPlanSource | null; + guidance: DropPlanGuidance | null; +}>; + +/** Tonight's first drop plan: the earliest labeled full-band arrival after a thin texture. */ +export type FirstDropPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + dropPlan: string; + dropPlanSource: DropPlanSource | null; + dropPlanGuidance: DropPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative drop-plan time as m:ss for rehearsal copy. */ +export function formatDropPlanTime(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 drop template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedDropPlan(value: string): OwnedDropPlan | null { + if (value === DROP_PLAN_SOLO) { + return { text: DROP_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(DROP_PLAN_PREFIX) || !value.endsWith(DROP_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(DROP_PLAN_PREFIX.length, -DROP_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(DROP_PLAN_PREFIX + DROP_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_DROP_PLAN_CHARACTERS - fixedLength); + return { + text: `${DROP_PLAN_PREFIX}${boundedTarget}${DROP_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own drop plan and its explicit provenance, or null when malformed. */ +function ownedDropPlan(role: unknown): OwnedDropPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const dropPlan = ownDataValue(role, "dropPlan"); + const dropPlanSource = ownDataValue(role, "dropPlanSource"); + if (typeof dropPlan !== "string") { + return null; + } + if (dropPlanSource !== undefined && dropPlanSource !== "model" && dropPlanSource !== "user") { + return null; + } + const trimmed = dropPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (dropPlanSource === "model") { + const generatedPlan = boundedGeneratedDropPlan(trimmed); + if (generatedPlan !== null) { + return generatedPlan; + } + } + return { + text: truncateCodePoints(trimmed, MAX_DROP_PLAN_CHARACTERS), + source: dropPlanSource ?? 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 dropSourceId(roleId: string): string { + return ACCOMPANIMENT_SOURCE_ROLE_IDS.has(roleId) ? ACCOMPANIMENT_SOURCE_ID : roleId; +} + +/** Prefer rehearsal priority, then a locale-independent stable id. */ +function pickLandingRole(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) => dropSourceId(roleId))).size; +} + +/** Resolve a drop plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstDropPlan(song: RehearsalSong): FirstDropPlan | 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 < 1 || previousSourceCount > 2 || currentSourceCount < 3) { + return []; + } + for (const roleId of previousActiveIds) { + if (!currentActiveIds.has(roleId)) { + return []; + } + } + let entered = false; + for (const roleId of currentActiveIds) { + if (!previousActiveIds.has(roleId)) { + entered = true; + break; + } + } + if (!entered) { + return []; + } + + const landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if (previousActiveIds.has(metadata.id)) { + return []; + } + const dropPlan = ownedDropPlan(metadata.role); + return dropPlan === null + ? [] + : [ + { + ...metadata, + dropPlan: dropPlan.text, + dropPlanSource: dropPlan.source, + dropPlanGuidance: dropPlan.guidance + } + ]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + dropPlan: landingRole.dropPlan, + dropPlanSource: landingRole.dropPlanSource, + dropPlanGuidance: landingRole.dropPlanGuidance, + 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 drop plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstDropPlan(song: RehearsalSong): FirstDropPlan | null { + try { + return resolveSafeFirstDropPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..fae7eab9e 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,55 @@ 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-drop-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstDropPlanOpenAction")).toBe("{at} {role} 드롭 열기"); + expect(t("firstDropPlanBody")).toBe("{at} {section}에서 {role} 파트가 드롭을 맞습니다."); + expect(t("firstDropPlanArmed")).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..f7f6d34dd 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}.", + "firstDropPlanLabel": "Tonight's first drop plan", + "firstDropPlanOpenAction": "Open {role} drop at {at}", + "firstDropPlanBody": "{role} lands the {section} drop at {at}.", + "firstDropPlanArmed": "Land {role} together at {at} when the texture fills.", + "firstDropPlanGeneratedGuidance": "Hit this drop with {target}; come in together when the texture fills.", + "firstDropPlanGeneratedSoloGuidance": "Hit this drop; come in together when the texture fills.", + "firstDropPlanUnavailable": "No drop plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstDropPlanNavigationFailed": "Could not open this drop 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..ae3e2e76c 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} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstDropPlanLabel": "오늘 첫 드롭 계획", + "firstDropPlanOpenAction": "{at} {role} 드롭 열기", + "firstDropPlanBody": "{at} {section}에서 {role} 파트가 드롭을 맞습니다.", + "firstDropPlanArmed": "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오십시오.", + "firstDropPlanGeneratedGuidance": "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.", + "firstDropPlanGeneratedSoloGuidance": "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.", + "firstDropPlanUnavailable": "사용 가능한 드롭 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstDropPlanNavigationFailed": "곡 맵에서 이 드롭을 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..2cfc95fae 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; + dropPlan?: string; + dropPlanSource?: ProvenanceSource; }; /** Documented. */ @@ -1500,7 +1502,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "dropPlan", + "dropPlanSource" ], path ); @@ -1588,6 +1592,19 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if (value.dropPlan !== undefined && typeof value.dropPlan !== "string") { + return invalidField(`${path}.dropPlan`); + } + if ( + value.dropPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.dropPlanSource) + ) { + return invalidField(`${path}.dropPlanSource`); + } + if (value.dropPlanSource !== undefined && value.dropPlan === undefined) { + return invalidField(`${path}.dropPlanSource`); + } + return null; } diff --git a/packages/shared-types/test/dropPlanProvenance.test.ts b/packages/shared-types/test/dropPlanProvenance.test.ts new file mode 100644 index 000000000..21df18403 --- /dev/null +++ b/packages/shared-types/test/dropPlanProvenance.test.ts @@ -0,0 +1,28 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("dropPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s drop plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + role.dropPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.dropPlanSource).toBe(source); + }); + + it("rejects an unknown drop plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + role.dropPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); + + it("rejects a drop plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.dropPlan; + role.dropPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..fb03976b0 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].dropPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.dropPlan = 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..cbf642991 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" +_DROP_PLAN_SOLO = "Hit this drop; come in together when the texture fills." +_DROP_PLAN_PREFIX = "Hit this drop with " +_DROP_PLAN_SUFFIX = "; come in together when the texture fills." + 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,80 @@ 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_drop_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 drop guidance only for a corroborated density fill. + + A drop plan is emitted only when real stem activity shows this role + entering after a thin texture: the previous section had one or two + distinct sources, the current section holds at least three, every + previous source stays, and at least one new source enters. Heuristic + fallback topology and first-section (no previous activity) produce no + plan. A density drop is not a drop plan. The shared ``other`` stem may + corroborate density but never proves which keyboard or guitar part owns + the landing. + """ + 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 in previous_active: + return None + if role_id in _OTHER_STEM_ROLE_IDS: + return None + if previous_active - current_active: + return None + entered = current_active - previous_active + previous_sources = self._source_count(previous_active) + current_sources = self._source_count(current_active) + if previous_sources < 1 or previous_sources > 2 or current_sources < 3: + return None + + named_entered = { + candidate_id for candidate_id in entered if candidate_id not in _OTHER_STEM_ROLE_IDS + } + if named_entered == {role_id}: + return _DROP_PLAN_SOLO + + partners = sorted(named_entered - {role_id}) + if len(partners) != 1: + return None + partner_id = partners[0] + other_name = next( + (role["name"] for role in roles.values() if role["id"] == partner_id), + None, + ) + if other_name is None: + return None + return f"{_DROP_PLAN_PREFIX}{other_name}{_DROP_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 +436,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] + drop_plan = self._activity_drop_plan( + role_id, + roles, + role_activity, + previous_role_activity, + ) + if drop_plan is not None: + role = role.copy() + role["dropPlan"] = drop_plan + role["dropPlanSource"] = "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..f81c50613 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] + dropPlan: NotRequired[str] + dropPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_drop_plan.py b/services/analysis-engine/tests/test_drop_plan.py new file mode 100644 index 000000000..91dd735b5 --- /dev/null +++ b/services/analysis-engine/tests/test_drop_plan.py @@ -0,0 +1,303 @@ +"""Tests for corroborated drop-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 = "Hit this drop; come in together when the texture fills." +_PREFIX = "Hit this drop with " +_SUFFIX = "; come in together when the texture fills." + + +def _activity( + *, + bass: bool, + keys_right: bool, + vocal: bool, + keys_left: bool = False, + guitar: bool = False, + extra: dict[str, bool] | None = None, +) -> dict[str, bool]: + """Return a complete role-activity map for one section.""" + activity = { + "bass-guitar": bass, + "keys-left": keys_left, + "keys-right": keys_right, + "lead-vocal": vocal, + "acoustic-guitar": guitar, + } + if extra: + activity.update(extra) + return activity + + +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_drop_emits_solo_plan_for_a_two_to_three_fill() -> None: + """A thin verse filling to three sources names the entering landing.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + assert vocal["dropPlanSource"] == "model" + assert all( + "dropPlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"] + ) + + +def test_activity_drop_names_two_named_entrances_as_partners() -> None: + """Two named parts entering together point at each other.""" + extractor = RoleExtractor() + previous = _activity(bass=False, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=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["lead-vocal"]["dropPlan"] == f"{_PREFIX}Bass Guitar{_SUFFIX}" + assert roles_by_id["bass-guitar"]["dropPlan"] == f"{_PREFIX}Lead Vocal{_SUFFIX}" + assert "dropPlan" not in roles_by_id["keys-right"] + + +def test_activity_drop_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a drop.""" + extractor = RoleExtractor() + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "verse-1", + _roles(extractor), + current, + None, + None, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a drop plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_for_a_density_drop() -> None: + """A staying sparse hold is a breakdown, not a drop.""" + 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( + "breakdown-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_a_previous_source_leaves() -> None: + """A mixed dropout is not a corroborated full-band arrival.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=False, keys_right=True, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "mix-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_previous_graph_is_already_dense() -> None: + """Three previous sources are already full-band, so a new entrance is not a drop.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "already-dense-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_current_graph_stays_thin() -> None: + """Two current sources are not a full-band arrival.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=False, vocal=False) + current = _activity(bass=True, keys_right=False, vocal=True) + topology = extractor._build_activity_topology( + "thin-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_previous_graph_is_empty() -> None: + """Zero previous sources cannot corroborate a fill after a thin texture.""" + extractor = RoleExtractor() + previous = _activity(bass=False, keys_right=False, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "from-silence-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_does_not_assign_an_other_stem_landing() -> None: + """The shared other stem may corroborate density but never owns the landing.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=False, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True, 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["lead-vocal"]["dropPlan"] == _SOLO_PLAN + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "dropPlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_drop_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=False, + keys_left=True, + guitar=True, + ) + current = _activity( + bass=True, + keys_right=True, + vocal=True, + keys_left=True, + guitar=True, + ) + topology = extractor._build_activity_topology( + "fill-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + + +def test_extract_emits_drop_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section evidence into drop emission.""" + extractor = RoleExtractor() + sr = 8 + bass = np.concatenate([np.ones(sr, dtype=np.float32), np.ones(sr, dtype=np.float32)]) + other = np.concatenate([np.zeros(sr, dtype=np.float32), np.ones(sr, dtype=np.float32)]) + vocal = np.concatenate([np.zeros(sr, dtype=np.float32), np.ones(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] + vocal_role = next(role for role in chorus["active_roles"] if role["id"] == "lead-vocal") + assert vocal_role.get("dropPlan") == _SOLO_PLAN + assert result["topologies"][0]["active_roles"] + assert all("dropPlan" not in role for role in result["topologies"][0]["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-named-entrance fill without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_drop_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=True, vocal=True), + _activity(bass=False, keys_right=True, vocal=False), + ) + assert plan is None + + +def test_activity_drop_stays_unnamed_when_more_than_one_named_partner_enters() -> None: + """More than two named entrances cannot name a single landing partner.""" + extractor = RoleExtractor() + plan = extractor._activity_drop_plan( + "bass-guitar", + _roles(extractor), + _activity( + bass=True, + keys_right=True, + vocal=True, + extra={"drums": True}, + ), + _activity(bass=False, keys_right=True, vocal=False), + ) + assert plan is None + + +def test_activity_drop_stays_unnamed_when_role_is_inactive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An inactive role cannot own a drop even if source counts look filled.""" + extractor = RoleExtractor() + + def _forced_three_current_sources(_cls: type[RoleExtractor], role_ids: set[str]) -> int: + """Keep previous density valid while reporting a filled current graph.""" + return 1 if len(role_ids) <= 2 else 3 + + monkeypatch.setattr(RoleExtractor, "_source_count", classmethod(_forced_three_current_sources)) + plan = extractor._activity_drop_plan( + "lead-vocal", + _roles(extractor), + _activity(bass=True, keys_right=True, vocal=False), + _activity(bass=True, keys_right=True, vocal=False), + ) + assert plan is None From 7f163886fb82caaee59b5762f1c46f5fc30ae840 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:42:59 -0700 Subject: [PATCH 02/43] fix(review): remove unused drop-plan constant --- .../analysis-engine/src/bandscope_analysis/roles/extractor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index cbf642991..84dc7c008 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -23,7 +23,6 @@ logger = logging.getLogger(__name__) _OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) -_OTHER_STEM_SOURCE_LABEL = "Accompaniment" _DROP_PLAN_SOLO = "Hit this drop; come in together when the texture fills." _DROP_PLAN_PREFIX = "Hit this drop with " _DROP_PLAN_SUFFIX = "; come in together when the texture fills." @@ -555,4 +554,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) + return max(counts, key=lambda c: counts[c]) \ No newline at end of file From 620aed16a232c90bbe6759c94a68a76af8e71809 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:45:16 -0700 Subject: [PATCH 03/43] test(review): pin user drop guidance after open --- ...stDropPlanCallout.custom-guidance.test.tsx | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx index 56031c6f9..8dd19c270 100644 --- a/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx @@ -1,8 +1,10 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; +const appendedSongStructureTargets = new Set(); + function songWithCustomDropPlan(source: "model" | "user" | undefined, text: string) { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; @@ -24,7 +26,30 @@ function songWithCustomDropPlan(source: "model" | "user" | undefined, text: stri 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); + appendedSongStructureTargets.add(timeline); +} + describe("FirstDropPlanCallout custom guidance", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + }); + it("preserves user-authored drop guidance verbatim", () => { render( { expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy(); }); + it("keeps user-authored guidance in the plain body after opening the drop", () => { + appendSongStructureTarget(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + + expect(screen.getByText("Lead Vocal lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + it("preserves custom copy without model provenance instead of rewriting it", () => { render( { ); expect(screen.getByText("Stack the last bar and land together.")).toBeTruthy(); }); -}); +}); \ No newline at end of file From 1c0fa8abb444b0f6df8b58fc837501567ee9b20f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:05:58 -0700 Subject: [PATCH 04/43] test(i18n): pin consistent Korean drop-plan guidance --- apps/desktop/src/i18n/index.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index fae7eab9e..e039035b6 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -116,14 +116,19 @@ describe("i18n", () => { expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); }); - it("keeps Korean first-drop-plan next-action copy particle-safe", () => { + it("keeps Korean first-drop-plan next-action copy particle-safe and tonally consistent", () => { const t = createTranslator("ko"); expect(t("firstDropPlanOpenAction")).toBe("{at} {role} 드롭 열기"); expect(t("firstDropPlanBody")).toBe("{at} {section}에서 {role} 파트가 드롭을 맞습니다."); expect(t("firstDropPlanArmed")).toBe( - "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오십시오." + "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오세요." + ); + expect(t("firstDropPlanGeneratedGuidance")).toBe( + "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." + ); + expect(t("firstDropPlanGeneratedSoloGuidance")).toBe( + "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." ); }); }); }); - From b340f15baf565af693ed21028ccfb54e695d97c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:06:56 -0700 Subject: [PATCH 05/43] fix(i18n): align Korean drop-plan guidance tone --- apps/desktop/src/locales/ko/common.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index ae3e2e76c..9e69c184d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -158,9 +158,9 @@ "firstDropPlanLabel": "오늘 첫 드롭 계획", "firstDropPlanOpenAction": "{at} {role} 드롭 열기", "firstDropPlanBody": "{at} {section}에서 {role} 파트가 드롭을 맞습니다.", - "firstDropPlanArmed": "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오십시오.", - "firstDropPlanGeneratedGuidance": "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.", - "firstDropPlanGeneratedSoloGuidance": "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.", + "firstDropPlanArmed": "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오세요.", + "firstDropPlanGeneratedGuidance": "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.", + "firstDropPlanGeneratedSoloGuidance": "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.", "firstDropPlanUnavailable": "사용 가능한 드롭 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", "firstDropPlanNavigationFailed": "곡 맵에서 이 드롭을 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } From 2664c8e1a55efa3c3af4d30a5bee32f8aa25935d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:08:23 -0700 Subject: [PATCH 06/43] test(workspace): reject untranslated model drop guidance --- .../firstDropPlan.model-guidance.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts new file mode 100644 index 000000000..61aef9afd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +it("rejects non-template model drop guidance instead of rendering untranslated copy", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + const previous = structuredClone(seed); + previous.id = "verse-thin"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys]; + 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: false, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-drop"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + vocal.dropPlan = "Model says: hit the chorus hard."; + vocal.dropPlanSource = "model"; + current.roles = [vocal, bass, keys]; + current.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: [] } + ]; + + song.sections = [previous, current]; + + expect(resolveFirstDropPlan(song)).toBeNull(); +}); From cf938afaa80fee74ad3b5f18b4aa95d6ff15c0c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:09:54 -0700 Subject: [PATCH 07/43] fix(workspace): reject free-form model drop guidance --- apps/desktop/src/features/workspace/firstDropPlan.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropPlan.ts b/apps/desktop/src/features/workspace/firstDropPlan.ts index 4890242b9..f5b898589 100644 --- a/apps/desktop/src/features/workspace/firstDropPlan.ts +++ b/apps/desktop/src/features/workspace/firstDropPlan.ts @@ -171,10 +171,7 @@ function ownedDropPlan(role: unknown): OwnedDropPlan | null { return null; } if (dropPlanSource === "model") { - const generatedPlan = boundedGeneratedDropPlan(trimmed); - if (generatedPlan !== null) { - return generatedPlan; - } + return boundedGeneratedDropPlan(trimmed); } return { text: truncateCodePoints(trimmed, MAX_DROP_PLAN_CHARACTERS), From a1c5a5756b63e59d5df01ab2e37db538a650c0f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:16:19 -0700 Subject: [PATCH 08/43] fix(ci): restore extractor formatter contract --- .../analysis-engine/src/bandscope_analysis/roles/extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 84dc7c008..e2da2a9b9 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -554,4 +554,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) \ No newline at end of file + return max(counts, key=lambda c: counts[c]) From f36fa45c5c4247960b7ac3fad3f9f5d789b166ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:16:53 -0700 Subject: [PATCH 09/43] test(core): reject orphan drop-plan provenance --- apps/desktop/core/tests/drop_plan_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index ebf128670..f586c9cf0 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -84,3 +84,18 @@ fn project_contract_round_trips_drop_plan_provenance() { json!("model") ); } + +#[test] +fn project_contract_rejects_drop_plan_source_without_drop_plan() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("dropPlan"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance without the value it describes" + ); +} From be72fe2c97b49917aaf3cbbe92406a5677c106b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:20:24 -0700 Subject: [PATCH 10/43] test(i18n): align particle-safe drop copy expectation --- .../features/workspace/FirstDropPlanCallout.particle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx index 4ae23638e..23f10fc98 100644 --- a/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx @@ -71,7 +71,7 @@ describe("FirstDropPlanCallout Korean role copy", () => { fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 드롭 열기" })); expect( - screen.getByText("0:30에서 피아노 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오십시오.") + screen.getByText("0:30에서 피아노 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오세요.") ).toBeTruthy(); expect(screen.queryByText(/피아노과/)).toBeNull(); expect(screen.queryByText(/피아노을/)).toBeNull(); From 0aab1d62e0cbeac12eb76acba81478a9c6d1042f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:20:52 -0700 Subject: [PATCH 11/43] test(i18n): align provenance guidance expectation --- .../workspace/FirstDropPlanCallout.provenance.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx index efb83f3e8..db43cb438 100644 --- a/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx @@ -50,7 +50,7 @@ describe("FirstDropPlanCallout drop-plan provenance", () => { render(); expect(screen.getByText(customPlan)).toBeTruthy(); - expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.")).toBeNull(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.")).toBeNull(); }); it("does not infer model authority when persisted drop guidance has no source", () => { @@ -61,7 +61,7 @@ describe("FirstDropPlanCallout drop-plan provenance", () => { render(); expect(screen.getByText(legacyPlan)).toBeTruthy(); - expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오.")).toBeNull(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.")).toBeNull(); }); it("localizes model guidance from structured landing topology instead of display sentence wording", () => { @@ -75,7 +75,7 @@ describe("FirstDropPlanCallout drop-plan provenance", () => { expect( screen.getByText( - "Keyboard 1 Right Hand 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오십시오." + "Keyboard 1 Right Hand 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." ) ).toBeTruthy(); expect( From 08925728b1dc6f4b3b5a9e75f862dc0754eecb64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:22:56 -0700 Subject: [PATCH 12/43] test(analysis): preserve shared source across role swap --- .../analysis-engine/tests/test_drop_plan.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/services/analysis-engine/tests/test_drop_plan.py b/services/analysis-engine/tests/test_drop_plan.py index 91dd735b5..7abb2c302 100644 --- a/services/analysis-engine/tests/test_drop_plan.py +++ b/services/analysis-engine/tests/test_drop_plan.py @@ -68,6 +68,23 @@ def test_activity_drop_emits_solo_plan_for_a_two_to_three_fill() -> None: ) +def test_activity_drop_keeps_shared_accompaniment_source_across_role_swap() -> None: + """A role swap inside the shared other stem does not invent a source dropout.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=False, vocal=True, guitar=True) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + + def test_activity_drop_names_two_named_entrances_as_partners() -> None: """Two named parts entering together point at each other.""" extractor = RoleExtractor() From 86363f35f766f6f49c72af3377ff8fd8beec5ee4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:08:33 -0700 Subject: [PATCH 13/43] test(drop-plan): pin shared-source continuity --- .../firstDropPlan.source-continuity.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts new file mode 100644 index 000000000..2aa149d97 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts @@ -0,0 +1,54 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +const DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +describe("resolveFirstDropPlan source continuity", () => { + it("keeps the shared accompaniment source across a keys-to-guitar role swap", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const guitar = structuredClone(seed.roles.find((role) => role.id === "acoustic-guitar")!); + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, guitar, vocal]) { + delete (role as { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + } + vocal.dropPlan = DROP_PLAN; + vocal.dropPlanSource = "model"; + + const previous = structuredClone(seed); + previous.id = "verse-thin"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys]; + 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: "acoustic-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-drop"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + current.roles = [bass, guitar, vocal]; + 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: "acoustic-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, current]; + + const resolved = resolveFirstDropPlan(song); + expect(resolved?.sectionId).toBe("chorus-drop"); + expect(resolved?.landingRoleId).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe(DROP_PLAN); + }); +}); From fc0d3c8e13020e7982af0e61eaa710d81f1c4130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:10:03 -0700 Subject: [PATCH 14/43] fix(drop-plan): compare continuing sources --- .../src/features/workspace/firstDropPlan.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropPlan.ts b/apps/desktop/src/features/workspace/firstDropPlan.ts index f5b898589..8879cc23d 100644 --- a/apps/desktop/src/features/workspace/firstDropPlan.ts +++ b/apps/desktop/src/features/workspace/firstDropPlan.ts @@ -319,9 +319,9 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { }); } -/** Count distinct source-separation stems that are explicitly active. */ -function activeSourceCount(activeIds: Set): number { - return new Set([...activeIds].map((roleId) => dropSourceId(roleId))).size; +/** Return distinct source-separation stems that are explicitly active. */ +function activeSourceIds(activeIds: Set): Set { + return new Set([...activeIds].map((roleId) => dropSourceId(roleId))); } /** Resolve a drop plan after the runtime root has passed its structural boundary checks. */ @@ -361,19 +361,19 @@ function resolveSafeFirstDropPlan(song: RehearsalSong): FirstDropPlan | null { const previousActiveIds = rankedGraphRoleIds(previousSection as RehearsalSection, true); const currentActiveIds = rankedGraphRoleIds(section as RehearsalSection, true); - const previousSourceCount = activeSourceCount(previousActiveIds); - const currentSourceCount = activeSourceCount(currentActiveIds); - if (previousSourceCount < 1 || previousSourceCount > 2 || currentSourceCount < 3) { + const previousSourceIds = activeSourceIds(previousActiveIds); + const currentSourceIds = activeSourceIds(currentActiveIds); + if (previousSourceIds.size < 1 || previousSourceIds.size > 2 || currentSourceIds.size < 3) { return []; } - for (const roleId of previousActiveIds) { - if (!currentActiveIds.has(roleId)) { + for (const sourceId of previousSourceIds) { + if (!currentSourceIds.has(sourceId)) { return []; } } let entered = false; - for (const roleId of currentActiveIds) { - if (!previousActiveIds.has(roleId)) { + for (const sourceId of currentSourceIds) { + if (!previousSourceIds.has(sourceId)) { entered = true; break; } @@ -436,4 +436,4 @@ export function resolveFirstDropPlan(song: RehearsalSong): FirstDropPlan | null } catch { return null; } -} +} \ No newline at end of file From 72a301250d68308c0b1ad2e22715aeb44409be31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:11:20 -0700 Subject: [PATCH 15/43] fix(drop-plan): preserve shared source continuity --- .../src/bandscope_analysis/roles/extractor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index e2da2a9b9..a74fd3412 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -381,7 +381,9 @@ def _activity_drop_plan( return None if role_id in _OTHER_STEM_ROLE_IDS: return None - if previous_active - current_active: + previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} + current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} + if previous_source_ids - current_source_ids: return None entered = current_active - previous_active previous_sources = self._source_count(previous_active) @@ -554,4 +556,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) + return max(counts, key=lambda c: counts[c]) \ No newline at end of file From ea6c8d974d38cd903b2a030f8584dc4158c177ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:11:51 -0700 Subject: [PATCH 16/43] test(drop-plan): reject unknown native provenance --- apps/desktop/core/tests/drop_plan_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index f586c9cf0..9f67028f4 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -99,3 +99,15 @@ fn project_contract_rejects_drop_plan_source_without_drop_plan() { "native persisted contract must reject provenance without the value it describes" ); } + +#[test] +fn project_contract_rejects_unknown_drop_plan_source() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["dropPlanSource"] = json!("legacy"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance outside model/user" + ); +} From 431eae7eed7094b14d0de78a9c1b86b04e0f0697 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:15:36 -0700 Subject: [PATCH 17/43] fix(drop-plan): validate native provenance --- apps/desktop/core/src/lib.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index fe91486d8..a64ec3ce8 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,13 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum DropPlanSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -194,7 +201,7 @@ pub struct RehearsalRolePayload { #[serde(default, skip_serializing_if = "Option::is_none")] drop_plan: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - drop_plan_source: Option, + drop_plan_source: Option, } #[derive(Clone, Debug, Serialize)] @@ -531,9 +538,22 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn validate_drop_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role.drop_plan.is_none() && role.drop_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + } + } + Ok(payload) +} + pub fn project_payload_from_content(content: &str) -> Result { if let Ok(parsed) = serde_json::from_str::(content) { - return Ok(parsed); + return validate_drop_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -551,7 +571,9 @@ pub fn project_payload_from_content(content: &str) -> Result Date: Thu, 27 Aug 2026 03:19:48 -0700 Subject: [PATCH 18/43] style(drop-plan): apply pinned Ruff formatting --- .../src/bandscope_analysis/roles/extractor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a74fd3412..93b67cf59 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -381,8 +381,12 @@ def _activity_drop_plan( return None if role_id in _OTHER_STEM_ROLE_IDS: return None - previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} - current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} + previous_source_ids = { + self._source_id(candidate_id) for candidate_id in previous_active + } + current_source_ids = { + self._source_id(candidate_id) for candidate_id in current_active + } if previous_source_ids - current_source_ids: return None entered = current_active - previous_active From 3ed69f3094a1da78bd77115d62124231f9b2941d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:07:36 -0700 Subject: [PATCH 19/43] test(review): reject accompaniment drop-plan ownership --- ...tDropPlan.accompaniment-provenance.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts new file mode 100644 index 000000000..48c7285c0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +const MODEL_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +describe("resolveFirstDropPlan accompaniment provenance", () => { + it("does not name a shared accompaniment role from persisted drop metadata", () => { + const song = createDemoRehearsalSong(); + const template = structuredClone(song.sections[0]!); + const bass = structuredClone(template.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(template.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(template.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, vocal]) { + delete (role as { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + } + keys.dropPlan = MODEL_DROP_PLAN; + keys.dropPlanSource = "model"; + + const thin = structuredClone(template); + thin.id = "verse-thin"; + thin.label = "verse"; + thin.timeRange = { start: 0, end: 10 }; + thin.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + thin.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 drop = structuredClone(template); + drop.id = "chorus-drop"; + drop.label = "chorus"; + drop.timeRange = { start: 10, end: 30 }; + drop.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + drop.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: [] } + ]; + + song.sections = [thin, drop]; + + expect(resolveFirstDropPlan(song)).toBeNull(); + }); +}); From e4d48df7f2dfd054eddf25a732b6150e01e25756 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:08:47 -0700 Subject: [PATCH 20/43] fix(review): reject accompaniment drop-plan ownership --- apps/desktop/src/features/workspace/firstDropPlan.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropPlan.ts b/apps/desktop/src/features/workspace/firstDropPlan.ts index 8879cc23d..e465bb26a 100644 --- a/apps/desktop/src/features/workspace/firstDropPlan.ts +++ b/apps/desktop/src/features/workspace/firstDropPlan.ts @@ -384,7 +384,10 @@ function resolveSafeFirstDropPlan(song: RehearsalSong): FirstDropPlan | null { const landingRole = pickLandingRole( rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { - if (previousActiveIds.has(metadata.id)) { + if ( + previousActiveIds.has(metadata.id) || + ACCOMPANIMENT_SOURCE_ROLE_IDS.has(metadata.id) + ) { return []; } const dropPlan = ownedDropPlan(metadata.role); From af95ce4752c32155e23ac3a30f5febbb17189d7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:15:23 -0700 Subject: [PATCH 21/43] fix(ci): apply pinned Ruff formatting --- .../src/bandscope_analysis/roles/extractor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 93b67cf59..54d2c4e33 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -396,7 +396,9 @@ def _activity_drop_plan( return None named_entered = { - candidate_id for candidate_id in entered if candidate_id not in _OTHER_STEM_ROLE_IDS + candidate_id + for candidate_id in entered + if candidate_id not in _OTHER_STEM_ROLE_IDS } if named_entered == {role_id}: return _DROP_PLAN_SOLO @@ -560,4 +562,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) \ No newline at end of file + return max(counts, key=lambda c: counts[c]) From bfc35d49f558365f8bf96ba9491ba230aa1e3de8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:21:28 -0700 Subject: [PATCH 22/43] fix(ci): match pinned Ruff set-comprehension layout --- .../src/bandscope_analysis/roles/extractor.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 54d2c4e33..f1ccef7bf 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -381,12 +381,8 @@ def _activity_drop_plan( return None if role_id in _OTHER_STEM_ROLE_IDS: return None - previous_source_ids = { - self._source_id(candidate_id) for candidate_id in previous_active - } - current_source_ids = { - self._source_id(candidate_id) for candidate_id in current_active - } + previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} + current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} if previous_source_ids - current_source_ids: return None entered = current_active - previous_active From ed677cbd09ffacf8c751eeb7f42cff329f84db8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:17:37 -0700 Subject: [PATCH 23/43] fix(ci): restore pinned Ruff drop-plan layout --- .../src/bandscope_analysis/roles/extractor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index f1ccef7bf..93b67cf59 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -381,8 +381,12 @@ def _activity_drop_plan( return None if role_id in _OTHER_STEM_ROLE_IDS: return None - previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} - current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} + previous_source_ids = { + self._source_id(candidate_id) for candidate_id in previous_active + } + current_source_ids = { + self._source_id(candidate_id) for candidate_id in current_active + } if previous_source_ids - current_source_ids: return None entered = current_active - previous_active @@ -392,9 +396,7 @@ def _activity_drop_plan( return None named_entered = { - candidate_id - for candidate_id in entered - if candidate_id not in _OTHER_STEM_ROLE_IDS + candidate_id for candidate_id in entered if candidate_id not in _OTHER_STEM_ROLE_IDS } if named_entered == {role_id}: return _DROP_PLAN_SOLO @@ -558,4 +560,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) + return max(counts, key=lambda c: counts[c]) \ No newline at end of file From bfd8c02c6544e46cde246622cb069de0b4a56907 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:35:03 -0700 Subject: [PATCH 24/43] style(ci): apply Ruff formatting to drop-plan extractor --- .../src/bandscope_analysis/roles/extractor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 93b67cf59..54d2c4e33 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -396,7 +396,9 @@ def _activity_drop_plan( return None named_entered = { - candidate_id for candidate_id in entered if candidate_id not in _OTHER_STEM_ROLE_IDS + candidate_id + for candidate_id in entered + if candidate_id not in _OTHER_STEM_ROLE_IDS } if named_entered == {role_id}: return _DROP_PLAN_SOLO @@ -560,4 +562,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) \ No newline at end of file + return max(counts, key=lambda c: counts[c]) From c0431582cbf38b2f81e54714ac3ca98dda96f91a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:44:50 -0700 Subject: [PATCH 25/43] style(ci): make named drop entries formatter-stable --- .../src/bandscope_analysis/roles/extractor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 54d2c4e33..0d87f7d0c 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -395,11 +395,7 @@ def _activity_drop_plan( if previous_sources < 1 or previous_sources > 2 or current_sources < 3: return None - named_entered = { - candidate_id - for candidate_id in entered - if candidate_id not in _OTHER_STEM_ROLE_IDS - } + named_entered = entered - _OTHER_STEM_ROLE_IDS if named_entered == {role_id}: return _DROP_PLAN_SOLO From 21ec157b2554c30b07ab9dfed1716cf25739a825 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:06:12 -0700 Subject: [PATCH 26/43] test(review): reset drop armed state on display identity changes --- .../workspace/FirstDropPlanCallout.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx index 442357a88..1b1f9e973 100644 --- a/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx @@ -116,6 +116,44 @@ describe("FirstDropPlanCallout", () => { expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); }); + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithDropPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.name = "Lead Singer"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Singer lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Singer together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("resets armed guidance when the section label changes in the same workspace", () => { + const firstSong = songWithDropPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.label = "bridge"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal lands the bridge drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + it("opens the named drop on the rendered map", () => { const { scrollIntoView } = appendSongStructureTarget(); render(); From 0fe3cf781362e04a4bc81ea226c58a80c107b7e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:07:56 -0700 Subject: [PATCH 27/43] fix(review): reset drop armed state on display identity changes --- .../src/features/workspace/FirstDropPlanCallout.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx index adb6068d2..c68aac543 100644 --- a/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx @@ -25,7 +25,9 @@ type OpenedDropPlan = Readonly<{ songIdentity: unknown; sectionId: string; sectionIndex: number; + sectionLabel: string; landingRoleId: string; + landingRoleName: string; dropPlan: string; dropPlanSource: DropPlanSource | null; dropPlanGuidanceKind: DropPlanGuidance["kind"] | null; @@ -106,7 +108,9 @@ export function FirstDropPlanCallout({ song, workspaceInstanceKey }: FirstDropPl songIdentity, named?.sectionIndex, named?.sectionId, + named?.sectionLabel, named?.landingRoleId, + named?.landingRoleName, named?.dropPlan, named?.dropPlanSource, guidanceKind, @@ -134,7 +138,9 @@ export function FirstDropPlanCallout({ song, workspaceInstanceKey }: FirstDropPl openedDropPlan.songIdentity === songIdentity && openedDropPlan.sectionId === named.sectionId && openedDropPlan.sectionIndex === named.sectionIndex && + openedDropPlan.sectionLabel === named.sectionLabel && openedDropPlan.landingRoleId === named.landingRoleId && + openedDropPlan.landingRoleName === named.landingRoleName && openedDropPlan.dropPlan === named.dropPlan && openedDropPlan.dropPlanSource === named.dropPlanSource && openedDropPlan.dropPlanGuidanceKind === guidanceKind && @@ -192,7 +198,9 @@ export function FirstDropPlanCallout({ song, workspaceInstanceKey }: FirstDropPl songIdentity, sectionId: named.sectionId, sectionIndex: named.sectionIndex, + sectionLabel: named.sectionLabel, landingRoleId: named.landingRoleId, + landingRoleName: named.landingRoleName, dropPlan: named.dropPlan, dropPlanSource: named.dropPlanSource, dropPlanGuidanceKind: guidanceKind, From db4d99291d5f26d2c5ac938118e4108247195712 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 10:22:46 -0700 Subject: [PATCH 28/43] fix(ci): apply canonical Ruff formatting --- .../src/bandscope_analysis/roles/extractor.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 0d87f7d0c..8c7a69f35 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -381,12 +381,8 @@ def _activity_drop_plan( return None if role_id in _OTHER_STEM_ROLE_IDS: return None - previous_source_ids = { - self._source_id(candidate_id) for candidate_id in previous_active - } - current_source_ids = { - self._source_id(candidate_id) for candidate_id in current_active - } + previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} + current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} if previous_source_ids - current_source_ids: return None entered = current_active - previous_active @@ -558,4 +554,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) + return max(counts, key=lambda c: counts[c]) \ No newline at end of file From 3c2da0c2ddc604c23aa89c47a1d44032e98b7d6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 10:24:39 -0700 Subject: [PATCH 29/43] fix(ci): preserve formatter-required final newline --- .../analysis-engine/src/bandscope_analysis/roles/extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 8c7a69f35..a64350672 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -554,4 +554,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) \ No newline at end of file + return max(counts, key=lambda c: counts[c]) From b0b425d34921328e094f8e5e4ca53bffd44f9b7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 10:35:49 -0700 Subject: [PATCH 30/43] test(drop-plan): build accompaniment swap fixture safely --- .../workspace/firstDropPlan.source-continuity.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts index 2aa149d97..ece8f2ace 100644 --- a/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts +++ b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts @@ -10,7 +10,9 @@ describe("resolveFirstDropPlan source continuity", () => { const seed = song.sections[0]!; const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); - const guitar = structuredClone(seed.roles.find((role) => role.id === "acoustic-guitar")!); + const guitar = structuredClone(keys); + guitar.id = "acoustic-guitar"; + guitar.name = "Acoustic Guitar"; const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); for (const role of [bass, keys, guitar, vocal]) { From be1449067bcc91028ed96b2ed693bfc9a2a841f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:12:36 -0700 Subject: [PATCH 31/43] test(drop-plan): reject blank sourced guidance --- packages/shared-types/test/dropPlanProvenance.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/shared-types/test/dropPlanProvenance.test.ts b/packages/shared-types/test/dropPlanProvenance.test.ts index 21df18403..9e2f97ae6 100644 --- a/packages/shared-types/test/dropPlanProvenance.test.ts +++ b/packages/shared-types/test/dropPlanProvenance.test.ts @@ -25,4 +25,12 @@ describe("dropPlan provenance", () => { role.dropPlanSource = "model"; expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); }); + + it.each(["", " "])("rejects a drop plan source with blank copy %j", (dropPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = dropPlan; + role.dropPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlan/); + }); }); From 0d5a454d58983a6d1b9fc75572c6d9b833a52f2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:13:20 -0700 Subject: [PATCH 32/43] test(drop-plan): reject blank native guidance --- apps/desktop/core/tests/drop_plan_contract.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index 9f67028f4..8895da24b 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -100,6 +100,20 @@ fn project_contract_rejects_drop_plan_source_without_drop_plan() { ); } +#[test] +fn project_contract_rejects_blank_drop_plan_with_source() { + for drop_plan in ["", " "] { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["dropPlan"] = json!(drop_plan); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject blank sourced drop-plan copy" + ); + } +} + #[test] fn project_contract_rejects_unknown_drop_plan_source() { let mut payload = song_with_drop_plan(); From 86de8980fa7b4fc9e1cc8082a7e8d567cc90e57a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 12:11:41 -0700 Subject: [PATCH 33/43] test(drop-plan): cover multiline sourced guidance --- .../test/dropPlanProvenance.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/shared-types/test/dropPlanProvenance.test.ts b/packages/shared-types/test/dropPlanProvenance.test.ts index 9e2f97ae6..82793dd11 100644 --- a/packages/shared-types/test/dropPlanProvenance.test.ts +++ b/packages/shared-types/test/dropPlanProvenance.test.ts @@ -26,11 +26,14 @@ describe("dropPlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); }); - it.each(["", " "])("rejects a drop plan source with blank copy %j", (dropPlan) => { - const song = createDemoRehearsalSong(); - const role = song.sections[0]!.roles[0]!; - role.dropPlan = dropPlan; - role.dropPlanSource = "model"; - expect(() => parseRehearsalSong(song)).toThrow(/dropPlan/); - }); + it.each(["", " ", "land here\nthen hold", "land here\rthen hold"])( + "rejects a drop plan source with blank or multiline copy %j", + (dropPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = dropPlan; + role.dropPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlan/); + } + ); }); From b8f7889421b39b1ef7391e0df7e918250bb719a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 12:12:13 -0700 Subject: [PATCH 34/43] test(drop-plan): cover multiline native guidance --- apps/desktop/core/tests/drop_plan_contract.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index 8895da24b..cc73a03c1 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -101,15 +101,15 @@ fn project_contract_rejects_drop_plan_source_without_drop_plan() { } #[test] -fn project_contract_rejects_blank_drop_plan_with_source() { - for drop_plan in ["", " "] { +fn project_contract_rejects_invalid_drop_plan_copy_with_source() { + for drop_plan in ["", " ", "land here\nthen hold", "land here\rthen hold"] { let mut payload = song_with_drop_plan(); payload["sections"][0]["roles"][0]["dropPlan"] = json!(drop_plan); let content = serde_json::to_string(&payload).expect("fixture should serialize"); assert!( project_payload_from_content(&content).is_err(), - "native persisted contract must reject blank sourced drop-plan copy" + "native persisted contract must reject blank or multiline sourced drop-plan copy" ); } } From ec12e75d9c73aa844eb7f5fde97761fe4a43e2c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:16:58 -0700 Subject: [PATCH 35/43] fix(drop-plan): reject invalid sourced guidance --- packages/shared-types/src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 2cfc95fae..a888d77dd 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1592,7 +1592,15 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } - if (value.dropPlan !== undefined && typeof value.dropPlan !== "string") { + if ( + value.dropPlan !== undefined && + ( + typeof value.dropPlan !== "string" || + value.dropPlan.trim().length === 0 || + value.dropPlan.includes("\n") || + value.dropPlan.includes("\r") + ) + ) { return invalidField(`${path}.dropPlan`); } if ( From 5a2847b75f6e455099c417f6977fe863bd8d1c8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:21:18 -0700 Subject: [PATCH 36/43] fix(drop-plan): reject invalid native guidance --- apps/desktop/core/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index a64ec3ce8..fd080273d 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -543,6 +543,13 @@ fn validate_drop_plan_provenance( ) -> Result { for section in &payload.sections { for role in §ion.roles { + if role.drop_plan.as_ref().is_some_and(|drop_plan| { + drop_plan.trim().is_empty() + || drop_plan.contains('\n') + || drop_plan.contains('\r') + }) { + return Err("Invalid project file format".to_string()); + } if role.drop_plan.is_none() && role.drop_plan_source.is_some() { return Err("Invalid project file format".to_string()); } From 38800928585595806279b923cce13d3798f93591 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:51:55 -0700 Subject: [PATCH 37/43] test(native): reproduce shared role persistence mismatch --- apps/desktop/core/tests/drop_plan_contract.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index cc73a03c1..51ec72951 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -85,6 +85,47 @@ fn project_contract_round_trips_drop_plan_provenance() { ); } +#[test] +fn project_contract_round_trips_optional_shared_role_fields() { + let mut payload = song_with_drop_plan(); + let role = payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object"); + role.insert( + "harmonicExplanation".into(), + json!("The leading tone resolves into the chorus tonic."), + ); + role.insert( + "transpositionPlan".into(), + json!("Move the line down a whole step if the vocal sits high."), + ); + role.insert( + "transcription".into(), + json!([{ + "pitch": "C#4", + "onset": 30.0, + "offset": 30.5, + "velocity": 96.0 + }]), + ); + role.insert("practiceProgress".into(), json!(75)); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project contract must accept optional shared role fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + let serialized_role = &serialized["sections"][0]["roles"][0]; + + for field in [ + "harmonicExplanation", + "transpositionPlan", + "transcription", + "practiceProgress", + ] { + assert_eq!(serialized_role[field], payload["sections"][0]["roles"][0][field]); + } +} + #[test] fn project_contract_rejects_drop_plan_source_without_drop_plan() { let mut payload = song_with_drop_plan(); From 5ee84d86827ecf0dc8c4300d71b6877105c305e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:43:01 -0700 Subject: [PATCH 38/43] fix(native): preserve optional shared role fields --- apps/desktop/core/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index fd080273d..8eb07e837 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,15 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] enum DropPlanSourcePayload { @@ -190,15 +199,23 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + practice_progress: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] drop_plan: Option, #[serde(default, skip_serializing_if = "Option::is_none")] drop_plan_source: Option, From 59efc24c3bba7773a463621a0bb8d9cd8a911b1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:47:40 -0700 Subject: [PATCH 39/43] test(native): bound shared practice progress --- apps/desktop/core/tests/drop_plan_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index 51ec72951..7225b09e5 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -126,6 +126,18 @@ fn project_contract_round_trips_optional_shared_role_fields() { } } +#[test] +fn project_contract_rejects_practice_progress_outside_shared_range() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["practiceProgress"] = json!(101); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must enforce the shared 0..=100 practice-progress range" + ); +} + #[test] fn project_contract_rejects_drop_plan_source_without_drop_plan() { let mut payload = song_with_drop_plan(); From 28a8579671bc5a24bc1ce91814d84cb9bec7f372 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:50:21 -0700 Subject: [PATCH 40/43] fix(native): enforce shared practice progress range --- apps/desktop/core/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 8eb07e837..c25f17de4 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -560,6 +560,9 @@ fn validate_drop_plan_provenance( ) -> Result { for section in &payload.sections { for role in §ion.roles { + if role.practice_progress.is_some_and(|progress| progress > 100) { + return Err("Invalid project file format".to_string()); + } if role.drop_plan.as_ref().is_some_and(|drop_plan| { drop_plan.trim().is_empty() || drop_plan.contains('\n') From 4ff8e1d9411371d5d854a0abb743d077ddb5c2f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:18:50 -0700 Subject: [PATCH 41/43] test(review): reject drop copy without provenance --- packages/shared-types/test/dropPlanProvenance.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/shared-types/test/dropPlanProvenance.test.ts b/packages/shared-types/test/dropPlanProvenance.test.ts index 82793dd11..6f055dc8d 100644 --- a/packages/shared-types/test/dropPlanProvenance.test.ts +++ b/packages/shared-types/test/dropPlanProvenance.test.ts @@ -26,6 +26,14 @@ describe("dropPlan provenance", () => { expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); }); + it("rejects drop plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + delete role.dropPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); + it.each(["", " ", "land here\nthen hold", "land here\rthen hold"])( "rejects a drop plan source with blank or multiline copy %j", (dropPlan) => { From ab8c90a22e806c43e94e5f8d1129d24f86e99618 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:19:18 -0700 Subject: [PATCH 42/43] test(review): reject native drop copy without provenance --- apps/desktop/core/tests/drop_plan_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/core/tests/drop_plan_contract.rs b/apps/desktop/core/tests/drop_plan_contract.rs index 7225b09e5..997fd8497 100644 --- a/apps/desktop/core/tests/drop_plan_contract.rs +++ b/apps/desktop/core/tests/drop_plan_contract.rs @@ -153,6 +153,21 @@ fn project_contract_rejects_drop_plan_source_without_drop_plan() { ); } +#[test] +fn project_contract_rejects_drop_plan_without_source() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("dropPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject drop-plan copy without provenance" + ); +} + #[test] fn project_contract_rejects_invalid_drop_plan_copy_with_source() { for drop_plan in ["", " ", "land here\nthen hold", "land here\rthen hold"] { From eb7432c124ccf667eca8db0656ed09273e51e5c7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 16:46:49 +0900 Subject: [PATCH 43/43] fix(contract): require provenance for plan copy --- apps/desktop/core/src/lib.rs | 3 +++ packages/shared-types/src/index.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index c25f17de4..523a5fa6e 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -573,6 +573,9 @@ fn validate_drop_plan_provenance( if role.drop_plan.is_none() && role.drop_plan_source.is_some() { return Err("Invalid project file format".to_string()); } + if role.drop_plan.is_some() && role.drop_plan_source.is_none() { + return Err("Invalid project file format".to_string()); + } } } Ok(payload) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index a888d77dd..139931371 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1612,6 +1612,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.dropPlanSource !== undefined && value.dropPlan === undefined) { return invalidField(`${path}.dropPlanSource`); } + if (value.dropPlan !== undefined && value.dropPlanSource === undefined) { + return invalidField(`${path}.dropPlanSource`); + } return null; }