diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..e9e50574b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## 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. +- 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. After a named part is selected, the ready workspace names that part's first trusted lyric, count, or transition cue as the next entrance. - 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..0f837fa4a 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 + - the selected part's first trusted lyric, count, or transition cue as the next entrance - 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..9f092604b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After a named part is selected, name that part's first trusted lyric, count, or transition cue and tell the player to catch it before the entrance. - 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..ac7ab6ba3 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 ready workspace names tonight's first playable range and the next instrument check. After a named part is selected, it also names that part's first trusted lyric, count, or transition cue as the next entrance. `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/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index 575684c50..3a7dbe730 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx @@ -72,4 +72,27 @@ describe("RoleSwitcher", () => { expect(tabValueToRoleId("role:unknown-role", roles)).toBeNull(); expect(tabValueToRoleId("raw-unknown-role", roles)).toBeNull(); }); + + it("clears an active role that is absent from the current song role allowlist", () => { + const onRoleChange = vi.fn(); + const { rerender } = render( + + ); + + expect(onRoleChange).not.toHaveBeenCalled(); + + rerender( + + ); + + expect(onRoleChange).toHaveBeenCalledWith(null); + }); }); diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index f5275964d..f37292438 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Users } from "lucide-react"; @@ -40,6 +41,12 @@ export function tabValueToRoleId(value: string, roles: RehearsalRoleOption[]): s export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) { const t = createTranslator(detectPreferredLocale()); + useEffect(() => { + if (activeRole !== null && !roles.some((role) => role.id === activeRole)) { + onRoleChange(null); + } + }, [activeRole, onRoleChange, roles]); + return (
diff --git a/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx new file mode 100644 index 000000000..2ca8a0e5c --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace selected-part confirmed chord", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("stays hidden until a part with a room-confirmed chord is selected", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull(); + }); + + it("names the selected part's confirmed chord and the next lock-in action", () => { + setNavigatorLanguage("en-US"); + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const callout = screen.getByTestId("selected-part-confirmed-chord"); + expect(callout).toHaveTextContent("Tonight's confirmed chord"); + expect(callout).toHaveTextContent( + "Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse." + ); + }); + + it("keeps Korean copy particle-safe for arbitrary chord symbols", () => { + setNavigatorLanguage("ko-KR"); + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("selected-part-confirmed-chord")).toHaveTextContent( + "verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx new file mode 100644 index 000000000..f37ce1fa1 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace selected-part entrance cue", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("hides the entrance cue until a named part is selected", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull(); + }); + + it("names the selected bass part's transition as the next entrance", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-entrance-cue"); + expect(callout).toHaveTextContent("Tonight's entrance cue"); + expect(callout).toHaveTextContent( + "Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat." + ); + }); + + it("names the selected vocal lyric as the next entrance", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( + 'Listen for "city lights" in verse, then Lead Vocal enters.' + ); + }); + + it("keeps Korean copy particle-safe for a Latin role name", () => { + setNavigatorLanguage("ko-KR"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-entrance-cue"); + expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐"); + expect(callout).toHaveTextContent("Bass Guitar 파트"); + expect(callout).not.toHaveTextContent("Bass Guitar으로"); + expect(callout).toHaveTextContent( + "verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat." + ); + }); + + it("tells the player to confirm a missing cue instead of hiding the next action", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + cue: { kind: "transition", value: "none" } + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( + "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance." + ); + }); + + it("clears selected-part guidance when the next project no longer contains the selected role", () => { + setNavigatorLanguage("en-US"); + const firstSong = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toBeInTheDocument(); + + const nextSong = createDemoRehearsalSong(); + nextSong.id = "replacement-project"; + nextSong.sections = nextSong.sections.map((section) => ({ + ...section, + roles: section.roles.filter((role) => role.id !== "bass-guitar") + })); + + rerender(); + + expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull(); + expect(screen.queryByTestId("selected-part-first-pass")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx new file mode 100644 index 000000000..8dee9a185 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace selected-part first-pass take", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("hides the first-pass take until a named part is selected", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByTestId("selected-part-first-pass")).toBeNull(); + }); + + it("names the selected bass part's simpler take as the first pass", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-first-pass"); + expect(callout).toHaveTextContent("Tonight's first-pass take"); + expect(callout).toHaveTextContent( + "First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest." + ); + }); + + it("names the selected vocal part's simpler take as the first pass", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent( + "First pass for Lead Vocal in verse: Keep the sustained note centered; skip the ad-lib on the first pass. Play that simpler take before adding the rest." + ); + }); + + it("keeps Korean copy particle-safe for a Latin role name", () => { + setNavigatorLanguage("ko-KR"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-first-pass"); + expect(callout).toHaveTextContent("오늘 이 파트의 첫 간소화"); + expect(callout).toHaveTextContent("Bass Guitar 파트"); + expect(callout).not.toHaveTextContent("Bass Guitar으로"); + expect(callout).toHaveTextContent( + "verse에서 Bass Guitar 파트의 첫 패스: Stay on roots if the chorus entrance gets muddy. 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요." + ); + }); + + it("tells the player to confirm a missing first-pass take instead of hiding the next action", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + simplification: "none" + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent( + "This part still needs a trusted first-pass take. Confirm the simpler version before the first run." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..4f726ed30 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,7 +5,10 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { fillEntranceCueCopy, firstEntranceCue } from "./firstEntranceCue"; +import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification"; +import { fillConfirmedChordCopy, selectedPartConfirmedChord } from "./selectedPartConfirmedChord"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; @@ -118,6 +121,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R ); }); +/** Documented. */ +function entranceCueCopyKey(kind: "lyric" | "count" | "transition"): TranslationKey { + if (kind === "lyric") { + return "workspaceSelectedEntranceCueLyric"; + } + if (kind === "count") { + return "workspaceSelectedEntranceCueCount"; + } + return "workspaceSelectedEntranceCueTransition"; +} + /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); @@ -163,6 +177,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const confirmedChord = useMemo( + () => selectedPartConfirmedChord(song, activeRole), + [activeRole, song] + ); + const confirmedChordCopy = confirmedChord + ? fillConfirmedChordCopy(t("workspaceConfirmedChordLock"), { + roleName: confirmedChord.roleName, + chord: confirmedChord.chord, + sectionLabel: confirmedChord.sectionLabel + }) + : null; + const selectedEntranceCue = useMemo( + () => (activeRole ? firstEntranceCue(song, activeRole) : null), + [activeRole, song] + ); + const selectedEntranceCueCopy = + selectedEntranceCue?.status === "ready" + ? fillEntranceCueCopy(t(entranceCueCopyKey(selectedEntranceCue.kind)), { + roleName: selectedEntranceCue.roleName, + sectionLabel: selectedEntranceCue.sectionLabel, + value: selectedEntranceCue.value + }) + : t("workspaceSelectedEntranceCueUnavailable"); + const selectedFirstPass = useMemo( + () => (activeRole ? firstPassSimplification(song, activeRole) : null), + [activeRole, song] + ); + const selectedFirstPassCopy = + selectedFirstPass?.status === "ready" + ? fillFirstPassCopy(t("workspaceSelectedFirstPassReady"), { + roleName: selectedFirstPass.roleName, + sectionLabel: selectedFirstPass.sectionLabel, + value: selectedFirstPass.value + }) + : t("workspaceSelectedFirstPassUnavailable"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +359,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ {confirmedChordCopy ? ( +
+

{t("workspaceConfirmedChordTitle")}

+

{confirmedChordCopy}

+
+ ) : null} +

{t("workspaceSongTimelineLabel")}

@@ -372,6 +432,26 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

Stem Player

{activeRoleDetails?.name ?? activeRole}

+
+

+ {t("workspaceSelectedEntranceCueTitle")} +

+

{selectedEntranceCueCopy}

+
+
+

+ {t("workspaceSelectedFirstPassTitle")} +

+

{selectedFirstPassCopy}

+
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstEntranceCue.test.ts b/apps/desktop/src/features/workspace/firstEntranceCue.test.ts new file mode 100644 index 000000000..4bb08b045 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntranceCue.test.ts @@ -0,0 +1,209 @@ +import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillEntranceCueCopy, firstEntranceCue, isAdmittedCueKind } from "./firstEntranceCue"; + +/** Return the demo song with one role replaced in every section it appears. */ +function withRolePatch( + song: RehearsalSong, + roleId: string, + patch: (role: RehearsalRole) => RehearsalRole +): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => (role.id === roleId ? patch(role) : role)) + })) + }; +} + +describe("isAdmittedCueKind", () => { + it("admits only lyric, count, and transition", () => { + expect(isAdmittedCueKind("lyric")).toBe(true); + expect(isAdmittedCueKind("count")).toBe(true); + expect(isAdmittedCueKind("transition")).toBe(true); + expect(isAdmittedCueKind("groove")).toBe(false); + expect(isAdmittedCueKind("")).toBe(false); + expect(isAdmittedCueKind(null)).toBe(false); + }); +}); + +describe("firstEntranceCue", () => { + it("names the selected bass part's first transition cue", () => { + expect(firstEntranceCue(createDemoRehearsalSong(), "bass-guitar")).toEqual({ + status: "ready", + kind: "transition", + value: "Hold through the pickup before the downbeat.", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("names the selected keys part's first count cue", () => { + expect(firstEntranceCue(createDemoRehearsalSong(), "keys-right")).toEqual({ + status: "ready", + kind: "count", + value: "Enter on beat 2 after the pickup.", + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand" + }); + }); + + it("names the selected vocal part's first lyric cue", () => { + expect(firstEntranceCue(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + status: "ready", + kind: "lyric", + value: "city lights", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("fails closed without a selected named part", () => { + expect(firstEntranceCue(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" }); + expect(firstEntranceCue(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" }); + expect(firstEntranceCue(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" }); + }); + + it("fails closed on blank, none, or unknown cue values and kinds", () => { + const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({ + ...role, + cue: { kind: "transition", value: " " } + })); + const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({ + ...role, + cue: { kind: "lyric", value: "none" } + })); + const unknownKind = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => ({ + ...role, + cue: { kind: "groove" as RehearsalRole["cue"]["kind"], value: "on the one" } + })); + + expect(firstEntranceCue(blank, "bass-guitar")).toEqual({ status: "unavailable" }); + expect(firstEntranceCue(none, "lead-vocal")).toEqual({ status: "unavailable" }); + expect(firstEntranceCue(unknownKind, "keys-right")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when cue is inherited instead of owned", () => { + const song = createDemoRehearsalSong(); + const role = { ...song.sections[0]!.roles[0]! }; + const { cue: _dropped, ...withoutCue } = role; + void _dropped; + Object.setPrototypeOf(withoutCue, { cue: { kind: "lyric", value: "sneaky lyric" } }); + song.sections[0] = { + ...song.sections[0]!, + roles: [withoutCue as RehearsalRole, ...song.sections[0]!.roles.slice(1)] + }; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when cue kind is inherited", () => { + const song = createDemoRehearsalSong(); + const inheritedKind = Object.create({ kind: "lyric" }) as RehearsalRole["cue"]; + inheritedKind.value = "city lights"; + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + cue: inheritedKind + }; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed on a malformed song root", () => { + expect(firstEntranceCue(null, "bass-guitar")).toEqual({ status: "unavailable" }); + expect(firstEntranceCue({ title: "no sections" }, "bass-guitar")).toEqual({ + status: "unavailable" + }); + }); + + it("fails closed on duplicate role ids in one section", () => { + const song = createDemoRehearsalSong(); + song.sections[0] = { + ...song.sections[0]!, + roles: [...song.sections[0]!.roles, { ...song.sections[0]!.roles[0]! }] + }; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when the same selected id uses two display names", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + verse, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => + role.id === "bass-guitar" ? { ...role, name: "Electric Bass" } : role + ) + } + ]; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("skips a non-canonical section label instead of showing it as the entrance", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, label: "drop-D intro" as RehearsalSong["sections"][number]["label"] }, + { ...verse, id: "chorus-1", label: "chorus" } + ]; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ + status: "ready", + kind: "transition", + value: "Hold through the pickup before the downbeat.", + sectionLabel: "chorus", + roleName: "Bass Guitar" + }); + }); + + it("does not skip an untrusted first canonical cue to a later section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + roles: verse.roles.map((role) => + role.id === "bass-guitar" ? { ...role, cue: { kind: "transition", value: "none" } } : role + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => + role.id === "bass-guitar" + ? { ...role, cue: { kind: "transition", value: "Catch the chorus lift." } } + : role + ) + } + ]; + + expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed for an unknown selected role", () => { + expect(firstEntranceCue(createDemoRehearsalSong(), "missing-role")).toEqual({ + status: "unavailable" + }); + }); +}); + +describe("fillEntranceCueCopy", () => { + it("fills owned tokens and leaves inherited members literal", () => { + expect( + fillEntranceCueCopy("Listen for \"{value}\" in {sectionLabel}, then {roleName} enters.", { + value: "city lights", + sectionLabel: "verse", + roleName: "Lead Vocal" + }) + ).toBe('Listen for "city lights" in verse, then Lead Vocal enters.'); + + expect(fillEntranceCueCopy("keep {toString}", {})).toBe("keep {toString}"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntranceCue.ts b/apps/desktop/src/features/workspace/firstEntranceCue.ts new file mode 100644 index 000000000..0c293e470 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntranceCue.ts @@ -0,0 +1,171 @@ +import { SECTION_FORM_LABELS, type CueAnchorKind, type RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Admitted lyric, count, or transition entrance for a selected part. */ +export type AdmittedCueKind = CueAnchorKind; + +/** Tonight's first trusted entrance cue for a selected named part. */ +export type FirstEntranceCue = + | { + status: "ready"; + kind: AdmittedCueKind; + value: string; + sectionLabel: string; + roleName: string; + } + | { status: "unavailable" }; + +type SelectedRoleCopy = { + sectionLabel: string; + roleName: string; + cue: { kind: AdmittedCueKind; value: string } | null; +}; + +const ADMITTED_CUE_KINDS = new Set(["lyric", "count", "transition"]); +const CANONICAL_SECTION_LABELS = new Set(SECTION_FORM_LABELS); + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Return whether a record owns a field rather than inheriting it. */ +function owns(record: Record, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +/** Return whether a cue kind is one of the three rehearsal entrance kinds. */ +export function isAdmittedCueKind(value: unknown): value is AdmittedCueKind { + return typeof value === "string" && ADMITTED_CUE_KINDS.has(value as AdmittedCueKind); +} + +/** Admit one own-property cue without granting inherited members authority. */ +function admitEntranceCue( + roleValue: Record +): { kind: AdmittedCueKind; value: string } | null { + if (!owns(roleValue, "cue") || !isRuntimeObject(roleValue.cue)) { + return null; + } + + const cueValue = roleValue.cue; + if (!owns(cueValue, "kind") || !owns(cueValue, "value") || !isAdmittedCueKind(cueValue.kind)) { + return null; + } + + const value = meaningfulRangeText(cueValue.value); + if (!value) { + return null; + } + + return { kind: cueValue.kind, value }; +} + +/** + * Name the selected part's first trusted entrance cue. + * + * Lyric, count, and transition cues are the rehearsal entrance the player + * should catch before that part comes in. This is selected-part guidance, not + * the song-wide first-lyric, first-count, or first-transition map products, + * and it is not Active Player or MIR work. Inherited cue fields, unknown + * kinds, blank or `none` values, unnamed roles, duplicate ids in one section, + * conflicting display names, and non-canonical section labels fail closed + * instead of becoming entrance authority. + */ +export function firstEntranceCue( + song: RehearsalSong | unknown, + activeRole: string | null +): FirstEntranceCue { + const selectedRoleId = meaningfulRangeText(activeRole); + if (!selectedRoleId || !isRuntimeObject(song) || !owns(song, "sections") || !Array.isArray(song.sections)) { + return { status: "unavailable" }; + } + + const copies: SelectedRoleCopy[] = []; + let knownName: string | undefined; + + for (const sectionValue of song.sections) { + if ( + !isRuntimeObject(sectionValue) || + !owns(sectionValue, "label") || + !owns(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return { status: "unavailable" }; + } + + const rawLabel = meaningfulRangeText(sectionValue.label); + if (!rawLabel) { + return { status: "unavailable" }; + } + + const sectionRoleIds = new Set(); + let selectedCopy: Record | null = null; + + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !owns(roleValue, "id") || !owns(roleValue, "name")) { + return { status: "unavailable" }; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName) { + return { status: "unavailable" }; + } + if (sectionRoleIds.has(roleId)) { + return { status: "unavailable" }; + } + sectionRoleIds.add(roleId); + + if (roleId !== selectedRoleId) { + continue; + } + if (selectedCopy) { + return { status: "unavailable" }; + } + selectedCopy = roleValue; + } + + if (!selectedCopy) { + continue; + } + + const roleName = meaningfulRangeText(selectedCopy.name); + if (!roleName) { + return { status: "unavailable" }; + } + if (knownName && knownName !== roleName) { + return { status: "unavailable" }; + } + knownName = roleName; + + if (!CANONICAL_SECTION_LABELS.has(rawLabel)) { + continue; + } + + copies.push({ + sectionLabel: rawLabel, + roleName, + cue: admitEntranceCue(selectedCopy) + }); + } + + for (const copy of copies) { + if (!copy.cue) { + return { status: "unavailable" }; + } + return { + status: "ready", + kind: copy.cue.kind, + value: copy.cue.value, + sectionLabel: copy.sectionLabel, + roleName: copy.roleName + }; + } + + return { status: "unavailable" }; +} + +/** Fill trusted `{token}` placeholders for entrance-cue copy. */ +export function fillEntranceCueCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/features/workspace/firstPassSimplification.test.ts b/apps/desktop/src/features/workspace/firstPassSimplification.test.ts new file mode 100644 index 000000000..909cd2fab --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPassSimplification.test.ts @@ -0,0 +1,212 @@ +import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification"; + +/** Return the demo song with one role replaced in every section it appears. */ +function withRolePatch( + song: RehearsalSong, + roleId: string, + patch: (role: RehearsalRole) => RehearsalRole +): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => (role.id === roleId ? patch(role) : role)) + })) + }; +} + +describe("firstPassSimplification", () => { + it("names the selected bass part's first-pass take", () => { + expect(firstPassSimplification(createDemoRehearsalSong(), "bass-guitar")).toEqual({ + status: "ready", + value: "Stay on roots if the chorus entrance gets muddy.", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("names the selected keys part's first-pass take", () => { + expect(firstPassSimplification(createDemoRehearsalSong(), "keys-right")).toEqual({ + status: "ready", + value: "Drop the top extension if the chorus turnaround still feels busy.", + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand" + }); + }); + + it("names the selected vocal part's first-pass take", () => { + expect(firstPassSimplification(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + status: "ready", + value: "Keep the sustained note centered; skip the ad-lib on the first pass.", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("fails closed without a selected named part", () => { + expect(firstPassSimplification(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" }); + expect(firstPassSimplification(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" }); + expect(firstPassSimplification(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" }); + }); + + it("fails closed on blank, none, or missing simplification values", () => { + const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({ + ...role, + simplification: " " + })); + const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({ + ...role, + simplification: "none" + })); + const missing = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => { + const { simplification: _dropped, ...withoutSimplification } = role; + void _dropped; + return withoutSimplification as RehearsalRole; + }); + + expect(firstPassSimplification(blank, "bass-guitar")).toEqual({ status: "unavailable" }); + expect(firstPassSimplification(none, "lead-vocal")).toEqual({ status: "unavailable" }); + expect(firstPassSimplification(missing, "keys-right")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when simplification is inherited instead of owned", () => { + const song = createDemoRehearsalSong(); + const role = { ...song.sections[0]!.roles[0]! }; + const { simplification: _dropped, ...withoutSimplification } = role; + void _dropped; + Object.setPrototypeOf(withoutSimplification, { simplification: "sneaky roots only" }); + song.sections[0] = { + ...song.sections[0]!, + roles: [withoutSimplification as RehearsalRole, ...song.sections[0]!.roles.slice(1)] + }; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed on a malformed song root", () => { + expect(firstPassSimplification(null, "bass-guitar")).toEqual({ status: "unavailable" }); + expect(firstPassSimplification({ title: "no sections" }, "bass-guitar")).toEqual({ + status: "unavailable" + }); + }); + + it("fails closed on a malformed section member", () => { + const song = createDemoRehearsalSong(); + song.sections = [null as unknown as RehearsalSong["sections"][number], ...song.sections]; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when a section omits roles or a role omits identity", () => { + const missingRoles = createDemoRehearsalSong(); + const { roles: _droppedRoles, ...sectionWithoutRoles } = missingRoles.sections[0]!; + void _droppedRoles; + missingRoles.sections[0] = sectionWithoutRoles as RehearsalSong["sections"][number]; + + const missingRoleIdentity = createDemoRehearsalSong(); + const { id: _droppedId, ...roleWithoutId } = missingRoleIdentity.sections[0]!.roles[0]!; + void _droppedId; + missingRoleIdentity.sections[0] = { + ...missingRoleIdentity.sections[0]!, + roles: [roleWithoutId as RehearsalRole, ...missingRoleIdentity.sections[0]!.roles.slice(1)] + }; + + expect(firstPassSimplification(missingRoles, "bass-guitar")).toEqual({ status: "unavailable" }); + expect(firstPassSimplification(missingRoleIdentity, "bass-guitar")).toEqual({ + status: "unavailable" + }); + }); + + it("fails closed on duplicate role ids in one section", () => { + const song = createDemoRehearsalSong(); + song.sections[0] = { + ...song.sections[0]!, + roles: [...song.sections[0]!.roles, { ...song.sections[0]!.roles[0]! }] + }; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed when the same selected id uses two display names", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + verse, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => + role.id === "bass-guitar" ? { ...role, name: "Electric Bass" } : role + ) + } + ]; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("skips a non-canonical section label instead of showing it as the first pass", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, label: "drop-D intro" as RehearsalSong["sections"][number]["label"] }, + { ...verse, id: "chorus-1", label: "chorus" } + ]; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ + status: "ready", + value: "Stay on roots if the chorus entrance gets muddy.", + sectionLabel: "chorus", + roleName: "Bass Guitar" + }); + }); + + it("does not skip an untrusted first canonical take to a later section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + roles: verse.roles.map((role) => + role.id === "bass-guitar" ? { ...role, simplification: "none" } : role + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + roles: verse.roles.map((role) => + role.id === "bass-guitar" + ? { ...role, simplification: "Hold roots through the chorus lift." } + : role + ) + } + ]; + + expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); + }); + + it("fails closed for an unknown selected role", () => { + expect(firstPassSimplification(createDemoRehearsalSong(), "missing-role")).toEqual({ + status: "unavailable" + }); + }); +}); + +describe("fillFirstPassCopy", () => { + it("fills owned tokens and leaves inherited members literal", () => { + expect( + fillFirstPassCopy("First pass for {roleName} in {sectionLabel}: {value} Play that simpler take before adding the rest.", { + roleName: "Bass Guitar", + sectionLabel: "verse", + value: "Stay on roots if the chorus entrance gets muddy." + }) + ).toBe( + "First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest." + ); + + expect(fillFirstPassCopy("keep {toString}", {})).toBe("keep {toString}"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPassSimplification.ts b/apps/desktop/src/features/workspace/firstPassSimplification.ts new file mode 100644 index 000000000..c8937c591 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPassSimplification.ts @@ -0,0 +1,148 @@ +import { SECTION_FORM_LABELS, type RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first trusted first-pass take for a selected named part. */ +export type FirstPassSimplification = + | { + status: "ready"; + value: string; + sectionLabel: string; + roleName: string; + } + | { status: "unavailable" }; + +type SelectedRoleCopy = { + sectionLabel: string; + roleName: string; + simplification: string | null; +}; + +const CANONICAL_SECTION_LABELS = new Set(SECTION_FORM_LABELS); + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Return whether a record owns a field rather than inheriting it. */ +function owns(record: Record, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +/** Admit one own-property simplification without granting inherited members authority. */ +function admitSimplification(roleValue: Record): string | null { + if (!owns(roleValue, "simplification")) { + return null; + } + + return meaningfulRangeText(roleValue.simplification) ?? null; +} + +/** + * Name the selected part's first trusted first-pass take. + * + * Simplification is the rehearsal instruction the player should play before + * adding the rest of the part. This is selected-part guidance, not the + * song-wide first-simpler-take map product, and it is not Active Player or + * MIR work. Inherited simplification fields, blank or `none` values, unnamed + * roles, duplicate ids in one section, conflicting display names, and + * non-canonical section labels fail closed instead of becoming first-pass + * authority. + */ +export function firstPassSimplification( + song: RehearsalSong | unknown, + activeRole: string | null +): FirstPassSimplification { + const selectedRoleId = meaningfulRangeText(activeRole); + if (!selectedRoleId || !isRuntimeObject(song) || !owns(song, "sections") || !Array.isArray(song.sections)) { + return { status: "unavailable" }; + } + + const copies: SelectedRoleCopy[] = []; + let knownName: string | undefined; + + for (const sectionValue of song.sections) { + if ( + !isRuntimeObject(sectionValue) || + !owns(sectionValue, "label") || + !owns(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return { status: "unavailable" }; + } + + const rawLabel = meaningfulRangeText(sectionValue.label); + if (!rawLabel) { + return { status: "unavailable" }; + } + + const sectionRoleIds = new Set(); + let selectedCopy: Record | null = null; + + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !owns(roleValue, "id") || !owns(roleValue, "name")) { + return { status: "unavailable" }; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName) { + return { status: "unavailable" }; + } + if (sectionRoleIds.has(roleId)) { + return { status: "unavailable" }; + } + sectionRoleIds.add(roleId); + + if (roleId !== selectedRoleId) { + continue; + } + if (selectedCopy) { + return { status: "unavailable" }; + } + selectedCopy = roleValue; + } + + if (!selectedCopy) { + continue; + } + + const roleName = meaningfulRangeText(selectedCopy.name); + if (!roleName) { + return { status: "unavailable" }; + } + if (knownName && knownName !== roleName) { + return { status: "unavailable" }; + } + knownName = roleName; + + if (!CANONICAL_SECTION_LABELS.has(rawLabel)) { + continue; + } + + copies.push({ + sectionLabel: rawLabel, + roleName, + simplification: admitSimplification(selectedCopy) + }); + } + + for (const copy of copies) { + if (!copy.simplification) { + return { status: "unavailable" }; + } + return { + status: "ready", + value: copy.simplification, + sectionLabel: copy.sectionLabel, + roleName: copy.roleName + }; + } + + return { status: "unavailable" }; +} + +/** Fill trusted `{token}` placeholders for first-pass copy. */ +export function fillFirstPassCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts new file mode 100644 index 000000000..3e3c1f21e --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { selectedPartConfirmedChord } from "./selectedPartConfirmedChord"; + +describe("selectedPartConfirmedChord conflicting overrides", () => { + it("fails closed when one selected role has two different user-confirmed harmony chords", () => { + const song = createDemoRehearsalSong(); + const leadVocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + leadVocal.manualOverrides = [ + { + field: "harmony", + value: { chord: "C#m11", functionLabel: "room confirmation", source: "user" }, + source: "user" + }, + { + field: "harmony", + value: { chord: "Bmaj7", functionLabel: "conflicting confirmation", source: "user" }, + source: "user" + } + ]; + + expect(selectedPartConfirmedChord(song, "lead-vocal")).toBeNull(); + }); + + it("accepts repeated copies of the same user-confirmed chord", () => { + const song = createDemoRehearsalSong(); + const leadVocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + leadVocal.manualOverrides = [ + ...(leadVocal.manualOverrides ?? []), + ...(leadVocal.manualOverrides ?? []) + ]; + + expect(selectedPartConfirmedChord(song, "lead-vocal")).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal", + chord: "C#m11" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts new file mode 100644 index 000000000..c4f83c3c0 --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts @@ -0,0 +1,185 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillConfirmedChordCopy, + selectedPartConfirmedChord +} from "./selectedPartConfirmedChord"; + +function withSelectedOverride( + song: RehearsalSong, + roleId: string, + chord: string | null, + extras: Partial = {} +): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => { + if (role.id !== roleId) { + return role; + } + return { + ...role, + ...extras, + manualOverrides: + chord === null + ? [] + : [ + { + field: "harmony" as const, + value: { + chord, + functionLabel: "user confirmed", + source: "user" as const + }, + source: "user" as const + } + ] + }; + }) + })) + }; +} + +describe("selectedPartConfirmedChord", () => { + it("names the selected part's first own user harmony override", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal", + chord: "C#m11" + }); + }); + + it("stays hidden until a named part is selected", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), null)).toBeNull(); + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), " ")).toBeNull(); + }); + + it("stays hidden when the selected part has no trusted override", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), "bass-guitar")).toBeNull(); + expect( + selectedPartConfirmedChord(withSelectedOverride(createDemoRehearsalSong(), "bass-guitar", "none"), "bass-guitar") + ).toBeNull(); + }); + + it("skips inherited, model, and non-harmony overrides", () => { + const song = createDemoRehearsalSong(); + const bass = song.sections[0]!.roles[0]!; + const inherited = Object.create({ + manualOverrides: [ + { + field: "harmony", + value: { chord: "G", functionLabel: "inherited", source: "user" }, + source: "user" + } + ] + }) as typeof bass; + Object.assign(inherited, { ...bass, manualOverrides: undefined }); + delete (inherited as { manualOverrides?: unknown }).manualOverrides; + song.sections[0]!.roles[0] = inherited; + + expect(selectedPartConfirmedChord(song, "bass-guitar")).toBeNull(); + + const modelOnly = withSelectedOverride(createDemoRehearsalSong(), "bass-guitar", "E3"); + modelOnly.sections[0]!.roles[0] = { + ...modelOnly.sections[0]!.roles[0]!, + manualOverrides: [ + { + field: "harmony", + value: { + chord: "Gmaj7", + functionLabel: "model leftover", + source: "model" + }, + source: "model" + } + ] + }; + + expect(selectedPartConfirmedChord(modelOnly, "bass-guitar")).toBeNull(); + }); + + it("fails closed on conflicting role copies and sparse collections", () => { + const conflict = createDemoRehearsalSong(); + conflict.sections.push({ + ...conflict.sections[0]!, + id: "verse-2", + roles: conflict.sections[0]!.roles.map((role) => + role.id === "lead-vocal" ? { ...role, name: "Lead Vox" } : role + ) + }); + expect(selectedPartConfirmedChord(conflict, "lead-vocal")).toBeNull(); + + const chordConflict = createDemoRehearsalSong(); + chordConflict.sections.push({ + ...chordConflict.sections[0]!, + id: "chorus-1", + label: "chorus", + roles: chordConflict.sections[0]!.roles.map((role) => + role.id === "lead-vocal" + ? { + ...role, + manualOverrides: [ + { + field: "harmony" as const, + value: { + chord: "Bmaj7", + functionLabel: "other copy", + source: "user" as const + }, + source: "user" as const + } + ] + } + : role + ) + }); + expect(selectedPartConfirmedChord(chordConflict, "lead-vocal")).toBeNull(); + + const sparse = createDemoRehearsalSong() as unknown as { sections: unknown[] }; + sparse.sections = []; + sparse.sections[1] = createDemoRehearsalSong().sections[0]; + expect(selectedPartConfirmedChord(sparse as unknown as RehearsalSong, "lead-vocal")).toBeNull(); + }); + + it("fails closed on malformed roots, traps, and non-canonical labels", () => { + expect(selectedPartConfirmedChord(null as unknown as RehearsalSong, "lead-vocal")).toBeNull(); + expect(selectedPartConfirmedChord({} as RehearsalSong, "lead-vocal")).toBeNull(); + + const trap = new Proxy(createDemoRehearsalSong(), { + has() { + throw new Error("has trap"); + }, + get(target, property, receiver) { + if (property === "sections") { + throw new Error("get trap"); + } + return Reflect.get(target, property, receiver); + } + }); + expect(selectedPartConfirmedChord(trap, "lead-vocal")).toBeNull(); + + const unknownLabel = createDemoRehearsalSong(); + unknownLabel.sections[0] = { ...unknownLabel.sections[0]!, label: "vibe-check" as typeof unknownLabel.sections[0]["label"] }; + expect(selectedPartConfirmedChord(unknownLabel, "lead-vocal")).toBeNull(); + }); +}); + +describe("fillConfirmedChordCopy", () => { + it("keeps placeholder-shaped chords literal", () => { + expect( + fillConfirmedChordCopy("{roleName} locks {chord} before {sectionLabel}.", { + roleName: "Lead Vocal", + chord: "C#m11 {sectionLabel}", + sectionLabel: "verse" + }) + ).toBe("Lead Vocal locks C#m11 {sectionLabel} before verse."); + }); + + it("does not satisfy tokens with inherited object members", () => { + expect(fillConfirmedChordCopy("Use {toString} in {missingToken}.", { chord: "C#m11" })).toBe( + "Use {toString} in {missingToken}." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts new file mode 100644 index 000000000..45f71543a --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts @@ -0,0 +1,170 @@ +import { SECTION_FORM_LABELS, type RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Room-confirmed chord a selected part should lock before the section. */ +export type SelectedPartConfirmedChord = { + sectionLabel: string; + roleName: string; + chord: string; +}; + +const CANONICAL_SECTION_LABELS = new Set(SECTION_FORM_LABELS); + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read an own data property and contain throwing membership or getter traps. */ +function ownValue(record: object, key: string): unknown { + try { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return undefined; + } + return (record as Record)[key]; + } catch { + return undefined; + } +} + +/** Admit a dense array or fail closed on holes and non-arrays. */ +function denseArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + return null; + } + } + + return value; +} + +/** Pull one unambiguous trusted user harmony chord from own override records. */ +function ownHarmonyOverrideChord(roleValue: object): string | null | undefined { + const overrides = denseArray(ownValue(roleValue, "manualOverrides")); + if (!overrides) { + return undefined; + } + + let foundChord: string | undefined; + for (const item of overrides) { + if (!isRuntimeObject(item)) { + continue; + } + if (ownValue(item, "field") !== "harmony") { + continue; + } + if (ownValue(item, "source") !== "user") { + continue; + } + + const overrideValue = ownValue(item, "value"); + if (!isRuntimeObject(overrideValue) || ownValue(overrideValue, "source") !== "user") { + continue; + } + + const chord = meaningfulRangeText(ownValue(overrideValue, "chord")); + if (!chord) { + continue; + } + if (foundChord && foundChord !== chord) { + return null; + } + foundChord = chord; + } + + return foundChord; +} + +/** + * Pick the selected part's first room-confirmed harmony chord. + * + * Hidden until a named part is selected. Only own user harmony overrides + * become buyer-visible chord authority. Conflicting role copies, inherited + * prototypes, sparse collections, and non-canonical section labels fail + * closed instead of inventing a rehearsal chord. + */ +export function selectedPartConfirmedChord( + song: RehearsalSong, + activeRole: string | null +): SelectedPartConfirmedChord | null { + const selectedRoleId = meaningfulRangeText(activeRole); + if (!selectedRoleId) { + return null; + } + + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong)) { + return null; + } + + const sections = denseArray(ownValue(runtimeSong, "sections")); + if (!sections) { + return null; + } + + let found: SelectedPartConfirmedChord | null = null; + let seenName: string | undefined; + + for (const sectionValue of sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + + const sectionLabel = meaningfulRangeText(ownValue(sectionValue, "label")); + if (!sectionLabel || !CANONICAL_SECTION_LABELS.has(sectionLabel)) { + continue; + } + + const roles = denseArray(ownValue(sectionValue, "roles")); + if (!roles) { + continue; + } + + for (const roleValue of roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + + const roleId = meaningfulRangeText(ownValue(roleValue, "id")); + const roleName = meaningfulRangeText(ownValue(roleValue, "name")); + if (!roleId || !roleName || roleId !== selectedRoleId) { + continue; + } + + if (seenName && seenName !== roleName) { + return null; + } + seenName = roleName; + + const chord = ownHarmonyOverrideChord(roleValue); + if (chord === null) { + return null; + } + if (!chord) { + continue; + } + + if (found && found.chord !== chord) { + return null; + } + + if (!found) { + found = { sectionLabel, roleName, chord }; + } + } + } + + return found; +} + +/** Fill trusted `{token}` placeholders for confirmed-chord copy. */ +export function fillConfirmedChordCopy( + template: string, + values: Record +): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..ea2101d7f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,16 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "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.", + "workspaceConfirmedChordTitle": "Tonight's confirmed chord", + "workspaceConfirmedChordLock": "{roleName} uses the room's {chord} in {sectionLabel}. Lock that chord before the {sectionLabel}.", + "workspaceSelectedEntranceCueTitle": "Tonight's entrance cue", + "workspaceSelectedEntranceCueLyric": "Listen for \"{value}\" in {sectionLabel}, then {roleName} enters.", + "workspaceSelectedEntranceCueCount": "Count this in {sectionLabel} before {roleName} enters: {value}", + "workspaceSelectedEntranceCueTransition": "Catch this transition in {sectionLabel} before {roleName} enters: {value}", + "workspaceSelectedEntranceCueUnavailable": "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance.", + "workspaceSelectedFirstPassTitle": "Tonight's first-pass take", + "workspaceSelectedFirstPassReady": "First pass for {roleName} in {sectionLabel}: {value} Play that simpler take before adding the rest.", + "workspaceSelectedFirstPassUnavailable": "This part still needs a trusted first-pass take. Confirm the simpler version before the first run.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..779507c51 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,16 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceConfirmedChordTitle": "오늘 방이 확인한 코드", + "workspaceConfirmedChordLock": "{sectionLabel}의 {roleName} 파트는 방이 확인한 {chord} 코드로 맞춥니다. {sectionLabel} 전에 그 코드를 고정하세요.", + "workspaceSelectedEntranceCueTitle": "오늘 이 파트의 첫 입장 큐", + "workspaceSelectedEntranceCueLyric": "{sectionLabel}에서 \"{value}\"를 듣고 {roleName} 파트로 들어오세요.", + "workspaceSelectedEntranceCueCount": "{sectionLabel}에서 이 카운트를 센 다음 {roleName} 파트로 들어오세요: {value}", + "workspaceSelectedEntranceCueTransition": "{sectionLabel}에서 이 전환을 잡고 {roleName} 파트로 들어오세요: {value}", + "workspaceSelectedEntranceCueUnavailable": "이 파트의 입장 큐를 아직 믿을 수 없습니다. 가사·카운트·전환을 확인한 다음 들어오세요.", + "workspaceSelectedFirstPassTitle": "오늘 이 파트의 첫 간소화", + "workspaceSelectedFirstPassReady": "{sectionLabel}에서 {roleName} 파트의 첫 패스: {value} 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요.", + "workspaceSelectedFirstPassUnavailable": "이 파트에는 아직 신뢰할 수 있는 첫 패스 간소화가 없습니다. 첫 연습 전에 더 단순한 버전을 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..0b4152f22 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -31,6 +31,9 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | +| Selected Part Entrance Cue | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/firstEntranceCue.ts` | Feature-local selected-part callout. Show only after Role Switcher selection; copy must name the next entrance. | +| Selected Part First-Pass Take | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/firstPassSimplification.ts` | Feature-local selected-part callout. Show only after Role Switcher selection; copy must name the simpler take to play first. | +| Selected Part Confirmed Chord | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts` | Feature-local selected-part callout. Show only when the selected part has a trusted user harmony override; copy must name the room-confirmed chord and lock-in action. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | diff --git a/docs/doctoring/selected-part-confirmed-chord.md b/docs/doctoring/selected-part-confirmed-chord.md new file mode 100644 index 000000000..ed7100c5d --- /dev/null +++ b/docs/doctoring/selected-part-confirmed-chord.md @@ -0,0 +1,37 @@ +# Selected-part confirmed chord + +## Product decision + +After a named part is selected, the ready rehearsal workspace names that part's first trusted user harmony override and tells the player to lock the room-confirmed chord before the section. The callout stays hidden until a part is selected and stays hidden when the part has no trusted override. + +This is selected-part confirmed-chord guidance only. It does not replace: + +- song-wide first confirmed chord ownership (`#1002`) +- selected-part entrance/first-pass guidance owned by the canonical `#1150` vertical +- setup-before-entrance (`#910`) +- Active Player (`#961`) +- MIR / known-stem ownership (`#828` / `#770`) + +## Buyer-visible next action + +- Lead Vocal: **Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse.** +- Bass Guitar / Keyboard: no callout, because those demo parts have no user harmony override. +- Korean copy avoids attaching a case particle directly to arbitrary chord notation: **verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요.** + +## Trust boundary + +- Untrusted input: in-memory project `manualOverrides`, role identity, section labels, and chord strings. +- Own-property admission only. Inherited `manualOverrides`, throwing `has`/`get` traps, sparse arrays, and non-object members fail closed. +- Only `field: "harmony"` overrides with `source: "user"` and a non-blank, non-`none` chord become buyer copy. +- Every valid user harmony override on the selected role is inspected. Repeated copies of the same chord are harmless; two different admitted chords on the same role are ambiguous and fail closed instead of choosing by array order. +- Only shared `SECTION_FORM_LABELS` become localization authority. +- Duplicate selected-role ids with conflicting display names or conflicting override chords across section copies fail closed. +- `fillConfirmedChordCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source, and placeholder-shaped chords stay literal. + +## Security Notes + +- Attack surface: rehearsal workspace UI copy from in-memory analysis output. No new file, URL, subprocess, IPC, WebView, model, credential, or export path. +- Trust boundary: browser/React state → selector → translated callout. +- Safe failure: missing selection, missing override, malformed runtime evidence, and conflicting same-role or cross-section copies hide the callout instead of inventing a chord. +- Privacy: chord symbols and role names remain rehearsal display data already present in the project; nothing is logged or exported by this slice. +- Test points: demo Lead Vocal override, hidden-until-selected, missing/`none` overrides, inherited/model overrides, conflicting copies, same-role conflicting user overrides, duplicate identical user overrides, sparse collections, getter traps, non-canonical labels, literal placeholder-shaped chords, and particle-safe Korean chord copy. diff --git a/docs/doctoring/selected-part-entrance-cue.md b/docs/doctoring/selected-part-entrance-cue.md new file mode 100644 index 000000000..5d486f67d --- /dev/null +++ b/docs/doctoring/selected-part-entrance-cue.md @@ -0,0 +1,77 @@ +# Selected-part entrance cue + +## Decision + +After a named part is selected, the ready rehearsal workspace names that part's first trusted `cue` as the next entrance action: + +- **lyric** — listen for the lyric, then enter; +- **count** — count the cue, then enter; +- **transition** — catch the transition, then enter. + +When the selected part has no trusted cue, the callout still names the next action: confirm the lyric, count, or transition before the first entrance. The callout stays hidden until a part is selected so this lane does not become a song-wide first-lyric, first-count, or first-transition product. + +This is not Active Player ownership (`#961`) and not MIR ownership (`#828` / `#770`). + +## Ordering and selection invariants + +The project contract treats `sections` array order as timeline order. `firstEntranceCue` therefore scans in array order rather than independently sorting by `timeRange.start`; ingestion/migration code that reorders sections must preserve chronological array order. If that invariant changes, the domain contract and tests must change together rather than silently choosing a different entrance. + +The selected role is also project-scoped. When a replacement project no longer contains the previously selected role id, `RoleSwitcher` clears the selection through the current role allowlist so entrance, first-pass, and related selected-part guidance do not survive as stale UI state. + +## Own-property admission + +`cue.kind` and `cue.value` are untrusted project fields. Workspace copy may only name an entrance from own-property evidence: + +- `cue` must be an owned object; +- `kind` must be an owned `lyric`, `count`, or `transition` string; +- `value` must be meaningful text (not blank or `none`); +- section labels must belong to shared `SECTION_FORM_LABELS`; +- unnamed roles, duplicate ids in one section, or the same id with two display names fail closed. + +The helper never logs cue text, role names, or project paths. + +## Security Notes + +### Attack surface + +Untrusted local project JSON can carry `sections[].roles[].cue`. A prototype-inherited `cue` or `kind`, an unknown kind, or a `none` sentinel must not become entrance authority or be interpolated into bilingual copy. + +### Trust boundary + +Admission is lexical and own-property only. The helper reads in-memory song objects already loaded by the desktop shell. It does not open files, resolve paths, call IPC, or export bytes. Display names interpolated into copy are the same named-role strings already shown in the Role Switcher. + +### Mitigations + +- `Object.prototype.hasOwnProperty.call` before reading `cue`, `kind`, `value`, `id`, `name`, `label`, and `roles`. +- `meaningfulRangeText` rejects blank and `none` sentinel values. +- Unknown kinds and non-canonical section labels never become buyer-visible localization authority. +- Conflicting section copies of the same named part return `unavailable`; Workspace still tells the player to confirm the entrance instead of guessing. +- A malformed role anywhere in the admitted section list causes the selector to fail closed. Parsed production projects exclude malformed roles, so this is a defensive integrity boundary rather than a normal user-visible fallback. +- Locale templates keep `{roleName}` / `{sectionLabel}` / `{value}` placeholders; `fillRangeCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source. +- Korean copy uses `{roleName} 파트` so a Latin role label cannot produce `Bass Guitar으로`. + +### Test points + +- Helper: lyric/count/transition; missing selection; blank/`none`/unknown kind; inherited cue and kind; duplicate ids; conflicting names; non-canonical labels; first untrusted canonical copy is not skipped. +- Workspace: hidden until a part is selected; bass transition copy; vocal lyric copy; Korean particle-safe Latin role; unavailable copy when the cue is `none`; replacement project clears a selected role that is absent from the new project. +- Role switcher: a stale active role outside the current rendered role allowlist is cleared to the all-roles state. + +### Realistic threats + +A crafted project that puts `cue` on `Object.prototype` or labels a section `drop-D intro` could otherwise tell a player to enter on hostile text or interpolate unexpected function source into the callout. + +### Remaining risk + +Cue text is already shown on the Section Roadmap card. This callout reuses the same admitted strings for the selected part only and does not persist a new field. + +## Verification + +Run: + +```bash +npm --workspace @bandscope/desktop exec vitest run \ + src/features/workspace/RoleSwitcher.test.tsx \ + src/features/workspace/firstEntranceCue.test.ts \ + src/features/workspace/Workspace.entrance-cue.test.tsx \ + src/features/workspace/Workspace.test.tsx +``` diff --git a/docs/doctoring/selected-part-first-pass.md b/docs/doctoring/selected-part-first-pass.md new file mode 100644 index 000000000..8fa5f90c1 --- /dev/null +++ b/docs/doctoring/selected-part-first-pass.md @@ -0,0 +1,63 @@ +# Selected-part first-pass take + +## Decision + +After a named part is selected, the ready rehearsal workspace names that part's first trusted `simplification` as the next first-pass action: play the simpler take before adding the rest. + +When the selected part has no trusted simplification, the callout still names the next action: confirm the simpler version before the first run. The callout stays hidden until a part is selected so this lane does not become a song-wide first-simpler-take product. + +This is not Active Player ownership (`#961`) and not MIR ownership (`#828` / `#770`). + +## Own-property admission + +`simplification` is an untrusted project field. Workspace copy may only name a first-pass take from own-property evidence: + +- `simplification` must be an owned string; +- the value must be meaningful text (not blank or `none`); +- section labels must belong to shared `SECTION_FORM_LABELS`; +- unnamed roles, duplicate ids in one section, or the same id with two display names fail closed. + +The helper never logs simplification text, role names, or project paths. + +## Security Notes + +### Attack surface + +Untrusted local project JSON can carry `sections[].roles[].simplification`. A prototype-inherited `simplification` or a `none` sentinel must not become first-pass authority or be interpolated into bilingual copy. + +### Trust boundary + +Admission is lexical and own-property only. The helper reads in-memory song objects already loaded by the desktop shell. It does not open files, resolve paths, call IPC, or export bytes. Display names interpolated into copy are the same named-role strings already shown in the Role Switcher. + +### Mitigations + +- `Object.prototype.hasOwnProperty.call` before reading `simplification`, `id`, `name`, `label`, and `roles`. +- `meaningfulRangeText` rejects blank and `none` sentinel values. +- Non-canonical section labels never become buyer-visible localization authority. +- Conflicting section copies of the same named part return `unavailable`; Workspace still tells the player to confirm the first-pass take instead of guessing. +- Locale templates keep `{roleName}` / `{sectionLabel}` / `{value}` placeholders; `fillRangeCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source. +- Korean copy uses `{roleName} 파트` so a Latin role label cannot produce `Bass Guitar으로`. + +### Test points + +- Helper: bass/keys/vocal takes; missing selection; blank/`none`/missing field; inherited simplification; duplicate ids; conflicting names; non-canonical labels; first untrusted canonical copy is not skipped. +- Workspace: hidden until a part is selected; bass first-pass copy; vocal first-pass copy; Korean particle-safe Latin role; unavailable copy when the take is `none`. + +### Realistic threats + +A crafted project that puts `simplification` on `Object.prototype` or labels a section `drop-D intro` could otherwise tell a player to play hostile text or interpolate unexpected function source into the callout. + +### Remaining risk + +Simplification text is already shown on the Section Roadmap card. This callout reuses the same admitted strings for the selected part only and does not persist a new field. + +## Verification + +Run: + +```bash +npm --workspace @bandscope/desktop exec vitest run \ + src/features/workspace/firstPassSimplification.test.ts \ + src/features/workspace/Workspace.first-pass.test.tsx \ + src/features/workspace/Workspace.test.tsx +```