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/