From f35dcfc8a9351e2e41b4c5bc22b177c0f4273e9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 04:27:28 +0000 Subject: [PATCH] feat(workspace): name tonight's first new dropout on the map After leftover parts return, the ready rehearsal map names who newly sits out so the room can count that part out, or the newly sitting-out part can stay out, from the renderer-owned landing. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 184 ++++++ .../src/features/workspace/Workspace.tsx | 66 ++- .../firstNewDropout.selected-role.test.ts | 122 ++++ .../workspace/firstNewDropout.test.ts | 553 ++++++++++++++++++ .../src/features/workspace/firstNewDropout.ts | 336 +++++++++++ apps/desktop/src/i18n/index.test.ts | 1 + apps/desktop/src/locales/en/common.json | 6 + apps/desktop/src/locales/ko/common.json | 6 + apps/desktop/vite.config.ts | 3 +- docs/design-system/component-contract.md | 1 + docs/doctoring/first-new-dropout.md | 28 + 15 files changed, 1307 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstNewDropout.selected-role.test.ts create mode 100644 apps/desktop/src/features/workspace/firstNewDropout.test.ts create mode 100644 apps/desktop/src/features/workspace/firstNewDropout.ts create mode 100644 docs/doctoring/first-new-dropout.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ce630a0ea 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, new-dropout cues after a leftover return, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. - 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..11fecc9a6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - 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 + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and tonight's first new dropout after a leftover return - 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..255c87f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first new dropout on the ready rehearsal map and tell the leftover-return parts to count that part out, or the newly sitting-out part to stay out, after leftover parts return. - 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..31c4342a1 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 tonight's first new dropout after a leftover return. `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/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..b4381fe67 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,190 @@ describe("Workspace", () => { ); }); + it("names tonight's first new dropout after a leftover return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || node.role_id === "keys-right" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 } + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + } + ]; + + render(); + + const callout = screen.getByTestId("first-new-dropout"); + expect(callout).toHaveTextContent("Tonight's first new dropout"); + expect(callout).toHaveTextContent( + "Lead Vocal newly sits out at bridge after chorus comes back from verse. Count Lead Vocal out from the top of bridge." + ); + expect(screen.getByRole("button", { name: "Open" })).toBeTruthy(); + const landing = screen.getByTestId("song-structure-grid").querySelector("[data-section-index='2']"); + expect(landing).toBeTruthy(); + expect(landing?.getAttribute("id")).toBeNull(); + }); + + it("tells the new dropout to stay out after the leftover return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || node.role_id === "keys-right" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 } + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-new-dropout")).toHaveTextContent( + "Lead Vocal newly sits out at bridge after chorus comes back from verse. Stay out from the top of bridge." + ); + }); + + it("opens the named new-dropout landing with reduced-motion auto scrolling", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || node.role_id === "keys-right" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 } + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + } + ]; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + const matchMedia = vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ + matches: query.includes("reduce"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest", + inline: "nearest" + }); + }); + + it("asks the player to confirm the new dropout when leftover evidence is missing", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + } + ]; + + render(); + + expect(screen.getByTestId("first-new-dropout")).toHaveTextContent( + "Tonight's first new dropout still needs a named part that newly sits out after a leftover return. Confirm who newly sits out after that return before the first section." + ); + expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); + }); + + it("says no new dropout is needed when every named part stays active", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-new-dropout")).toHaveTextContent( + "No new dropout is needed: every named part stays active after leftover parts return. Rehearse from the first section without a new sit-out cue." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..c5358c89e 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { firstNewDropout, hasTrustworthyAllActiveTimeline } from "./firstNewDropout"; 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)}

@@ -163,6 +168,44 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const namedNewDropout = useMemo(() => firstNewDropout(song, activeRole), [activeRole, song]); + const noNewDropoutNeeded = useMemo(() => hasTrustworthyAllActiveTimeline(song), [song]); + const firstNewDropoutCopy = namedNewDropout + ? fillRangeCopy( + t( + activeRole && activeRole === namedNewDropout.dropoutRoleId + ? "workspaceFirstNewDropoutStayOut" + : "workspaceFirstNewDropoutNamed" + ), + { + dropoutRoleName: namedNewDropout.dropoutRoleName, + sectionLabel: namedNewDropout.sectionLabel, + returnSectionLabel: namedNewDropout.returnSectionLabel, + fromSectionLabel: namedNewDropout.fromSectionLabel + } + ) + : t(noNewDropoutNeeded ? "workspaceFirstNewDropoutNone" : "workspaceFirstNewDropoutMissing"); + + /** Open the named new-dropout landing on the renderer-owned map section. */ + const openNamedNewDropout = () => { + if (!namedNewDropout) { + return; + } + const grid = document.querySelector("[data-testid='song-structure-grid']"); + if (!(grid instanceof HTMLElement)) { + return; + } + const landing = grid.querySelector(`[data-section-index="${namedNewDropout.sectionIndex}"]`); + if (!(landing instanceof HTMLElement)) { + return; + } + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + landing.scrollIntoView({ + behavior: reduceMotion ? "auto" : "smooth", + block: "nearest", + inline: "nearest" + }); + }; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +352,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstNewDropoutTitle")}

+

{firstNewDropoutCopy}

+ {namedNewDropout ? ( + + ) : null} +
diff --git a/apps/desktop/src/features/workspace/firstNewDropout.selected-role.test.ts b/apps/desktop/src/features/workspace/firstNewDropout.selected-role.test.ts new file mode 100644 index 000000000..cfb35c63c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstNewDropout.selected-role.test.ts @@ -0,0 +1,122 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstNewDropout } from "./firstNewDropout"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: RehearsalSong["sections"][number]["label"], + start: number, + inactiveRoleIds: readonly string[] +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + return { + ...template, + id, + label, + timeRange: { start, end: start + 20 }, + partGraph: template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })) + }; +} + +describe("firstNewDropout selected-role search", () => { + it("keeps searching after the selected part newly drops out during an earlier leftover sit-out", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right", "lead-vocal"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["lead-vocal"]) + ] + }; + + expect(firstNewDropout(song, "lead-vocal")).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "bridge", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + }); + + it("does not tell a leftover sit-out to stay out as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right", "lead-vocal"]) + ] + }; + + expect(firstNewDropout(song, "lead-vocal")).toBeNull(); + expect(firstNewDropout(song, "keys-right")).toBeNull(); + }); + + it("does not show another part's new-dropout cue to a selected leftover that never returned", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right", "lead-vocal"]) + ] + }; + + expect(firstNewDropout(song, "keys-right")).toBeNull(); + }); + + it("does not show another part's new-dropout cue to a selected part that stayed active through an earlier leftover sit-out only", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const selectedRole = { + ...template.roles[0]!, + id: "always-active", + name: "Always Active" + }; + const selectedTemplate: RehearsalSong["sections"][number] = { + ...template, + roles: [...template.roles, selectedRole], + partGraph: [ + ...template.partGraph, + { + ...template.partGraph[0]!, + role_id: "always-active", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(selectedTemplate, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(selectedTemplate, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(selectedTemplate, "bridge-1", "bridge", 40, ["lead-vocal"]) + ] + }; + + expect(firstNewDropout(song, "always-active")).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstNewDropout.test.ts b/apps/desktop/src/features/workspace/firstNewDropout.test.ts new file mode 100644 index 000000000..3e1b5a163 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstNewDropout.test.ts @@ -0,0 +1,553 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstNewDropout, hasTrustworthyAllActiveTimeline } from "./firstNewDropout"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: string, + start: number, + inactiveRoleIds: readonly string[], + activeOnlyRoles = false +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + const partGraph = template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })); + return { + ...template, + id, + label: label as RehearsalSong["sections"][number]["label"], + timeRange: { start, end: start + 20 }, + partGraph, + roles: activeOnlyRoles + ? template.roles.filter((role) => !inactive.has(role.id)) + : template.roles + }; +} + +function leftoverThenFullReturnThenDropout( + dropoutRoleId = "lead-vocal", + originalSitOutRoleIds: readonly string[] = ["bass-guitar", "keys-right"] +): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + return { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, originalSitOutRoleIds), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [dropoutRoleId]) + ] + }; +} + +function fullReturnWithSameSectionDropout(): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + return { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["lead-vocal"]) + ] + }; +} + +describe("hasTrustworthyAllActiveTimeline", () => { + it("accepts the demo song where every graph node is active", () => { + expect(hasTrustworthyAllActiveTimeline(createDemoRehearsalSong())).toBe(true); + }); + + it("rejects malformed roots and a later sit-out", () => { + expect(hasTrustworthyAllActiveTimeline(null)).toBe(false); + expect(hasTrustworthyAllActiveTimeline({})).toBe(false); + expect(hasTrustworthyAllActiveTimeline(leftoverThenFullReturnThenDropout())).toBe(false); + const unlabeled = createDemoRehearsalSong(); + unlabeled.sections[0] = { + ...unlabeled.sections[0]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(hasTrustworthyAllActiveTimeline(unlabeled)).toBe(false); + const emptyGraph = createDemoRehearsalSong(); + emptyGraph.sections[0] = { ...emptyGraph.sections[0]!, partGraph: [] }; + expect(hasTrustworthyAllActiveTimeline(emptyGraph)).toBe(false); + }); +}); + +describe("firstNewDropout", () => { + it("returns null on the demo song where every graph node is active", () => { + expect(firstNewDropout(createDemoRehearsalSong())).toBeNull(); + }); + + it("names the new dropout after a leftover return", () => { + expect(firstNewDropout(leftoverThenFullReturnThenDropout())).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + }); + + it("names a new dropout in the leftover-return section itself", () => { + expect(firstNewDropout(fullReturnWithSameSectionDropout())).toEqual({ + sectionLabel: "chorus", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 1 + }); + }); + + it("uses song-wide role names when inactive analysis roles are omitted from section roles", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "opening-1", "opening", 0, []), + sectionWithInactiveRoles(template, "verse-1", "verse", 20, ["bass-guitar", "keys-right"], true), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 40, [], true), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 60, ["lead-vocal"], true) + ] + }; + + expect(firstNewDropout(song)).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 3 + }); + }); + + it("treats repeated form labels as distinct timeline sections", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[2] = { + ...song.sections[2]!, + label: song.sections[1]!.label + }; + + expect(firstNewDropout(song)).toEqual({ + sectionLabel: "chorus", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + }); + + it("skips a leftover sit-out until leftover parts fully return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right", "lead-vocal"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("names a new dropout that started during a leftover sit-out once leftover parts return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right", "lead-vocal"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]) + ] + }; + + expect(firstNewDropout(song)).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "bridge", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + }); + + it("does not treat a leftover sit-out as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("does not treat a remaining leftover as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right", "lead-vocal"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("does not treat a come-in without a leftover return as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("does not treat a continued sit-out with nobody returning as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right", "lead-vocal"]) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("does not treat a tutti leftover return without a later sit-out as a new dropout", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstNewDropout(song)).toBeNull(); + }); + + it("skips an all-active section after leftover return until a named new dropout exists", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 40, []), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["lead-vocal"]) + ] + }; + + expect(firstNewDropout(song)).toEqual({ + sectionLabel: "outro", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 3 + }); + }); + + it("keeps the selected new dropout and returning leftover on tonight's first new dropout", () => { + const song = leftoverThenFullReturnThenDropout(); + expect(firstNewDropout(song, "lead-vocal")).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + expect(firstNewDropout(song, "keys-right")).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "lead-vocal", + dropoutRoleName: "Lead Vocal", + sectionIndex: 2 + }); + expect(firstNewDropout(song, "missing-role")).toBeNull(); + }); + + it("picks the selected new dropout when several parts newly sit out", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right", "lead-vocal"]) + ] + }; + + expect(firstNewDropout(song)).toEqual({ + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse", + dropoutRoleId: "keys-right", + dropoutRoleName: "Keyboard 1 Right Hand", + sectionIndex: 2 + }); + expect(firstNewDropout(song, "lead-vocal")?.dropoutRoleId).toBe("lead-vocal"); + }); + + it("ignores inherited is_active evidence", () => { + const song = leftoverThenFullReturnThenDropout(); + const inherited = Object.create({ + is_active: false, + role_id: "keys-right" + }) as RehearsalSong["sections"][number]["partGraph"][number]; + song.sections[0] = { + ...song.sections[0]!, + partGraph: [inherited, ...song.sections[0]!.partGraph] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("does not treat a missing is_active flag as new-dropout evidence", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[2] = { + ...song.sections[2]!, + partGraph: song.sections[2]!.partGraph.map((node) => { + if (node.role_id !== "lead-vocal") { + return node; + } + const rest: Record = { + role_id: node.role_id, + handoff_to: node.handoff_to, + handoff_from: node.handoff_from + }; + return rest as RehearsalSong["sections"][number]["partGraph"][number]; + }) + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on an own is_active accessor", () => { + const song = leftoverThenFullReturnThenDropout(); + const lead = song.sections[2]!.partGraph.find((node) => node.role_id === "lead-vocal")!; + const accessorNode: Record = { + role_id: lead.role_id, + handoff_to: lead.handoff_to, + handoff_from: lead.handoff_from + }; + Object.defineProperty(accessorNode, "is_active", { + enumerable: true, + get: () => false + }); + song.sections[2] = { + ...song.sections[2]!, + partGraph: song.sections[2]!.partGraph.map((node) => + node.role_id === "lead-vocal" + ? (accessorNode as RehearsalSong["sections"][number]["partGraph"][number]) + : node + ) + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed when a Proxy get-trap supplies is_active", () => { + const song = leftoverThenFullReturnThenDropout(); + const lead = song.sections[2]!.partGraph.find((node) => node.role_id === "lead-vocal")!; + const target = { + role_id: lead.role_id, + handoff_to: lead.handoff_to, + handoff_from: lead.handoff_from + }; + const proxied = new Proxy(target, { + get(record, property, receiver) { + if (property === "is_active") { + return false; + } + return Reflect.get(record, property, receiver); + } + }); + song.sections[2] = { + ...song.sections[2]!, + partGraph: song.sections[2]!.partGraph.map((node) => + node.role_id === "lead-vocal" + ? (proxied as RehearsalSong["sections"][number]["partGraph"][number]) + : node + ) + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on a sparse partGraph", () => { + const song = leftoverThenFullReturnThenDropout(); + const sparse: RehearsalSong["sections"][number]["partGraph"] = []; + sparse[0] = song.sections[0]!.partGraph[0]!; + sparse[2] = song.sections[0]!.partGraph[2]!; + song.sections[0] = { + ...song.sections[0]!, + partGraph: sparse + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on contradictory duplicate graph identities", () => { + const song = leftoverThenFullReturnThenDropout(); + const section = song.sections[2]!; + const leadNode = section.partGraph.find((node) => node.role_id === "lead-vocal")!; + const withoutLead = section.partGraph.filter((node) => node.role_id !== "lead-vocal"); + song.sections[2] = { + ...section, + partGraph: [...withoutLead, { ...leadNode, is_active: true }, { ...leadNode, is_active: false }] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on contradictory role names across sections", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[1] = { + ...song.sections[1]!, + roles: song.sections[1]!.roles.map((role) => + role.id === "lead-vocal" ? { ...role, name: "Other Vocal" } : role + ) + }; + expect(firstNewDropout(song)).toBeNull(); + expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); + }); + + it("fails closed on duplicate role identities in one section", () => { + const song = leftoverThenFullReturnThenDropout(); + const verse = song.sections[0]!; + song.sections[0] = { + ...verse, + roles: [...verse.roles, { ...verse.roles[0]! }] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed when no named roles exist", () => { + expect( + firstNewDropout({ + sections: [{ label: "verse", roles: [], partGraph: [] }] + } as unknown as RehearsalSong) + ).toBeNull(); + expect( + hasTrustworthyAllActiveTimeline({ + sections: [{ label: "verse", roles: [], partGraph: [] }] + }) + ).toBe(false); + }); + + it("skips blank leftover-return labels until a named new dropout exists", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[2] = { + ...song.sections[2]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed when a later section has no named graph", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[2] = { + ...song.sections[2]!, + partGraph: [] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect(firstNewDropout(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); + + it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[2] = { + ...song.sections[2]!, + partGraph: song.sections[2]!.partGraph.map((node) => + node.role_id === "lead-vocal" + ? { ...node, is_active: 0 as unknown as boolean } + : node + ) + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on a sparse roles list", () => { + const song = leftoverThenFullReturnThenDropout(); + const sparse: RehearsalSong["sections"][number]["roles"] = []; + sparse[0] = song.sections[0]!.roles[0]!; + sparse[2] = song.sections[0]!.roles[2]!; + song.sections[0] = { ...song.sections[0]!, roles: sparse }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed when a role is missing own identity fields", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[0] = { + ...song.sections[0]!, + roles: [{ name: "Bass Guitar" } as RehearsalSong["sections"][number]["roles"][number]] + }; + expect(firstNewDropout(song)).toBeNull(); + }); + + it("fails closed on blank role names", () => { + const song = leftoverThenFullReturnThenDropout(); + song.sections[0] = { + ...song.sections[0]!, + roles: song.sections[0]!.roles.map((role) => ({ ...role, name: " " })) + }; + expect(firstNewDropout(song)).toBeNull(); + }); +}); + +describe("new-dropout copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy( + "{dropoutRoleName} newly sits out at {sectionLabel} after {returnSectionLabel} comes back from {fromSectionLabel}.", + { + dropoutRoleName: "Lead Vocal {sectionLabel}", + sectionLabel: "bridge", + returnSectionLabel: "chorus", + fromSectionLabel: "verse" + } + ) + ).toBe("Lead Vocal {sectionLabel} newly sits out at bridge after chorus comes back from verse."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstNewDropout.ts b/apps/desktop/src/features/workspace/firstNewDropout.ts new file mode 100644 index 000000000..afa43f7a8 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstNewDropout.ts @@ -0,0 +1,336 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named new dropout after a leftover return. */ +export type FirstNewDropout = { + sectionLabel: string; + returnSectionLabel: string; + fromSectionLabel: string; + dropoutRoleId: string; + dropoutRoleName: string; + sectionIndex: number; +}; + +/** 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); +} + +/** + * Admit an own data-property boolean `is_active` flag. + * + * Inherited members, own accessors, and Proxy get-traps cannot substitute + * buyer-visible sit-out evidence. + */ +function ownActiveFlag(value: Record): boolean | null { + const descriptor = Object.getOwnPropertyDescriptor(value, "is_active"); + if (!descriptor || typeof descriptor.get === "function" || typeof descriptor.set === "function") { + return null; + } + if (descriptor.value === true) { + return true; + } + if (descriptor.value === false) { + return false; + } + return null; +} + +/** Reject sparse arrays so a hole cannot stand in for a named graph node. */ +function denseOwnArray(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; +} + +type NamedRoleCatalog = Map; + +/** + * Build trustworthy role identity evidence across the whole song. + * + * Production analysis emits active-only section `roles` while keeping inactive + * identities in `partGraph`. The song-wide catalog therefore lets a newly + * sitting-out part keep its trustworthy display name after leftover parts return. + */ +function namedSongRoles(songValue: Record): NamedRoleCatalog | null { + const sections = denseOwnArray(songValue.sections); + if (!sections) { + return null; + } + + const namedRoles: NamedRoleCatalog = new Map(); + for (const sectionValue of sections) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const roles = denseOwnArray(sectionValue.roles); + if (!roles) { + return null; + } + + const sectionRoleIds = new Set(); + for (const roleValue of roles) { + if ( + !isRuntimeObject(roleValue) || + !Object.prototype.hasOwnProperty.call(roleValue, "id") || + !Object.prototype.hasOwnProperty.call(roleValue, "name") + ) { + return null; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName || sectionRoleIds.has(roleId)) { + return null; + } + sectionRoleIds.add(roleId); + + const knownName = namedRoles.get(roleId); + if (knownName && knownName !== roleName) { + return null; + } + namedRoles.set(roleId, roleName); + } + } + + return namedRoles.size > 0 ? namedRoles : null; +} + +type NamedGraphNode = { + roleId: string; + active: boolean; +}; + +/** + * Collect one complete, unique activity record for every song-wide named role. + * + * Missing, unknown, duplicate, inherited, accessor, Proxy, sparse, or + * non-boolean graph evidence fails closed so a leftover part cannot also + * count as tonight's new dropout in the same section. + */ +function namedGraphNodes( + sectionValue: Record, + namedRoles: NamedRoleCatalog +): NamedGraphNode[] | null { + const partGraph = denseOwnArray(sectionValue.partGraph); + if (!partGraph) { + return null; + } + + const nodes: NamedGraphNode[] = []; + const seenRoleIds = new Set(); + for (const nodeValue of partGraph) { + if ( + !isRuntimeObject(nodeValue) || + !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") + ) { + return null; + } + + const roleId = meaningfulRangeText(nodeValue.role_id); + if (!roleId || !namedRoles.has(roleId) || seenRoleIds.has(roleId)) { + return null; + } + + const active = ownActiveFlag(nodeValue); + if (active === null) { + return null; + } + + seenRoleIds.add(roleId); + nodes.push({ roleId, active }); + } + + return seenRoleIds.size === namedRoles.size ? nodes : null; +} + +/** Return true only when every trustworthy named graph section keeps every role active. */ +export function hasTrustworthyAllActiveTimeline(song: RehearsalSong | unknown): boolean { + if (!isRuntimeObject(song)) { + return false; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles) { + return false; + } + + let sawNamedSection = false; + for (const sectionValue of song.sections as unknown[]) { + const record = sectionValue as Record; + const sectionLabel = meaningfulRangeText(record.label); + if (!sectionLabel) { + continue; + } + + const nodes = namedGraphNodes(record, namedRoles); + if (!nodes) { + return false; + } + sawNamedSection = true; + if (nodes.some((node) => !node.active)) { + return false; + } + } + + return sawNamedSection; +} + +type PendingReturn = { + returnSectionLabel: string; + fromSectionLabel: string; + activeAtReturn: Set; +}; + +function namedDropout( + candidates: NamedGraphNode[], + nodes: NamedGraphNode[], + namedRoles: NamedRoleCatalog, + activeRole: string | null, + sectionLabel: string, + returnSectionLabel: string, + fromSectionLabel: string, + sectionIndex: number +): FirstNewDropout | "continue" { + let dropout = candidates[0]; + if (activeRole) { + const activeRoleNode = nodes.find((node) => node.roleId === activeRole)!; + if (!activeRoleNode.active) { + dropout = candidates.find((node) => node.roleId === activeRole); + } + } + if (!dropout) { + return "continue"; + } + + return { + sectionLabel, + returnSectionLabel, + fromSectionLabel, + dropoutRoleId: dropout.roleId, + dropoutRoleName: namedRoles.get(dropout.roleId)!, + sectionIndex + }; +} + +/** + * Pick the first new dropout a player should honor after leftover parts return. + * + * A leftover return here is a later named section where every member of the + * current reduced cohort is own-property active. A new dropout is a named part + * that was not in that reduced cohort and is own-property tacet either in that + * leftover-return section or in a later named section. A leftover sit-out, + * remaining leftover, come-in, tacet, tutti, continued sit-out, or a dropout + * that starts before leftover parts have all returned is not a new dropout. + * + * Inherited/missing activity, own accessors, Proxy get-traps, sparse arrays, + * incomplete or contradictory graphs, unnamed roles, and malformed runtime + * data fail closed. When a role is selected, a new dropout is shown only after + * a leftover return that includes that named part, so a leftover sit-out is + * never told to stay out as a new dropout. + */ +export function firstNewDropout( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstNewDropout | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = denseOwnArray(song.sections); + if (!sections) { + return null; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles || (activeRole && !namedRoles.has(activeRole))) { + return null; + } + + let reducedFrom: string | null = null; + let sittingOutIds: Set | null = null; + let pendingReturn: PendingReturn | null = null; + + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex += 1) { + const sectionValue = sections[sectionIndex] as Record; + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + const nodes = namedGraphNodes(sectionValue, namedRoles); + if (!nodes) { + return null; + } + + if (pendingReturn) { + const awaiting = pendingReturn; + const newDropouts = nodes.filter( + (node) => awaiting.activeAtReturn.has(node.roleId) && node.active === false + ); + const found = namedDropout( + newDropouts, + nodes, + namedRoles, + activeRole, + sectionLabel, + awaiting.returnSectionLabel, + awaiting.fromSectionLabel, + sectionIndex + ); + if (found === "continue") { + continue; + } + return found; + } + + const sittingOut = nodes.filter((node) => node.active === false); + if (!sittingOutIds || !reducedFrom) { + if (sittingOut.length === 0) { + continue; + } + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } + + const baselineIds = sittingOutIds; + const returning = nodes.filter( + (node) => node.active === true && baselineIds.has(node.roleId) + ); + const leftovers = sittingOut.filter((node) => baselineIds.has(node.roleId)); + + if (returning.length === baselineIds.size && leftovers.length === 0) { + const newDropouts = sittingOut.filter((node) => !baselineIds.has(node.roleId)); + const found = namedDropout( + newDropouts, + nodes, + namedRoles, + activeRole, + sectionLabel, + sectionLabel, + reducedFrom, + sectionIndex + ); + if (found !== "continue") { + return found; + } + + pendingReturn = { + returnSectionLabel: sectionLabel, + fromSectionLabel: reducedFrom, + activeAtReturn: new Set(nodes.filter((node) => node.active).map((node) => node.roleId)) + }; + reducedFrom = null; + sittingOutIds = null; + continue; + } + } + + return null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..e0149ba12 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -60,6 +60,7 @@ describe("i18n", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("workspaceFirstNewDropoutTitle")).toBe("오늘 먼저 새로 빠질 자리"); }); it("falls back to English when a Korean translation is missing", () => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..962c27cfd 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,12 @@ "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.", + "workspaceFirstNewDropoutTitle": "Tonight's first new dropout", + "workspaceFirstNewDropoutNamed": "{dropoutRoleName} newly sits out at {sectionLabel} after {returnSectionLabel} comes back from {fromSectionLabel}. Count {dropoutRoleName} out from the top of {sectionLabel}.", + "workspaceFirstNewDropoutStayOut": "{dropoutRoleName} newly sits out at {sectionLabel} after {returnSectionLabel} comes back from {fromSectionLabel}. Stay out from the top of {sectionLabel}.", + "workspaceFirstNewDropoutNone": "No new dropout is needed: every named part stays active after leftover parts return. Rehearse from the first section without a new sit-out cue.", + "workspaceFirstNewDropoutMissing": "Tonight's first new dropout still needs a named part that newly sits out after a leftover return. Confirm who newly sits out after that return before the first section.", + "workspaceFirstNewDropoutOpen": "Open", "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..d55c0a9cc 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,12 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstNewDropoutTitle": "오늘 먼저 새로 빠질 자리", + "workspaceFirstNewDropoutNamed": "{dropoutRoleName} 쪽은 {fromSectionLabel} 구간에서 쉰 파트들이 {returnSectionLabel} 구간에서 다 돌아온 뒤 {sectionLabel} 구간에서 새로 쉽니다. {sectionLabel} 구간 처음부터 {dropoutRoleName} 쪽을 빼 두세요.", + "workspaceFirstNewDropoutStayOut": "{dropoutRoleName} 쪽은 {fromSectionLabel} 구간에서 쉰 파트들이 {returnSectionLabel} 구간에서 다 돌아온 뒤 {sectionLabel} 구간에서 새로 쉽니다. {sectionLabel} 구간 처음부터 쉬세요.", + "workspaceFirstNewDropoutNone": "새로 빠질 자리는 없습니다. 이름이 확인된 모든 파트가 쉬는 파트가 돌아온 뒤에도 계속 연주합니다. 첫 구간부터 새 휴식 큐 없이 합주하세요.", + "workspaceFirstNewDropoutMissing": "오늘 먼저 새로 빠질 자리는 아직 확인이 필요합니다. 쉬는 파트가 돌아온 뒤 누가 새로 쉬는지를 첫 구간 전에 확인하세요.", + "workspaceFirstNewDropoutOpen": "열기", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..0aa98ab4b 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,8 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstNewDropout.ts" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..1a0f98925 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. - `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy. - `EmptyState` must remain an actionable state card, not a blank placeholder panel. +- Ready `Workspace` names tonight's first playable range and tonight's first new dropout so the map enables the next rehearsal action without opening files or export paths. - If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. ## Pattern Backlog diff --git a/docs/doctoring/first-new-dropout.md b/docs/doctoring/first-new-dropout.md new file mode 100644 index 000000000..5a9b27628 --- /dev/null +++ b/docs/doctoring/first-new-dropout.md @@ -0,0 +1,28 @@ +# Tonight's first new dropout + +The ready rehearsal map names the first new dropout from existing `partGraph` evidence: a named reduced section, then a later named leftover return where every previously sitting-out named part is own-property active, and a named part that was not in that reduced cohort is own-property tacet either in that leftover-return section or in a later named section. This is who newly sits out after leftover parts come back. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover, tutti, handoff, Fine, last-line breath, a continued sit-out with nobody returning, or a leftover that never returns. + +Gould (2011) treats a later rest or tacet after a return as a new sit-out for that part, not as leftover of the earlier rest. MusicXML 4.0 records the same activity on each part at each measure; a later rest after every earlier silent part has resumed is new dropout evidence, not leftover evidence (MakeMusic & W3C Music Notation Community Group, 2021). + +## Next action + +- Named new dropout: stay out from the top of the named section after leftover parts return. +- Named returning or other included part: count the new dropout out from the top of the named section. +- Trustworthy all-active timeline: no new-dropout cue is needed; rehearse from the first section without a new sit-out cue. +- Missing or malformed evidence: confirm who newly sits out after the leftover return before the first section rather than inventing a cue. +- Open uses the renderer-owned `[data-testid=song-structure-grid] [data-section-index=N]` landing. Analysis `section.id` is never DOM-ID authority. Reduced-motion Open uses `behavior: "auto"`. + +## Security Notes + +- Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project. +- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits an own data-property leftover return followed by a later own data-property tacet on a part that was not in the reduced cohort. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a new dropout. Inherited `is_active` is isolated. Own accessors and Proxy get-traps cannot substitute `is_active`. Sparse `partGraph` arrays fail closed. Same-section false-then-true nodes are not a return. A leftover sit-out, remaining leftover, come-in, tutti leftover return with nobody newly out, or a continued sit-out with nobody returning is not a new dropout. When a role is selected, only a new dropout after a leftover return that includes that named part is shown. +- Safe failure: inherited flags, own accessors, Proxy get-traps, sparse arrays, blank labels, missing names, leftover sit-outs without a leftover return, leftover returns with nobody newly out, come-ins, remaining leftovers, continued sit-outs, and malformed roots return `null`. The workspace distinguishes a trustworthy all-active timeline from malformed or missing evidence, so only the former gets an explicit “no new dropout needed” next action. +- Logging/privacy: rejected or accepted new dropouts are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstNewDropout.test.ts`, `firstNewDropout.selected-role.test.ts`, and the Workspace callout cover the trustworthy all-active case, an explicit lead new dropout after a leftover return, same-section new dropouts, selected-role scoping, inherited flags, missing `is_active`, own accessors, Proxy get-traps, sparse arrays, leftover sit-outs, remaining leftovers, come-ins, tuttis, continued sit-outs, unnamed roles, empty graphs, renderer-owned Open, reduced-motion auto scrolling, and literal copy filling. + +## References + +Gould, E. (2011). *Behind bars: The definitive guide to music notation*. Faber Music. + +MakeMusic & W3C Music Notation Community Group. (2021). *MusicXML 4.0*. World Wide Web Consortium. https://www.w3.org/2021/06/musicxml40/