From a2d6dcfe30b416fed65036d7aec448ce171d58b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:25:09 +0000 Subject: [PATCH 01/28] feat(workspace): name tonight's first turnaround plan on the map Name the earliest corroborated shared continuation so the band can turn the last bars together and land the next downbeat. Engine copy is emitted only from mixed-source stem continuation into the next section; cutoff, heuristic topology, and a lone continuing part stay unnamed. Open scrolls the renderer-owned song-structure section. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- ...rstTurnaroundPlanCallout.identity.test.tsx | 21 ++ ...TurnaroundPlanCallout.memoization.test.tsx | 25 ++ ...rstTurnaroundPlanCallout.particle.test.tsx | 129 +++++++ ...tTurnaroundPlanCallout.provenance.test.tsx | 84 +++++ ...naroundPlanCallout.reduced-motion.test.tsx | 43 +++ .../FirstTurnaroundPlanCallout.test.tsx | 252 +++++++++++++ .../workspace/FirstTurnaroundPlanCallout.tsx | 198 ++++++++++ ...roundPlanCallout.unavailable-copy.test.tsx | 34 ++ ...aroundPlanCallout.workspace-scope.test.tsx | 54 +++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../workspace/coverageContract.test.ts | 13 + ...tTurnaroundPlan.inherited-metadata.test.ts | 94 +++++ ...irstTurnaroundPlan.proxy-authority.test.ts | 78 ++++ .../firstTurnaroundPlan.section-label.test.ts | 14 + .../firstTurnaroundPlan.snapshot.test.ts | 41 ++ .../workspace/firstTurnaroundPlan.test.ts | 338 +++++++++++++++++ .../features/workspace/firstTurnaroundPlan.ts | 357 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 50 ++- apps/desktop/src/i18n/index.ts | 36 +- apps/desktop/src/locales/en/common.json | 9 +- apps/desktop/src/locales/ko/common.json | 9 +- apps/desktop/vite.config.ts | 21 +- docs/design-system/component-contract.md | 1 + ...motion-first-turnaround-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 6 + packages/shared-types/test/index.test.ts | 7 + .../src/bandscope_analysis/roles/extractor.py | 73 +++- .../src/bandscope_analysis/roles/model.py | 3 +- services/analysis-engine/tests/test_roles.py | 158 ++++++++ 34 files changed, 2181 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.memoization.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/coverageContract.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.section-label.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.snapshot.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-turnaround-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..4d08d623f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Name tonight's first turnaround plan with the owning part when an active role is corroborated, the owned `turnaroundPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, confirmed overrides, harmonic explanations, or confidence notes. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..8facbd8fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-26 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first turnaround plan must name the owning part when corroborated, the owned `turnaroundPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, confirmed overrides, harmonic explanations, or confidence notes. 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. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 6027f1a81..933de2f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first turnaround plan in the mounted rehearsal workspace so parts that carry a shared continuation into the next section can be named on the map; real analyzed songs now receive this guidance only when section-level stem activity shows at least two distinct sources remaining active together into the next section, while mixed shared continuation is the evidence and heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - 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 82c2c704a..1504a4a18 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). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first turnaround plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, confirmed overrides, harmonic explanations, or confidence notes. 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. `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/FirstTurnaroundPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.identity.test.tsx new file mode 100644 index 000000000..78ac3efe6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +it("gives co-mounted turnaround-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first turnaround plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..a26dff3d3 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.memoization.test.tsx @@ -0,0 +1,25 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +describe("FirstTurnaroundPlanCallout resolver reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not rescan role metadata when a parent rerenders the same song object", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!; + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor"); + + const { rerender } = render(); + const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + expect(firstScanCount).toBeGreaterThan(0); + + rerender(); + const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + + expect(secondScanCount).toBe(firstScanCount); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx new file mode 100644 index 000000000..25bebcb8d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +describe("FirstTurnaroundPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the turnaround action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together." + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:30 벌스에서 피아노 파트의 턴어라운드 계획이 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 턴어라운드 열기" })); + + expect(screen.getByText("0:30에서 피아노 파트의 턴어라운드를 맞춘 다음 합주를 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); + + it("localizes the analysis-engine turnaround template instead of exposing English guidance", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: "Turn these last bars with Lead Vocal; land the downbeat together." + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect( + screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ).toBeTruthy(); + expect( + screen.queryByText("Turn these last bars with Lead Vocal; land the downbeat together.") + ).toBeNull(); + }); + + it("localizes the rest-of-band turnaround template instead of exposing English guidance", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: "Turn these last bars with the rest of the band; land the downbeat together." + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect( + screen.getByText("나머지 밴드와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ).toBeTruthy(); + expect( + screen.queryByText("Turn these last bars with the rest of the band; land the downbeat together.") + ).toBeNull(); + }); + + it("preserves the generated template shape when long target names are bounded", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const targetRole = `Lead-${"A".repeat(180)}`; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: `Turn these last bars with ${targetRole}; land the downbeat together.` + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect(screen.queryByText(/^Turn these last bars with /)).toBeNull(); + expect( + screen.getByText( + (content) => + content.startsWith("Lead-") && content.endsWith("파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx new file mode 100644 index 000000000..093cb863a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx @@ -0,0 +1,84 @@ +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"; + +describe("FirstTurnaroundPlanCallout turnaround-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user turnaround guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const customPlan = "Turn these last bars with Lead Vocal; land the downbeat together."; + section.roles = [ + { + ...section.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: customPlan, + turnaroundPlanSource: "user" + } + ]; + section.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect( + screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ).toBeNull(); + }); + + it("does not infer model authority when persisted turnaround guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const legacyPlan = "Turn these last bars with Lead Vocal; land the downbeat together."; + section.roles = [ + { + ...section.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: legacyPlan + } + ]; + section.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect( + screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ).toBeNull(); + }); + + it("localizes the engine template only when model provenance is explicit", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const generatedPlan = "Turn these last bars with Lead Vocal; land the downbeat together."; + section.roles = [ + { + ...section.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + turnaroundPlan: generatedPlan, + turnaroundPlanSource: "model" + } + ]; + section.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect( + screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + ).toBeTruthy(); + expect(screen.queryByText(generatedPlan)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..d30c7e947 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.reduced-motion.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +describe("FirstTurnaroundPlanCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.test.tsx new file mode 100644 index 000000000..2679374d4 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.test.tsx @@ -0,0 +1,252 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +const DEMO_TURNAROUND_PLAN = + "Turn these last bars with Lead Vocal on the verse last beat; land the chorus downbeat together."; +const appendedSongStructureTargets = new Set(); + +function songWithTurnaroundPlan() { + return createDemoRehearsalSong(); +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstTurnaroundPlanCallout", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No turnaround plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithTurnaroundPlan(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithTurnaroundPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No turnaround plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same turnaround signature", () => { + const firstSong = songWithTurnaroundPlan(); + const nextSong = songWithTurnaroundPlan(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + expect( + screen.getByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar has a shared turnaround in the verse at 0:30.")).toBeTruthy(); + expect( + screen.queryByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithTurnaroundPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + expect( + screen.getByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeTruthy(); + expect(screen.queryByText("Bass Guitar has a shared turnaround in the verse at 0:30.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's turnaround plan under the named landing part", () => { + const song = songWithTurnaroundPlan(); + song.sections[0]!.roles[0]!.turnaroundPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.turnaroundPlan = "Leave the vocal on the last lyric while the turnaround lands."; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.turnaroundPlan = + "Turn these last bars with Lead Vocal; land the downbeat together."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand has a shared turnaround in the verse at 0:30.") + ).toBeTruthy(); + expect( + screen.getByText("Turn these last bars with Lead Vocal; land the downbeat together.") + ).toBeTruthy(); + expect(screen.queryByText("Leave the vocal on the last lyric while the turnaround lands.")).toBeNull(); + expect(screen.queryByText(DEMO_TURNAROUND_PLAN)).toBeNull(); + }); + + it("names the first turnaround plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_TURNAROUND_PLAN)).toBeTruthy(); + 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(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + + 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(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + + expect(screen.getByText("Bass Guitar has a shared turnaround in the verse at 0:30.")).toBeTruthy(); + expect( + screen.queryByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithTurnaroundPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("fails closed when one workspace owns more than one song-structure renderer", () => { + const firstScroll = vi.fn(); + const secondScroll = vi.fn(); + const { container } = render( +
+ +
+
+
+
+
+
+
+ ); + const targets = container.querySelectorAll('[data-section-index="0"]'); + Object.defineProperty(targets[0], "scrollIntoView", { + configurable: true, + value: firstScroll + }); + Object.defineProperty(targets[1], "scrollIntoView", { + configurable: true, + value: secondScroll + }); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + + expect(firstScroll).not.toHaveBeenCalled(); + expect(secondScroll).not.toHaveBeenCalled(); + expect(screen.getByText("Bass Guitar has a shared turnaround in the verse at 0:30.")).toBeTruthy(); + }); + + it("fails closed when more than one song-structure renderer is mounted globally", () => { + const first = appendSongStructureTarget(); + const second = appendSongStructureTarget(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + + expect(first.scrollIntoView).not.toHaveBeenCalled(); + expect(second.scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Bass Guitar has a shared turnaround in the verse at 0:30.")).toBeTruthy(); + + first.grid.remove(); + second.grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx new file mode 100644 index 000000000..df80dce65 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx @@ -0,0 +1,198 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatTurnaroundPlanTime, resolveFirstTurnaroundPlan } from "./firstTurnaroundPlan"; + +/** Props for the first turnaround-plan rehearsal callout. */ +export interface FirstTurnaroundPlanCalloutProps { + song: RehearsalSong; +} + +type TurnaroundPlanCopyValues = Readonly>; + +type OpenedTurnaroundPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + landingRoleId: string; + turnaroundPlan: string; + atSeconds: number; +}>; + +const GENERATED_ACTIVITY_TURNAROUND_PLAN = + /^Turn these last bars with (.+); land the downbeat together\.$/u; +const GENERATED_ACTIVITY_TURNAROUND_PLAN_BAND_TARGET = "the rest of the band"; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableTurnaroundPlanSongIdentity(song: RehearsalSong): unknown { + if (song === null || typeof song !== "object" || Array.isArray(song)) { + return song; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(song, "id"); + } catch { + return song; + } + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ? descriptor.value + : song; +} + +/** Interpolate turnaround-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatTurnaroundPlanCopy(template: string, values: TurnaroundPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof TurnaroundPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize the analysis-engine-owned turnaround sentence while preserving custom role-owned guidance verbatim. */ +function localizedTurnaroundPlan( + turnaroundPlan: string, + generatedTemplate: string, + generatedBandTemplate: string +): string { + const match = GENERATED_ACTIVITY_TURNAROUND_PLAN.exec(turnaroundPlan); + const targetRole = match?.[1]?.trim() ?? ""; + if (targetRole.length === 0) { + return turnaroundPlan; + } + if (targetRole === GENERATED_ACTIVITY_TURNAROUND_PLAN_BAND_TARGET) { + return generatedBandTemplate; + } + return generatedTemplate.replace("{target}", () => targetRole); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredTurnaroundPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveTurnaroundPlanRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first turnaround plan and open the matching rendered map section. */ +export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutProps) { + const calloutId = `workspace-surface-turnaround-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableTurnaroundPlanSongIdentity(song); + const named = useMemo(() => resolveFirstTurnaroundPlan(song), [song]); + const [openedTurnaroundPlan, setOpenedTurnaroundPlan] = useState(null); + + useEffect(() => { + setOpenedTurnaroundPlan(null); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.landingRoleId, + named?.turnaroundPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedTurnaroundPlan !== null && + openedTurnaroundPlan.songIdentity === songIdentity && + openedTurnaroundPlan.sectionId === named.sectionId && + openedTurnaroundPlan.sectionIndex === named.sectionIndex && + openedTurnaroundPlan.landingRoleId === named.landingRoleId && + openedTurnaroundPlan.turnaroundPlan === named.turnaroundPlan && + openedTurnaroundPlan.atSeconds === named.atSeconds; + const at = formatTurnaroundPlanTime(named.atSeconds); + const copyValues: TurnaroundPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatTurnaroundPlanCopy(t("firstTurnaroundPlanOpenAction"), copyValues); + const body = formatTurnaroundPlanCopy(t("firstTurnaroundPlanBody"), copyValues); + const armed = formatTurnaroundPlanCopy(t("firstTurnaroundPlanArmed"), copyValues); + const turnaroundPlan = localizedTurnaroundPlan( + named.turnaroundPlan, + t("firstTurnaroundPlanGeneratedGuidance"), + t("firstTurnaroundPlanGeneratedBandGuidance") + ); + + return ( + + ); +} 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/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..4cd9e70e7 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,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 71546b524..d056d1b2b 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 { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,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)}

@@ -331,6 +336,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/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..7efaab68a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.snapshot.test.ts @@ -0,0 +1,41 @@ +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; + 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]; + section.partGraph = [ + { role_id: roleId, is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [section]; + + 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..cf8d2de2a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts @@ -0,0 +1,338 @@ +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"; + 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: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + 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]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + turnaroundPlan: "Earlier turnaround." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + 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"; + song.sections = [song.sections[0]!, ascii]; + + 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: [] } + ]; + + 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: [] } + ]; + + 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]!.partGraph = [null as never, song.sections[0]!.partGraph[0]!]; + 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..f9c690f6f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts @@ -0,0 +1,357 @@ +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); + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +/** 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; + 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, or null when it cannot be shown. */ +function ownedTurnaroundPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const turnaroundPlan = ownDataValue(role, "turnaroundPlan"); + if (typeof turnaroundPlan !== "string") { + return null; + } + const trimmed = turnaroundPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return ( + boundedGeneratedActivityTurnaroundPlan(trimmed) ?? + truncateCodePoints(trimmed, MAX_TURNAROUND_PLAN_CHARACTERS) + ); +} + +/** 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; +} + +/** 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 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 landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + const turnaroundPlan = ownedTurnaroundPlan(metadata.role); + return turnaroundPlan === null ? [] : [{ ...metadata, turnaroundPlan }]; + }) + ); + 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, + 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 39f716d50..9fb895e8a 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,12 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase 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." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..615306fd2 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,12 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstTurnaroundPlanLabel": "오늘 첫 턴어라운드 계획", + "firstTurnaroundPlanOpenAction": "{at} {role} 턴어라운드 열기", + "firstTurnaroundPlanBody": "{at} {section}에서 {role} 파트의 턴어라운드 계획이 있습니다.", + "firstTurnaroundPlanArmed": "{at}에서 {role} 파트의 턴어라운드를 맞춘 다음 합주를 시작하세요.", + "firstTurnaroundPlanGeneratedGuidance": "{target} 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", + "firstTurnaroundPlanGeneratedBandGuidance": "나머지 밴드와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", + "firstTurnaroundPlanUnavailable": "사용 가능한 턴어라운드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." } 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..c2791aefe 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + turnaroundPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +475,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." @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "turnaroundPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1555,9 @@ 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 (!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..a2481f913 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -738,6 +738,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({ @@ -1257,6 +1258,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/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..77d5eaa50 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,61 @@ 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, + ) + elif other_stem_continuing and role_id not in _OTHER_STEM_ROLE_IDS: + 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 +418,17 @@ 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 + 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..7d91490c9 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,7 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + turnaroundPlan: NotRequired[str] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..db9bdbc47 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -133,3 +133,161 @@ 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 + ) From 4be0d99674ea3451d28b2927a2dafc26bb0563d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:05:58 -0700 Subject: [PATCH 02/28] test(workspace): pin turnaround provenance contract --- .../FirstTurnaroundPlanCallout.particle.test.tsx | 15 +++++++++------ ...FirstTurnaroundPlanCallout.provenance.test.tsx | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx index 25bebcb8d..e83656bbd 100644 --- a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.particle.test.tsx @@ -60,7 +60,8 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { id: "piano", name: "피아노", rehearsalPriority: "high", - turnaroundPlan: "Turn these last bars with Lead Vocal; land the downbeat together." + turnaroundPlan: "Turn these last bars with Lead Vocal; land the downbeat together.", + turnaroundPlanSource: "model" } ]; seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; @@ -68,7 +69,7 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { render(); expect( - screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ).toBeTruthy(); expect( screen.queryByText("Turn these last bars with Lead Vocal; land the downbeat together.") @@ -85,7 +86,8 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { id: "piano", name: "피아노", rehearsalPriority: "high", - turnaroundPlan: "Turn these last bars with the rest of the band; land the downbeat together." + turnaroundPlan: "Turn these last bars with the rest of the band; land the downbeat together.", + turnaroundPlanSource: "model" } ]; seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; @@ -93,7 +95,7 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { render(); expect( - screen.getByText("나머지 밴드와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + screen.getByText("나머지 밴드와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ).toBeTruthy(); expect( screen.queryByText("Turn these last bars with the rest of the band; land the downbeat together.") @@ -111,7 +113,8 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { id: "piano", name: "피아노", rehearsalPriority: "high", - turnaroundPlan: `Turn these last bars with ${targetRole}; land the downbeat together.` + turnaroundPlan: `Turn these last bars with ${targetRole}; land the downbeat together.`, + turnaroundPlanSource: "model" } ]; seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; @@ -122,7 +125,7 @@ describe("FirstTurnaroundPlanCallout Korean role copy", () => { expect( screen.getByText( (content) => - content.startsWith("Lead-") && content.endsWith("파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + content.startsWith("Lead-") && content.endsWith("파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ) ).toBeTruthy(); }); diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx index 093cb863a..ca4d02be3 100644 --- a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.provenance.test.tsx @@ -29,7 +29,7 @@ describe("FirstTurnaroundPlanCallout turnaround-plan provenance", () => { expect(screen.getByText(customPlan)).toBeTruthy(); expect( - screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ).toBeNull(); }); @@ -53,7 +53,7 @@ describe("FirstTurnaroundPlanCallout turnaround-plan provenance", () => { expect(screen.getByText(legacyPlan)).toBeTruthy(); expect( - screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + screen.queryByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ).toBeNull(); }); @@ -77,7 +77,7 @@ describe("FirstTurnaroundPlanCallout turnaround-plan provenance", () => { render(); expect( - screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 마지막 박 뒤로 남기지 마세요.") + screen.getByText("Lead Vocal 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.") ).toBeTruthy(); expect(screen.queryByText(generatedPlan)).toBeNull(); }); From 260d7e1c58484e9fe302d0862d41bd7beb81e213 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:12:18 -0700 Subject: [PATCH 03/28] fix(workspace): require explicit turnaround provenance --- .../workspace/FirstTurnaroundPlanCallout.tsx | 12 +++++- .../features/workspace/firstTurnaroundPlan.ts | 39 +++++++++++++++---- .../src/bandscope_analysis/roles/model.py | 1 + 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx index df80dce65..a527de4bb 100644 --- a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx @@ -14,6 +14,7 @@ export interface FirstTurnaroundPlanCalloutProps { } type TurnaroundPlanCopyValues = Readonly>; +type TurnaroundPlanSource = "model" | "user"; type OpenedTurnaroundPlan = Readonly<{ songIdentity: unknown; @@ -21,6 +22,7 @@ type OpenedTurnaroundPlan = Readonly<{ sectionIndex: number; landingRoleId: string; turnaroundPlan: string; + turnaroundPlanSource: TurnaroundPlanSource | null; atSeconds: number; }>; @@ -55,12 +57,16 @@ function formatTurnaroundPlanCopy(template: string, values: TurnaroundPlanCopyVa }); } -/** Localize the analysis-engine-owned turnaround sentence while preserving custom role-owned guidance verbatim. */ +/** Localize only explicit model-owned engine guidance; preserve user and legacy guidance verbatim. */ function localizedTurnaroundPlan( turnaroundPlan: string, + turnaroundPlanSource: TurnaroundPlanSource | null, generatedTemplate: string, generatedBandTemplate: string ): string { + if (turnaroundPlanSource !== "model") { + return turnaroundPlan; + } const match = GENERATED_ACTIVITY_TURNAROUND_PLAN.exec(turnaroundPlan); const targetRole = match?.[1]?.trim() ?? ""; if (targetRole.length === 0) { @@ -113,6 +119,7 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP named?.sectionId, named?.landingRoleId, named?.turnaroundPlan, + named?.turnaroundPlanSource, named?.atSeconds ]); @@ -138,6 +145,7 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP openedTurnaroundPlan.sectionIndex === named.sectionIndex && openedTurnaroundPlan.landingRoleId === named.landingRoleId && openedTurnaroundPlan.turnaroundPlan === named.turnaroundPlan && + openedTurnaroundPlan.turnaroundPlanSource === named.turnaroundPlanSource && openedTurnaroundPlan.atSeconds === named.atSeconds; const at = formatTurnaroundPlanTime(named.atSeconds); const copyValues: TurnaroundPlanCopyValues = { @@ -150,6 +158,7 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP const armed = formatTurnaroundPlanCopy(t("firstTurnaroundPlanArmed"), copyValues); const turnaroundPlan = localizedTurnaroundPlan( named.turnaroundPlan, + named.turnaroundPlanSource, t("firstTurnaroundPlanGeneratedGuidance"), t("firstTurnaroundPlanGeneratedBandGuidance") ); @@ -187,6 +196,7 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP sectionIndex: named.sectionIndex, landingRoleId: named.landingRoleId, turnaroundPlan: named.turnaroundPlan, + turnaroundPlanSource: named.turnaroundPlanSource, atSeconds: named.atSeconds }); }} diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts index f9c690f6f..49136cce3 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts @@ -15,6 +15,8 @@ const GENERATED_ACTIVITY_TURNAROUND_PLAN_FIXED_CHARACTERS = Array.from( ).length; const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +type TurnaroundPlanSource = "model" | "user"; + type RankedRoleMetadata = Readonly<{ role: RehearsalRole; id: string; @@ -22,6 +24,11 @@ type RankedRoleMetadata = Readonly<{ 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; @@ -32,6 +39,7 @@ export type FirstTurnaroundPlan = { landingRoleId: string; landingRoleName: string; turnaroundPlan: string; + turnaroundPlanSource: TurnaroundPlanSource | null; atSeconds: number; }; @@ -137,23 +145,33 @@ function boundedGeneratedActivityTurnaroundPlan(value: string): string | null { return `${GENERATED_ACTIVITY_TURNAROUND_PLAN_PREFIX}${boundedTarget}${GENERATED_ACTIVITY_TURNAROUND_PLAN_SUFFIX}`; } -/** Return a bounded snapshotted own turnaround plan, or null when it cannot be shown. */ -function ownedTurnaroundPlan(role: unknown): string | null { +/** 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 ( - boundedGeneratedActivityTurnaroundPlan(trimmed) ?? - truncateCodePoints(trimmed, MAX_TURNAROUND_PLAN_CHARACTERS) - ); + 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. */ @@ -317,7 +335,13 @@ function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPla const landingRole = pickLandingRole( rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { const turnaroundPlan = ownedTurnaroundPlan(metadata.role); - return turnaroundPlan === null ? [] : [{ ...metadata, turnaroundPlan }]; + return turnaroundPlan === null + ? [] + : [{ + ...metadata, + turnaroundPlan: turnaroundPlan.text, + turnaroundPlanSource: turnaroundPlan.source + }]; }) ); if (!landingRole) { @@ -333,6 +357,7 @@ function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPla landingRoleId: landingRole.id, landingRoleName: landingRole.name, turnaroundPlan: landingRole.turnaroundPlan, + turnaroundPlanSource: landingRole.turnaroundPlanSource, atSeconds: timeRange.end } ]; diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index 7d91490c9..b6c741cfb 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -84,6 +84,7 @@ class RehearsalRole(TypedDict): manualOverrides: list[ManualOverride] overlapWarnings: list[str] turnaroundPlan: NotRequired[str] + turnaroundPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): From c830b8fa3328a77e871b67284efc2b1688530e6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:13:53 -0700 Subject: [PATCH 04/28] fix(analysis): emit turnaround plan provenance --- .../src/bandscope_analysis/roles/extractor.py | 1 + .../tests/test_turnaround_plan_provenance.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 services/analysis-engine/tests/test_turnaround_plan_provenance.py diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 77d5eaa50..76100e735 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -428,6 +428,7 @@ def _build_activity_topology( 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/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"]) From 4ed460020731fc0827f198ab444d89b4b0535521 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:17:02 -0700 Subject: [PATCH 05/28] fix(shared-types): validate turnaround plan provenance --- packages/shared-types/src/index.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index c2791aefe..69a4c67aa 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -140,6 +140,7 @@ export type RehearsalRole = { setupNote: string; transpositionPlan?: string; turnaroundPlan?: string; + turnaroundPlanSource?: ProvenanceSource; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -1500,6 +1501,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "setupNote", "transpositionPlan", "turnaroundPlan", + "turnaroundPlanSource", "manualOverrides", "overlapWarnings", "transcription", @@ -1558,6 +1560,15 @@ function validateRehearsalRole(value: unknown, path: string): string | null { 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`); } @@ -1712,9 +1723,6 @@ function validateExportSummary(value: unknown, path: string): string | null { if (extraKey) { return extraKey; } - if (!isOneOf(EXPORT_FORMATS, value.format)) { - return invalidField(`${path}.format`); - } if (typeof value.headline !== "string") { return invalidField(`${path}.headline`); } From 9beb20ee9e23ba8fc2c6471f3059f29c2d9eab06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:22:29 -0700 Subject: [PATCH 06/28] fix(shared-types): preserve export format validation --- packages/shared-types/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 69a4c67aa..8bc73757b 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1723,6 +1723,9 @@ function validateExportSummary(value: unknown, path: string): string | null { if (extraKey) { return extraKey; } + if (!isOneOf(EXPORT_FORMATS, value.format)) { + return invalidField(`${path}.format`); + } if (typeof value.headline !== "string") { return invalidField(`${path}.headline`); } From 63cc39f62d4836f5f7d0f364878df8b5ec717ee4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:22:53 -0700 Subject: [PATCH 07/28] test(shared-types): cover turnaround plan provenance --- .../test/turnaroundPlanProvenance.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/shared-types/test/turnaroundPlanProvenance.test.ts 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/); + }); +}); From e543306b2d49577fc43e8572cd6d2d692dc2b436 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:30:01 -0700 Subject: [PATCH 08/28] test(workspace): require turnaround continuation evidence --- .../firstTurnaroundPlan.continuation.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts 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..c8dc49692 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts @@ -0,0 +1,38 @@ +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(); + }); +}); From 949e5d62ee5d3a9b7c4b501702b4e1f33522252d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:32:45 -0700 Subject: [PATCH 09/28] fix(workspace): require next-section turnaround evidence --- .../src/features/workspace/firstTurnaroundPlan.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts index 49136cce3..d40fd65e9 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts @@ -319,6 +319,10 @@ function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPla 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); @@ -332,8 +336,14 @@ function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPla return []; } + const continuingRoleIds = new Set( + rankedActiveRoles(nextSection as RehearsalSection).map((metadata) => metadata.id) + ); const landingRole = pickLandingRole( rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if (!continuingRoleIds.has(metadata.id)) { + return []; + } const turnaroundPlan = ownedTurnaroundPlan(metadata.role); return turnaroundPlan === null ? [] From d27b92024f92eb9ba7bd66257bf3a7228d73c9ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:36:06 -0700 Subject: [PATCH 10/28] test(workspace): align turnaround fixtures with continuation contract --- .../workspace/firstTurnaroundPlan.test.ts | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts index cf8d2de2a..486d0fd58 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts @@ -62,7 +62,26 @@ function withTurnaroundSection( handoff_from: [] } ]; - song.sections = [section]; + + 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; } @@ -165,7 +184,16 @@ describe("resolveFirstTurnaroundPlan", () => { ]; earlier.timeRange = { start: 8, end: 24 }; earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; - song.sections = [song.sections[0]!, earlier]; + 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"); @@ -178,7 +206,8 @@ describe("resolveFirstTurnaroundPlan", () => { const song = withTurnaroundSection({ id: "ä-turnaround", start: 10, end: 26 }); const ascii = structuredClone(song.sections[0]!); ascii.id = "z-turnaround"; - song.sections = [song.sections[0]!, ascii]; + const continuation = song.sections[1]!; + song.sections = [song.sections[0]!, ascii, continuation]; expect(resolveFirstTurnaroundPlan(song)?.section.id).toBe("z-turnaround"); }); @@ -203,6 +232,14 @@ describe("resolveFirstTurnaroundPlan", () => { { 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."); @@ -222,6 +259,14 @@ describe("resolveFirstTurnaroundPlan", () => { { 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."); From 0beffa614e94bfdc91be2b88f0cbf0849315e4c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:38:22 -0700 Subject: [PATCH 11/28] test(workspace): reject lone turnaround continuation --- .../firstTurnaroundPlan.continuation.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts index c8dc49692..f7a88fbad 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.continuation.test.ts @@ -35,4 +35,39 @@ describe("resolveFirstTurnaroundPlan continuation authority", () => { 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(); + }); }); From 3c174266c25663dbe6f0bf1356fdc981a19f7932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:39:53 -0700 Subject: [PATCH 12/28] fix(workspace): require shared turnaround continuation --- .../features/workspace/firstTurnaroundPlan.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts index d40fd65e9..462ee46a0 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.ts @@ -14,6 +14,12 @@ 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"; @@ -239,6 +245,11 @@ function repeatedIds(ids: string[]): Set { 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) { @@ -336,11 +347,22 @@ function resolveSafeFirstTurnaroundPlan(song: RehearsalSong): FirstTurnaroundPla return []; } - const continuingRoleIds = new Set( + 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( - rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + activeRoles.flatMap((metadata) => { if (!continuingRoleIds.has(metadata.id)) { return []; } From 8015d8b3a2d85a17a4cf99d2d0285e5e7b4cccf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:41:03 -0700 Subject: [PATCH 13/28] test(workspace): model shared turnaround evidence --- .../workspace/firstTurnaroundPlan.test.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts index 486d0fd58..d26f5a3b0 100644 --- a/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts +++ b/apps/desktop/src/features/workspace/firstTurnaroundPlan.test.ts @@ -27,6 +27,10 @@ function withTurnaroundSection( 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]!, @@ -52,7 +56,8 @@ function withTurnaroundSection( }, turnaroundPlan: overrides.turnaroundPlan ?? DEMO_TURNAROUND_PLAN, manualOverrides: [] - } + }, + companionRole ]; section.partGraph = [ { @@ -60,6 +65,12 @@ function withTurnaroundSection( is_active: overrides.isActive ?? true, handoff_to: [], handoff_from: [] + }, + { + role_id: companionRole.id, + is_active: true, + handoff_to: [], + handoff_from: [] } ]; @@ -172,6 +183,9 @@ describe("resolveFirstTurnaroundPlan", () => { 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 = [ { @@ -180,10 +194,14 @@ describe("resolveFirstTurnaroundPlan", () => { 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: [] }]; + 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 }; @@ -305,8 +323,16 @@ describe("resolveFirstTurnaroundPlan", () => { 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]!.partGraph = [null as never, song.sections[0]!.partGraph[0]!]; + 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"); }); From ad179d9a7252e41c0b7e4ac1efaa7969e0dbd8b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:45:31 -0700 Subject: [PATCH 14/28] test(workspace): surface turnaround navigation failure --- ...undPlanCallout.navigation-failure.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.navigation-failure.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..404c01e66 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.navigation-failure.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstTurnaroundPlanCallout } from "./FirstTurnaroundPlanCallout"; + +describe("FirstTurnaroundPlanCallout navigation failure", () => { + it("tells the user when the named turnaround cannot be opened on the rendered map", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar turnaround at 0:30" })); + + expect( + screen.getByText( + "Could not open this turnaround on the song map. Use the map below to find the section." + ) + ).toBeTruthy(); + expect( + screen.queryByText(/Turn those last bars on Bass Guitar at 0:30 before the next section lands./) + ).toBeNull(); + }); +}); From b472c6755e1f363aa1f43bb3c9d6f2c46adeed6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:46:43 -0700 Subject: [PATCH 15/28] fix(workspace): explain turnaround map navigation failure --- .../features/workspace/FirstTurnaroundPlanCallout.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx index a527de4bb..4afa0be5d 100644 --- a/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTurnaroundPlanCallout.tsx @@ -110,9 +110,11 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP const songIdentity = stableTurnaroundPlanSongIdentity(song); const named = useMemo(() => resolveFirstTurnaroundPlan(song), [song]); const [openedTurnaroundPlan, setOpenedTurnaroundPlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); useEffect(() => { setOpenedTurnaroundPlan(null); + setNavigationFailed(false); }, [ songIdentity, named?.sectionIndex, @@ -184,8 +186,10 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP `[data-section-index="${named.sectionIndex}"]` ) ?? null; if (typeof target?.scrollIntoView !== "function") { + setNavigationFailed(true); return; } + setNavigationFailed(false); target.scrollIntoView({ block: "nearest", behavior: preferredTurnaroundPlanScrollBehavior() @@ -203,6 +207,11 @@ export function FirstTurnaroundPlanCallout({ song }: FirstTurnaroundPlanCalloutP > {actionLabel} + {navigationFailed ? ( +

+ {t("firstTurnaroundPlanNavigationFailed")} +

+ ) : null} ); } From 8bbf5b6cc1c64147c7cb868ee8f4107ad67b31da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:47:57 -0700 Subject: [PATCH 16/28] feat(i18n): explain turnaround navigation failure --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d00445a30..089e86fff 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -156,6 +156,7 @@ "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}.", From f9762dde2844a6d232ef3c07b19d9ce393f1ae96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:49:02 -0700 Subject: [PATCH 17/28] feat(i18n): localize turnaround navigation failure --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 02296864e..567e6e91c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -156,6 +156,7 @@ "firstTurnaroundPlanGeneratedGuidance": "{target} 파트와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", "firstTurnaroundPlanGeneratedBandGuidance": "나머지 밴드와 이 턴어라운드를 맞추세요. 다음 섹션 첫 박에 함께 들어가세요.", "firstTurnaroundPlanUnavailable": "사용 가능한 턴어라운드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstTurnaroundPlanNavigationFailed": "곡 맵에서 이 턴어라운드를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", From 905098a6d0ac79f98eba0892f75d9e9fea00ff9e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 22:30:56 +0900 Subject: [PATCH 18/28] docs(shared-types): describe RehearsalRole guidance metadata contract --- packages/shared-types/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 8bc73757b..d2b2cf129 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; From 97791d546877e426251d14c247ec89c93d3a36f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:18:28 -0700 Subject: [PATCH 19/28] fix(turnaround): satisfy pinned Ruff line length --- .../src/bandscope_analysis/roles/extractor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 76100e735..090965bf8 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -374,7 +374,10 @@ def _activity_turnaround_plan( if source_count < 2: return None if source_count >= 3: - return f"{_TURNAROUND_PLAN_PREFIX}{_TURNAROUND_PLAN_BAND_TARGET}{_TURNAROUND_PLAN_SUFFIX}" + return ( + f"{_TURNAROUND_PLAN_PREFIX}{_TURNAROUND_PLAN_BAND_TARGET}" + f"{_TURNAROUND_PLAN_SUFFIX}" + ) partner_ids = [candidate_id for candidate_id in named_source_ids if candidate_id != role_id] other_name: str | None = None From 88fc722dd0a2130763508020f0105c94ed5c76b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:06:47 -0700 Subject: [PATCH 20/28] test(turnaround): reproduce inert demo plan --- .../workspace/firstTurnaroundPlan.demo.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstTurnaroundPlan.demo.test.ts 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 + }); + }); +}); From ab026be10e8fdd120561df7990086ae1436f9766 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 16:28:43 -0700 Subject: [PATCH 21/28] docs(accessibility): remove conflicting wrapper role guidance --- .jules/palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index 98395b1e4..b8e035cee 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -28,7 +28,7 @@ ## 2024-06-29 - 비활성화된 네이티브 버튼의 툴팁 차단 **Learning:** 네이티브 `