diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 5c1c16989..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2024-05-18 - Added focus visible styles for keyboard navigation -**Learning:** Interactive inline buttons (like the chord editor) and scrollable regions with `tabIndex={0}` do not automatically get focus visible styles, meaning keyboard users tabbing through won't know they are focused on them. Unlike central ` + {navigationFailed ? ( +

+ {t("firstTurnaroundPlanNavigationFailed")} +

+ ) : null} + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..b086a547a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +function songWithoutTurnaroundPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.turnaroundPlan = ""; + } + } + return song; +} + +describe("FirstTurnaroundPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English turnaround plan is unavailable", () => { + render(); + + expect(screen.getByText("No turnaround plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean turnaround plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 턴어라운드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..a3ca7f825 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +describe("FirstTurnaroundPlanCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Bass Guitar turnaround at 0:30" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..f3a5e49a2 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -32,7 +32,7 @@ describe("SectionRoadmap", () => { expect(screen.getAllByTitle("우선순위: high").length).toBeGreaterThan(0); expect(screen.getByText("사용자")).toBeTruthy(); expect(screen.getAllByText("음역").length).toBeGreaterThan(0); - expect(screen.getByText("C#2 — E3")).toBeTruthy(); + expect(screen.getAllByText("C#2 — E3").length).toBeGreaterThan(0); expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); }); @@ -43,6 +43,7 @@ describe("SectionRoadmap", () => { ...song.sections[0]!.roles[0]!, range: { lowestNote: " ", highestNote: "none" } }; + song.sections = [song.sections[0]!]; render(); @@ -57,6 +58,7 @@ describe("SectionRoadmap", () => { ...song.sections[0]!.roles[0]!, range: { lowestNote: "E3", highestNote: "C#2" } }; + song.sections = [song.sections[0]!]; render(); @@ -72,6 +74,7 @@ describe("SectionRoadmap", () => { ...song.sections[0]!.roles[0]!, range: { lowestNote: "low-ish", highestNote: "E3" } }; + song.sections = [song.sections[0]!]; render(); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..aab403d8c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -92,6 +92,7 @@ describe("Workspace", () => { id: "low-end", name: "Bass Guitar" }; + song.sections = [song.sections[0]!]; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); @@ -161,6 +162,7 @@ describe("Workspace", () => { range: { lowestNote: "", highestNote: "none" }, overlapWarnings: [] })); + song.sections = [song.sections[0]!]; render(); @@ -188,6 +190,7 @@ describe("Workspace", () => { ...role, overlapWarnings: [] })); + song.sections = [song.sections[0]!]; render(); @@ -326,4 +329,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first turnaround plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar turnaround at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..fcd62c3a0 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -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)}

@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts new file mode 100644 index 000000000..6f7145eb8 --- /dev/null +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the first turnaround-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstTurnaroundPlan.ts", + "src/features/workspace/FirstTurnaroundPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts index 643935954..32a55fa0e 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -65,6 +65,7 @@ describe("firstRangeSqueeze", () => { ...role, overlapWarnings: index === 0 ? [" none ", ""] : [] })); + song.sections = [song.sections[0]!]; expect(firstRangeSqueeze(song)).toEqual({ sectionLabel: "verse", @@ -94,6 +95,7 @@ describe("firstRangeSqueeze", () => { const song = createDemoRehearsalSong(); const selectedRole = song.sections[0]!.roles[0]!; selectedRole.range = range; + song.sections = [song.sections[0]!]; expect(firstRangeSqueeze(song, selectedRole.id)).toBeNull(); } diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts new file mode 100644 index 000000000..f7a88fbad --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +describe("resolveFirstTurnaroundPlan continuation authority", () => { + it("does not name a legacy turnaround plan when the landing role stops before the next section", () => { + const song = createDemoRehearsalSong(); + const current = structuredClone(song.sections[0]!); + const landingRole = current.roles.find((role) => role.id === "bass-guitar")!; + landingRole.turnaroundPlan = "Hold the turnaround together."; + delete landingRole.turnaroundPlanSource; + for (const role of current.roles) { + if (role.id !== landingRole.id) { + delete role.turnaroundPlan; + delete role.turnaroundPlanSource; + } + } + + const next = structuredClone(current); + next.id = "chorus-after-turnaround"; + next.label = "chorus"; + next.timeRange = { start: current.timeRange.end, end: current.timeRange.end + 20 }; + next.roles = next.roles.map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + next.partGraph = next.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === landingRole.id ? false : node.is_active + })); + + song.sections = [current, next]; + + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("does not name a turnaround when the landing role is the only source that continues", () => { + const song = createDemoRehearsalSong(); + const current = structuredClone(song.sections[0]!); + const landingRole = current.roles.find((role) => role.id === "bass-guitar")!; + landingRole.turnaroundPlan = "Hold the turnaround together."; + delete landingRole.turnaroundPlanSource; + for (const role of current.roles) { + if (role.id !== landingRole.id) { + delete role.turnaroundPlan; + delete role.turnaroundPlanSource; + } + } + + const next = structuredClone(current); + next.id = "chorus-solo-continuation"; + next.label = "chorus"; + next.timeRange = { start: current.timeRange.end, end: current.timeRange.end + 20 }; + next.roles = next.roles + .filter((role) => role.id === landingRole.id) + .map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + next.partGraph = next.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === landingRole.id + })); + + song.sections = [current, next]; + + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.demo.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.demo.test.ts new file mode 100644 index 000000000..aa659033c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.demo.test.ts @@ -0,0 +1,13 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +describe("demo turnaround plan", () => { + it("keeps the demo turnaround actionable across a real successor section", () => { + expect(resolveFirstTurnaroundPlan(createDemoRehearsalSong())).toMatchObject({ + sectionId: "verse-1", + landingRoleId: "bass-guitar", + atSeconds: 30 + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..681c395be --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +function songWithTurnaroundPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "turnaround-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + turnaroundPlan: "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstTurnaroundPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithTurnaroundPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstTurnaroundPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithTurnaroundPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithTurnaroundPlan(); + Object.defineProperty(section.roles[0]!, "turnaroundPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile turnaroundPlan getter"); + } + }); + + expect(() => resolveFirstTurnaroundPlan(song)).not.toThrow(); + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable turnaround-plan identity authority", () => { + const { song, section } = songWithTurnaroundPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "turnaround-own"; + } + }); + + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("does not let inherited turnaround plans establish the named copy", () => { + const { song, section } = songWithTurnaroundPlan(); + const inheritedRole = Object.create({ + turnaroundPlan: "Inherited turnaround plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "lead-vocal" }, + name: { configurable: true, enumerable: true, value: "Lead Vocal" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the landing part", () => { + const { song, section } = songWithTurnaroundPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithTurnaroundPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.proxy-authority.test.ts new file mode 100644 index 000000000..682257ab7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.proxy-authority.test.ts @@ -0,0 +1,78 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +const DEMO_TURNAROUND_PLAN = + "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together."; + +describe("resolveFirstTurnaroundPlan own-data authority", () => { + it("uses the snapshotted own-data turnaround plan instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo turnaround-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "turnaroundPlan") { + return "Injected proxy turnaround."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe(DEMO_TURNAROUND_PLAN); + }); + + it("uses the snapshotted own-data time range instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + expect(section).toBeDefined(); + if (!section) { + throw new Error("Demo turnaround-plan fixture is missing the expected verse section."); + } + const expectedEnd = section.timeRange.end; + section.timeRange = new Proxy(section.timeRange, { + get(target, property, receiver) { + if (property === "end") { + return expectedEnd + 15; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstTurnaroundPlan(song)?.atSeconds).toBe(expectedEnd); + }); + + it("returns snapshotted role identity and display copy instead of Proxy get values", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo turnaround-plan fixture is missing the expected Bass Guitar role."); + } + const expectedId = role.id; + const expectedName = role.name; + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "name") { + return "Injected proxy role"; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstTurnaroundPlan(song); + expect(resolved?.turnaroundPlan).toBe(DEMO_TURNAROUND_PLAN); + expect(resolved?.landingRoleId).toBe(expectedId); + expect(resolved?.landingRoleName).toBe(expectedName); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.section-label.test.ts new file mode 100644 index 000000000..45289553f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +describe("resolveFirstTurnaroundPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.snapshot.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.snapshot.test.ts new file mode 100644 index 000000000..768134a08 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.snapshot.test.ts @@ -0,0 +1,60 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +describe("resolveFirstTurnaroundPlan descriptor snapshots", () => { + it("uses the turnaround-plan snapshot that admitted the role", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const role = section.roles[0]!; + const roleId = role.id; + const companion = structuredClone(section.roles[2]!); + delete companion.turnaroundPlan; + delete companion.turnaroundPlanSource; + section.roles = [role, companion]; + section.partGraph = [ + { role_id: roleId, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: companion.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const continuation = structuredClone(section); + continuation.id = "chorus-1"; + continuation.label = "chorus"; + continuation.timeRange = { start: 30, end: 50 }; + continuation.roles = continuation.roles.map((continuingRole) => { + const clone = structuredClone(continuingRole); + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + continuation.partGraph = continuation.partGraph.map((node) => ({ + ...node, + is_active: true + })); + + let turnaroundPlanDescriptorReads = 0; + const proxiedRole = new Proxy(role, { + getOwnPropertyDescriptor(target, key) { + if (key === "turnaroundPlan") { + turnaroundPlanDescriptorReads += 1; + return { + configurable: true, + enumerable: true, + writable: true, + value: + turnaroundPlanDescriptorReads === 1 + ? "Turn these last bars with Lead Vocal; land the downbeat together." + : "Changed after validation." + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + } + }); + section.roles = [proxiedRole, companion]; + song.sections = [section, continuation]; + + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe( + "Turn these last bars with Lead Vocal; land the downbeat together." + ); + expect(turnaroundPlanDescriptorReads).toBe(1); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts new file mode 100644 index 000000000..d26f5a3b0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatTurnaroundPlanTime, resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +const DEMO_TURNAROUND_PLAN = + "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together."; + +function withTurnaroundSection( + overrides: { + id?: string; + start?: number; + end?: number; + turnaroundPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + functionLabel?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-turnaround"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "lead-vocal"; + const companionRoleId = roleId === "bass-guitar" ? "lead-vocal" : "bass-guitar"; + const companionRole = structuredClone(verse.roles.find((role) => role.id === companionRoleId)!); + delete companionRole.turnaroundPlan; + delete companionRole.turnaroundPlanSource; + section.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "medium", + cue: { kind: "lyric", value: "city lights" }, + range: { lowestNote: "G#3", highestNote: "C#5" }, + setupNote: "Watch the breath before the last line of the verse.", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + overlapWarnings: ["Melodic overlap: competing with Keyboard 1 Right Hand."], + harmony: { + chord: "C#m7", + functionLabel: overrides.functionLabel ?? "vi melodic pull", + source: "model" + }, + harmonicExplanation: + "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.", + confidence: { + level: "high", + source: "user", + notes: "Singer confirmed the pickup phrasing in rehearsal notes." + }, + turnaroundPlan: overrides.turnaroundPlan ?? DEMO_TURNAROUND_PLAN, + manualOverrides: [] + }, + companionRole + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: companionRole.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + + const continuation = structuredClone(section); + continuation.id = `${section.id}-continuation`; + continuation.label = "chorus"; + const continuationStart = + Number.isInteger(section.timeRange.end) && + section.timeRange.end >= 0 && + section.timeRange.end < MAX_SECTION_TIME_SECONDS + ? section.timeRange.end + : 30; + continuation.timeRange = { start: continuationStart, end: continuationStart + 1 }; + continuation.roles = continuation.roles.map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + continuation.partGraph = continuation.partGraph.map((node) => ({ ...node, is_active: true })); + + song.sections = [section, continuation]; + return song; +} + +describe("resolveFirstTurnaroundPlan", () => { + it("picks the demo song's earliest turnaround plan and the part that carries it into the next section", () => { + const resolved = resolveFirstTurnaroundPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.landingRole.id).toBe("bass-guitar"); + expect(resolved?.turnaroundPlan).toBe(DEMO_TURNAROUND_PLAN); + expect(resolved?.atSeconds).toBe(30); + expect(formatTurnaroundPlanTime(resolved?.atSeconds ?? -1)).toBe("0:30"); + expect(formatTurnaroundPlanTime(Number.NaN)).toBe("0:00"); + expect(formatTurnaroundPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a turnaround plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withTurnaroundSection(); + delete song.sections[0]!.roles[0]!.turnaroundPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = DEMO_TURNAROUND_PLAN; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (song.sections[0]!.roles[0] as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[0]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the turnaround."; + (song.sections[0]!.roles[0] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (song.sections[0]!.roles[0] as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (song.sections[0]!.roles[0] as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (song.sections[0]!.roles[0] as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + (song.sections[0]!.roles[0] as { cutoffPlan?: string }).cutoffPlan = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: DEMO_TURNAROUND_PLAN + }; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("skips a blank turnaround plan", () => { + expect(resolveFirstTurnaroundPlan(withTurnaroundSection({ turnaroundPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line turnaround plan", () => { + expect( + resolveFirstTurnaroundPlan(withTurnaroundSection({ turnaroundPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two turnaround plans", () => { + const song = withTurnaroundSection({ + id: "verse-late-turnaround", + start: 40, + end: 56, + roleId: "keys-right", + turnaroundPlan: "Late turnaround." + }); + const earlier = structuredClone(song.sections[0]!); + const earlierCompanion = structuredClone(earlier.roles[1]!); + delete earlierCompanion.turnaroundPlan; + delete earlierCompanion.turnaroundPlanSource; + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + turnaroundPlan: "Earlier turnaround." + }, + earlierCompanion + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [ + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: earlierCompanion.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const continuation = structuredClone(earlier); + continuation.id = "chorus-after-early-turnaround"; + continuation.timeRange = { start: 24, end: 25 }; + continuation.roles = continuation.roles.map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + song.sections = [song.sections[0]!, earlier, continuation]; + + const resolved = resolveFirstTurnaroundPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.turnaroundPlan).toBe("Earlier turnaround."); + expect(resolved?.atSeconds).toBe(24); + }); + + it("breaks same-time turnaround-plan ties with locale-independent id ordering", () => { + const song = withTurnaroundSection({ id: "ä-turnaround", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-turnaround"; + const continuation = song.sections[1]!; + song.sections = [song.sections[0]!, ascii, continuation]; + + expect(resolveFirstTurnaroundPlan(song)?.section.id).toBe("z-turnaround"); + }); + + it("prefers a high-priority turnaround part over a low-priority part in the same section", () => { + const song = withTurnaroundSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + turnaroundPlan: "Low-priority turnaround." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + turnaroundPlan: "High-priority turnaround." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + const continuation = song.sections[1]!; + continuation.roles = section.roles.map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + continuation.partGraph = section.partGraph.map((node) => ({ ...node })); + + expect(resolveFirstTurnaroundPlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe("High-priority turnaround."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withTurnaroundSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + turnaroundPlan: "ASCII turnaround." + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + const continuation = song.sections[1]!; + continuation.roles = section.roles.map((role) => { + const clone = { ...role }; + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; + }); + continuation.partGraph = section.partGraph.map((node) => ({ ...node })); + + expect(resolveFirstTurnaroundPlan(song)?.landingRole.id).toBe("z-role"); + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe("ASCII turnaround."); + }); + + it("skips a turnaround plan whose graph node is inactive", () => { + expect(resolveFirstTurnaroundPlan(withTurnaroundSection({ isActive: false }))).toBeNull(); + }); + + it("skips a turnaround plan whose rehearsal window is unbounded", () => { + expect(resolveFirstTurnaroundPlan(withTurnaroundSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a turnaround plan whose end precedes its start", () => { + expect(resolveFirstTurnaroundPlan(withTurnaroundSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length turnaround-plan window", () => { + expect(resolveFirstTurnaroundPlan(withTurnaroundSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a turnaround plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstTurnaroundPlan( + withTurnaroundSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstTurnaroundPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withTurnaroundSection(); + song.sections[0]!.roles = [ + null as never, + song.sections[0]!.roles[0]!, + song.sections[0]!.roles[1]! + ]; + song.sections[0]!.partGraph = [ + null as never, + song.sections[0]!.partGraph[0]!, + song.sections[0]!.partGraph[1]! + ]; + expect(resolveFirstTurnaroundPlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withTurnaroundSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("keeps the turnaround plan unnamed when role identities are duplicated", () => { + const song = withTurnaroundSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstTurnaroundPlan(song)).toBeNull(); + }); + + it("bounds the turnaround plan to 180 Unicode code points", () => { + const song = withTurnaroundSection({ turnaroundPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstTurnaroundPlan(song); + expect(resolved?.turnaroundPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the turnaround-plan boundary", () => { + const song = withTurnaroundSection({ turnaroundPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstTurnaroundPlan(song); + expect(Array.from(resolved?.turnaroundPlan ?? "")).toHaveLength(180); + expect(resolved?.turnaroundPlan.endsWith("😀")).toBe(true); + }); + + it("keeps the generated activity sentence recognizable after bounding a long partner name", () => { + const target = `Lead-${"A".repeat(180)}`; + const song = withTurnaroundSection({ + turnaroundPlan: `Turn these last bars with ${target}; land the downbeat together.` + }); + const resolved = resolveFirstTurnaroundPlan(song); + expect(resolved?.turnaroundPlan.startsWith("Turn these last bars with Lead-")).toBe(true); + expect(resolved?.turnaroundPlan.endsWith("; land the downbeat together.")).toBe(true); + expect(Array.from(resolved?.turnaroundPlan ?? "").length).toBeLessThanOrEqual(180); + }); + + it("preserves a short generated shared-turnaround sentence", () => { + const song = withTurnaroundSection({ + turnaroundPlan: "Turn these last bars with Lead Vocal; land the downbeat together." + }); + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe( + "Turn these last bars with Lead Vocal; land the downbeat together." + ); + }); + + it("does not treat an empty generated partner as structured guidance", () => { + const song = withTurnaroundSection({ + turnaroundPlan: "Turn these last bars with ; land the downbeat together." + }); + expect(resolveFirstTurnaroundPlan(song)?.turnaroundPlan).toBe( + "Turn these last bars with ; land the downbeat together." + ); + }); + + it("contains exceptions from the runtime root instead of crashing", () => { + const song = new Proxy(withTurnaroundSection(), { + getOwnPropertyDescriptor() { + throw new Error("hostile descriptor"); + } + }); + expect(() => resolveFirstTurnaroundPlan(song as never)).not.toThrow(); + expect(resolveFirstTurnaroundPlan(song as never)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts new file mode 100644 index 000000000..462ee46a0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts @@ -0,0 +1,414 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_TURNAROUND_PLAN_CHARACTERS = 180; +const GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX = "Turn these last bars with "; +const GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX = "; land the downbeat together."; +const GENERATED_ACTIVITY_TURNAROUND_PLAN_FIXED_CHARACTERS = Array.from( + GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX + GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX +).length; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const ACCOMPANIMENT_SOURCE_ROLE_IDS = new Set([ + "keys-left", + "keys-right", + "acoustic-guitar" +]); +const ACCOMPANIMENT_SOURCE_ID = "other"; + +type TurnaroundPlanSource = "model" | "user"; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedTurnaroundPlan = Readonly<{ + text: string; + source: TurnaroundPlanSource | null; +}>; + +/** Tonight's first turnaround plan: the earliest labeled section and the part that carries it into the next section. */ +export type FirstTurnaroundPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + turnaroundPlan: string; + turnaroundPlanSource: TurnaroundPlanSource | null; + atSeconds: number; +}; + +/** Format a non-negative turnaround-plan time as m:ss for rehearsal copy. */ +export function formatTurnaroundPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Keep a bounded engine-owned turnaround sentence structurally recognizable for localization. */ +function boundedGeneratedActivityTurnaroundPlan(value: string): string | null { + if ( + !value.startsWith(GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX) || + !value.endsWith(GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX) + ) { + return null; + } + const target = value + .slice( + GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX.length, + value.length - GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX.length + ) + .trim(); + if (target.length === 0) { + return null; + } + const boundedTarget = truncateCodePoints( + target, + MAX_TURNAROUND_PLAN_CHARACTERS - GENERATED_ACTIVITY_TURNAROUND_PLAN_FIXED_CHARACTERS + ); + return `${GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX}${boundedTarget}${GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX}`; +} + +/** Return a bounded snapshotted own turnaround plan and its explicit provenance, or null when malformed. */ +function ownedTurnaroundPlan(role: unknown): OwnedTurnaroundPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const turnaroundPlan = ownDataValue(role, "turnaroundPlan"); + const turnaroundPlanSource = ownDataValue(role, "turnaroundPlanSource"); + if (typeof turnaroundPlan !== "string") { + return null; + } + if ( + turnaroundPlanSource !== undefined && + turnaroundPlanSource !== "model" && + turnaroundPlanSource !== "user" + ) { + return null; + } + const trimmed = turnaroundPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return { + text: + boundedGeneratedActivityTurnaroundPlan(trimmed) ?? + truncateCodePoints(trimmed, MAX_TURNAROUND_PLAN_CHARACTERS), + source: turnaroundPlanSource ?? null + }; +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Map canonical accompaniment roles back to their shared source-separation stem. */ +function turnaroundSourceId(roleId: string): string { + return ACCOMPANIMENT_SOURCE_ROLE_IDS.has(roleId) ? ACCOMPANIMENT_SOURCE_ID : roleId; +} + +/** Prefer rehearsal priority, then a locale-independent stable id. */ +function pickLandingRole(roles: Role[]): Role | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { + return []; + } + + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); + + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Resolve a turnaround plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const nextSection = sections[sectionIndex + 1]; + if (!isRuntimeObject(nextSection)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const activeRoles = rankedActiveRoles(section as RehearsalSection); + const nextActiveRoleIds = new Set( + rankedActiveRoles(nextSection as RehearsalSection).map((metadata) => metadata.id) + ); + const continuingRoleIds = new Set( + activeRoles.filter((metadata) => nextActiveRoleIds.has(metadata.id)).map((metadata) => metadata.id) + ); + const continuingSourceCount = new Set( + [...continuingRoleIds].map((roleId) => turnaroundSourceId(roleId)) + ).size; + if (continuingSourceCount < 2) { + return []; + } + + const landingRole = pickLandingRole( + activeRoles.flatMap((metadata) => { + if (!continuingRoleIds.has(metadata.id)) { + return []; + } + const turnaroundPlan = ownedTurnaroundPlan(metadata.role); + return turnaroundPlan === null + ? [] + : [{ + ...metadata, + turnaroundPlan: turnaroundPlan.text, + turnaroundPlanSource: turnaroundPlan.source + }]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + turnaroundPlan: landingRole.turnaroundPlan, + turnaroundPlanSource: landingRole.turnaroundPlanSource, + atSeconds: timeRange.end + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named turnaround plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPlan | null { + try { + return resolveSafeFirstTurnaroundPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..febca8938 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,52 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-turnaround-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstTurnaroundPlanOpenAction")).toBe("{at} {role} 턴어라운드 열기"); + expect(t("firstTurnaroundPlanBody")).toBe("{at} {section}에서 {role} 파트의 턴어라운드 계획이 있습니다."); + expect(t("firstTurnaroundPlanArmed")).toBe("{at}에서 {role} 파트의 턴어라운드를 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f5656ce01 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a translator for the requested locale, falling back to the English dictionary for missing entries. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..089e86fff 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstTurnaroundPlanLabel": "Tonight's first turnaround plan", + "firstTurnaroundPlanOpenAction": "Open {role} turnaround at {at}", + "firstTurnaroundPlanBody": "{role} has a shared turnaround in the {section} at {at}.", + "firstTurnaroundPlanArmed": "Turn those last bars on {role} at {at} before the next section lands.", + "firstTurnaroundPlanGeneratedGuidance": "Turn these last bars with {target}; land the downbeat together.", + "firstTurnaroundPlanGeneratedBandGuidance": "Turn these last bars with the rest of the band; land the downbeat together.", + "firstTurnaroundPlanUnavailable": "No turnaround plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstTurnaroundPlanNavigationFailed": "Could not open this turnaround on the song map. Use the map below to find the section.", "workspaceFirstRangeTitle": "Tonight's first range", "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}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..567e6e91c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstTurnaroundPlanLabel": "오늘 첫 턴어라운드 계획", + "firstTurnaroundPlanOpenAction": "{at} {role} 턴어라운드 열기", + "firstTurnaroundPlanBody": "{at} {section}에서 {role} 파트의 턴어라운드 계획이 있습니다.", + "firstTurnaroundPlanArmed": "{at}에서 {role} 파트의 턴어라운드를 맞춘 다음 합주를 시작하세요.", + "firstTurnaroundPlanGeneratedGuidance": "{target} 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", + "firstTurnaroundPlanGeneratedBandGuidance": "나머지 밴드와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", + "firstTurnaroundPlanUnavailable": "사용 가능한 턴어라운드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstTurnaroundPlanNavigationFailed": "곡 맵에서 이 턴어라운드를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..fff65da76 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,18 @@ import { fileURLToPath } from "node:url"; const configDirectory = path.dirname(fileURLToPath(import.meta.url)); +/** Production files whose V8 coverage is owned by the desktop test gate. */ +export const DESKTOP_OWNED_PRODUCTION_COVERAGE = [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstTurnaroundPlan.ts", + "src/features/workspace/FirstTurnaroundPlanCallout.tsx" +]; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +31,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..59c6762f7 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | +| First Turnaround Plan Callout (feature-local pattern) | No dedicated Figma node exists yet; keep feature-local until the design catalog is updated. | `apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `turnaroundPlan` copy, the labeled section end, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `vampPlan`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, `hookPlan`, `soloPlan`, `padPlan`, `hitPlan`, `cutoffPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-cutoff-plan, first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, and first-articulation-plan. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | diff --git a/docs/doctoring/reduced-motion-first-turnaround-plan-navigation.md b/docs/doctoring/reduced-motion-first-turnaround-plan-navigation.md new file mode 100644 index 000000000..68a352356 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-turnaround-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first turnaround-plan navigation + +Open tonight's first turnaround plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..1b7bf1cef 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -125,7 +125,7 @@ export type ManualOverride = source: "user"; }; -/** Documented. */ +/** An active rehearsal role on one section of tonight's map, carrying optional activity-backed guidance metadata. */ export type RehearsalRole = { id: string; name: string; @@ -139,6 +139,8 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + turnaroundPlan?: string; + turnaroundPlanSource?: ProvenanceSource; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +476,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", + turnaroundPlan: "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together.", manualOverrides: [], overlapWarnings: [ "Density warning: competing with Keyboard Left Hand in low register." @@ -622,6 +625,30 @@ const demoRehearsalSongSeed: RehearsalSong = { } }; +/** Keep the demo turnaround on a real continuation boundary for the workspace resolver. */ +const demoContinuationSection = structuredClone(demoRehearsalSongSeed.sections[0]); +demoContinuationSection.id = "chorus-1"; +demoContinuationSection.label = "chorus"; +demoContinuationSection.groove = "Open chorus lift on the shared downbeat"; +demoContinuationSection.timeRange = { start: 30, end: 50 }; +demoContinuationSection.confidence = { + level: "medium", + source: "model", + notes: "The chorus follows the verse turnaround." +}; +demoContinuationSection.roles = demoContinuationSection.roles.map((role) => { + const clone = structuredClone(role); + delete clone.turnaroundPlan; + delete clone.turnaroundPlanSource; + return clone; +}); +demoContinuationSection.partGraph = demoContinuationSection.partGraph.map((node) => ({ + ...node, + handoff_to: [], + handoff_from: [] +})); +demoRehearsalSongSeed.sections.push(demoContinuationSection); + /** Documented. */ export function createDefaultProjectSummary(input: { id: string; @@ -1497,6 +1524,8 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "turnaroundPlan", + "turnaroundPlanSource", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1581,18 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.turnaroundPlan !== undefined && typeof value.turnaroundPlan !== "string") { + return invalidField(`${path}.turnaroundPlan`); + } + if ( + value.turnaroundPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.turnaroundPlanSource) + ) { + return invalidField(`${path}.turnaroundPlanSource`); + } + if (value.turnaroundPlanSource !== undefined && value.turnaroundPlan === undefined) { + return invalidField(`${path}.turnaroundPlanSource`); + } if (!isDenseArray(value.manualOverrides)) { return invalidField(`${path}.manualOverrides`); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..63fcacc00 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -725,7 +725,8 @@ describe("shared type helpers", () => { } } ] - } + }, + expect.anything() ], exportSummary: { format: "cue-sheet" @@ -738,6 +739,7 @@ describe("shared type helpers", () => { expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); + expect(song.sections[0]?.roles[0]?.turnaroundPlan).toContain("verse last beat"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -747,6 +749,12 @@ describe("shared type helpers", () => { chord: "C#m11" } }); + expect(song.sections).toHaveLength(2); + expect(song.sections[1]).toMatchObject({ + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }); }); it("returns a fresh copy of the rehearsal song fixture", () => { @@ -1257,6 +1265,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].turnaroundPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.turnaroundPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/packages/shared-types/test/turnaroundPlanProvenance.test.ts b/packages/shared-types/test/turnaroundPlanProvenance.test.ts new file mode 100644 index 000000000..696720e13 --- /dev/null +++ b/packages/shared-types/test/turnaroundPlanProvenance.test.ts @@ -0,0 +1,30 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; + +describe("turnaround plan provenance", () => { + it.each(["model", "user"] as const)("preserves explicit %s provenance", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.turnaroundPlan = "Turn these last bars with Lead Vocal; land the downbeat together."; + role.turnaroundPlanSource = source; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.turnaroundPlanSource).toBe(source); + }); + + it("rejects unsupported provenance values", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]! as unknown as Record; + role.turnaroundPlan = "Turn these last bars with Lead Vocal; land the downbeat together."; + role.turnaroundPlanSource = "inferred"; + + expect(() => parseRehearsalSong(song)).toThrow(/turnaroundPlanSource/); + }); + + it("rejects provenance without a turnaround plan", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.turnaroundPlan; + role.turnaroundPlanSource = "model"; + + expect(() => parseRehearsalSong(song)).toThrow(/turnaroundPlanSource/); + }); +}); diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..c8cc2dd2d 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -22,6 +22,12 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_OTHER_STEM_SOURCE_LABEL = "Accompaniment" +_TURNAROUND_PLAN_PREFIX = "Turn these last bars with " +_TURNAROUND_PLAN_SUFFIX = "; land the downbeat together." +_TURNAROUND_PLAN_BAND_TARGET = "the rest of the band" + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -330,6 +336,63 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _activity_turnaround_plan( + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + next_role_activity: dict[str, bool] | None, + ) -> str | None: + """Return bounded turnaround guidance only for a shared continuation. + + A turnaround plan is emitted only when real stem activity shows this role + remaining active into the next section with at least one other distinct + source also continuing. Mixed-source shared continuation is the evidence. + Heuristic fallback topology and last-section (no next activity) produce no plan. + """ + if ( + next_role_activity is None + or not role_activity.get(role_id, False) + or not next_role_activity.get(role_id, False) + ): + return None + + continuing_role_ids = [ + candidate_id + for candidate_id, is_active in role_activity.items() + if is_active and next_role_activity.get(candidate_id, False) + ] + named_source_ids = [ + candidate_id + for candidate_id in continuing_role_ids + if candidate_id not in _OTHER_STEM_ROLE_IDS + ] + other_stem_continuing = any( + candidate_id in _OTHER_STEM_ROLE_IDS for candidate_id in continuing_role_ids + ) + source_count = len(named_source_ids) + (1 if other_stem_continuing else 0) + if source_count < 2: + return None + if source_count >= 3: + return ( + f"{_TURNAROUND_PLAN_PREFIX}{_TURNAROUND_PLAN_BAND_TARGET}{_TURNAROUND_PLAN_SUFFIX}" + ) + + partner_ids = [candidate_id for candidate_id in named_source_ids if candidate_id != role_id] + other_name: str | None = None + if partner_ids: + other_id = partner_ids[0] + other_name = next( + (role["name"] for role in roles.values() if role["id"] == other_id), + None, + ) + else: + other_name = _OTHER_STEM_SOURCE_LABEL + + if other_name is None: + return None + return f"{_TURNAROUND_PLAN_PREFIX}{other_name}{_TURNAROUND_PLAN_SUFFIX}" + def _build_activity_topology( self, section_id: str, @@ -357,7 +420,18 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + turnaround_plan = self._activity_turnaround_plan( + role_id, + roles, + role_activity, + next_role_activity, + ) + if turnaround_plan is not None: + role = role.copy() + role["turnaroundPlan"] = turnaround_plan + role["turnaroundPlanSource"] = "model" + active_roles.append(role) part_graph.append( { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..b6c741cfb 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -83,6 +83,8 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + turnaroundPlan: NotRequired[str] + turnaroundPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..ad94fbc70 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -133,3 +133,162 @@ def test_role_extractor_falls_back_when_activity_detection_fails() -> None: assert result["topologies"][0]["section_id"] == "verse-1" assert result["topologies"][0]["part_graph"][0]["role_id"] == "bass-guitar" + + +def _extract_with_activity( + stem_activity: list[dict[str, bool]], + section_ids: list[str] | None = None, +) -> dict[str, dict[str, object]]: + """Run RoleExtractor against a patched stem-activity map.""" + extractor = RoleExtractor() + sections = [{"id": section_id} for section_id in (section_ids or ["verse-1", "chorus-1"])] + audio_features = { + "stems": {"bass": np.ones(200, dtype=np.float32)}, + "sr": 10, + "boundaries": [ + (float(index * 10), float((index + 1) * 10)) for index in range(len(sections)) + ], + } + with ( + patch( + "bandscope_analysis.roles.extractor.detect_stem_activity", + return_value=stem_activity, + ), + patch.object( + RoleExtractor, + "_extract_features", + return_value=( + {"lowestNote": "", "highestNote": ""}, + "", + {"lowestNote": "E1", "highestNote": "E3"}, + "Em", + ), + ), + ): + result = extractor.extract(sections, audio_features) + return {role["id"]: role for role in result["topologies"][0]["active_roles"]} + + +def test_role_extractor_emits_activity_corroborated_turnaround_plan() -> None: + """Emit a turnaround plan only when two distinct sources continue together.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": False}, + {"bass": True, "vocals": True, "other": False}, + ] + ) + assert verse_roles["bass-guitar"]["turnaroundPlan"] == ( + "Turn these last bars with Lead Vocal; land the downbeat together." + ) + assert verse_roles["lead-vocal"]["turnaroundPlan"] == ( + "Turn these last bars with Bass Guitar; land the downbeat together." + ) + + +def test_role_extractor_groups_shared_other_stem_continuation_for_turnaround_plan() -> None: + """Name the shared accompaniment stem without inventing a specific instrument.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": False, "other": True}, + {"bass": True, "vocals": False, "other": True}, + ] + ) + assert verse_roles["bass-guitar"]["turnaroundPlan"] == ( + "Turn these last bars with Accompaniment; land the downbeat together." + ) + assert verse_roles["keys-right"]["turnaroundPlan"] == ( + "Turn these last bars with Bass Guitar; land the downbeat together." + ) + + +def test_role_extractor_keeps_mixed_continuations_as_shared_turnaround_evidence() -> None: + """Mixed shared continuation is the turnaround evidence, not an ambiguity.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": True}, + {"bass": True, "vocals": True, "other": True}, + ] + ) + assert verse_roles["bass-guitar"]["turnaroundPlan"] == ( + "Turn these last bars with the rest of the band; land the downbeat together." + ) + assert verse_roles["lead-vocal"]["turnaroundPlan"] == ( + "Turn these last bars with the rest of the band; land the downbeat together." + ) + assert verse_roles["acoustic-guitar"]["turnaroundPlan"] == ( + "Turn these last bars with the rest of the band; land the downbeat together." + ) + + +def test_role_extractor_keeps_single_continuation_turnaround_plan_unnamed() -> None: + """A lone continuation is not a shared turnaround.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": False}, + {"bass": True, "vocals": False, "other": False}, + ] + ) + assert "turnaroundPlan" not in verse_roles["bass-guitar"] + assert "turnaroundPlan" not in verse_roles["lead-vocal"] + + +def test_role_extractor_keeps_last_section_turnaround_plan_unnamed() -> None: + """Without a next section there is no shared-continuation evidence.""" + extractor = RoleExtractor() + sections = [{"id": "outro"}] + audio_features = { + "stems": {"bass": np.ones(100, dtype=np.float32)}, + "sr": 10, + "boundaries": [(0.0, 10.0)], + } + with ( + patch( + "bandscope_analysis.roles.extractor.detect_stem_activity", + return_value=[{"bass": True, "vocals": True, "other": True}], + ), + patch.object( + RoleExtractor, + "_extract_features", + return_value=( + {"lowestNote": "", "highestNote": ""}, + "", + {"lowestNote": "E1", "highestNote": "E3"}, + "Em", + ), + ), + ): + result = extractor.extract(sections, audio_features) + outro_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + assert "turnaroundPlan" not in outro_roles["bass-guitar"] + + +def test_role_extractor_keeps_heuristic_turnaround_plan_unnamed() -> None: + """Heuristic fallback topology must not invent a shared turnaround.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + intro_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + verse_roles = {role["id"]: role for role in result["topologies"][1]["active_roles"]} + assert all("turnaroundPlan" not in role for role in intro_roles.values()) + assert all("turnaroundPlan" not in role for role in verse_roles.values()) + + +def test_activity_turnaround_plan_fails_closed_without_a_named_partner() -> None: + """Unknown continuation partners stay unnamed instead of inventing copy.""" + assert ( + RoleExtractor._activity_turnaround_plan( + "bass-guitar", + {}, + {"bass-guitar": True, "lead-vocal": True}, + {"bass-guitar": True, "lead-vocal": True}, + ) + is None + ) + assert ( + RoleExtractor._activity_turnaround_plan( + "keys-right", + {}, + {"keys-right": True, "lead-vocal": True}, + {"keys-right": True, "lead-vocal": True}, + ) + is None + ) diff --git a/services/analysis-engine/tests/test_turnaround_plan_provenance.py b/services/analysis-engine/tests/test_turnaround_plan_provenance.py new file mode 100644 index 000000000..c398531ab --- /dev/null +++ b/services/analysis-engine/tests/test_turnaround_plan_provenance.py @@ -0,0 +1,35 @@ +"""Regression coverage for turnaround-plan provenance.""" + +from bandscope_analysis.roles.extractor import RoleExtractor + + +def test_activity_turnaround_plan_marks_generated_copy_as_model_owned() -> None: + """Generated turnaround copy carries explicit provenance into the role contract.""" + extractor = RoleExtractor() + empty_range = {"lowestNote": "", "highestNote": ""} + roles = extractor._build_roles("", empty_range, "", empty_range) + activity = { + "bass-guitar": True, + "keys-left": False, + "keys-right": False, + "lead-vocal": True, + "acoustic-guitar": False, + } + + topology = extractor._build_activity_topology("verse-1", roles, activity, activity) + generated_roles = [role for role in topology["active_roles"] if "turnaroundPlan" in role] + + assert generated_roles + assert all(role["turnaroundPlanSource"] == "model" for role in generated_roles) + + +def test_heuristic_topology_does_not_invent_turnaround_provenance() -> None: + """Fallback topology must not claim provenance when it has no turnaround plan.""" + extractor = RoleExtractor() + empty_range = {"lowestNote": "", "highestNote": ""} + roles = extractor._build_roles("", empty_range, "", empty_range) + + topology = extractor._build_topology("verse-1", True, roles) + + assert all("turnaroundPlan" not in role for role in topology["active_roles"]) + assert all("turnaroundPlanSource" not in role for role in topology["active_roles"])