diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..e5bf1f5a9 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 pickup with the incoming part, outgoing partner when the graph corroborates it, section, and time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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 ca0df5ac4..65ba48c47 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Workspace and player copy for tonight's first pickup must name the incoming part, outgoing partner when corroborated, section, and time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..3751efcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first pickup on the workspace and player so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..15bc67511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first pickup so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/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/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..9c4cc67ba --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first pickup from this player.") + ).toBeTruthy(); + }); + + it("keeps the pickup hear action unavailable without a player playback callback", () => { + render(); + + expect( + screen.queryByRole("button", { + name: "Hear Lead Vocal pick up from Bass Guitar at 0:30" + }) + ).toBeNull(); + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + }); + + it("delegates the pickup hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Lead Vocal pick up from Bass Guitar at 0:30" + }) + ); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(30); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..a47c263b3 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,14 +1,22 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstPickupCallout } from "../workspace/FirstPickupCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Player surface that names tonight's first pickup and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

No song loaded. Start an analysis to use the player.

+

{t("firstPickupNeedsSong")}

); } @@ -16,12 +24,18 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul return (

{title}

+
@@ -31,9 +45,9 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
- {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( { + it("renders unavailable guidance instead of crashing when the runtime song root is null", () => { + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.") + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.localization.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.localization.test.tsx new file mode 100644 index 000000000..a6be57dec --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.localization.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPickupCallout } from "./FirstPickupCallout"; + +describe("FirstPickupCallout section-form localization", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses particle-safe Korean section-form copy instead of exposing the raw verse enum", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "베이스 기타"; + song.sections[0]!.roles[2]!.name = "피아노"; + + render(); + + expect( + screen.getByText("0:30 벌스 끝에서 피아노 파트가 베이스 기타의 넘김을 받습니다.") + ).toBeTruthy(); + expect(screen.queryByText("피아노이 0:30 벌스 끝에서 베이스 기타의 넘김을 받습니다.")).toBeNull(); + expect(screen.queryByText(/verse 끝에서/)).toBeNull(); + }); + + it("localizes an explicit pickup form without changing its domain label or guessing Hangul particles", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.id = "pickup-localized"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[2]!, + id: "lead-vocal-pickup", + name: "피아노" + } + ]; + section.partGraph = [ + { + role_id: "lead-vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + + render(); + + expect(screen.getByText("0:08 픽업에서 피아노 파트가 픽업합니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:08 픽업에서 픽업합니다.")).toBeNull(); + expect(section.label).toBe("pickup"); + expect(screen.queryByText(/pickup에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx new file mode 100644 index 000000000..9fc302c3d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.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 { FirstPickupCallout } from "./FirstPickupCallout"; + +describe("FirstPickupCallout renderer-owned action completion", () => { + it("does not arm the pickup when the renderer-owned section target is missing", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + expect( + screen.queryByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..b4b214e47 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.reduced-motion.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPickupCallout } from "./FirstPickupCallout"; + +describe("FirstPickupCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal("matchMedia", matchMedia); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + 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 Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx new file mode 100644 index 000000000..2b097e57b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstPickupCallout } from "./FirstPickupCallout"; + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstPickupCallout", () => { + it("names the first pickup as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + + grid.remove(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearPickup = vi.fn(); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect(onHearPickup).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first pickup changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + + const replacementSong = createDemoRehearsalSong(); + replacementSong.id = "demo-song-replacement"; + replacementSong.sections[0]!.roles[2]!.name = "Lead Harmony"; + rerender(); + expect(screen.getByText("Lead Harmony picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2]!.name = "{section}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Open {section} pickup from Bass Guitar at 0:30" + }) + ).toBeTruthy(); + }); + + it("tells the room to stay on the map when no pickup exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect( + screen.getByText("No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.") + ).toBeTruthy(); + }); + + it("names a labeled pickup section without inventing a partner", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + id: "pickup-1", + label: "pickup", + timeRange: { start: 8, end: 10 }, + roles: [ + { + ...verse.roles[2]!, + id: "lead-vocal-pickup", + name: "Lead Vocal" + } + ], + partGraph: [ + { + role_id: "lead-vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + } + ]; + + render(); + expect(screen.getByText("Lead Vocal picks up the pickup at 0:08.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Lead Vocal pickup at 0:08" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx new file mode 100644 index 000000000..af9f03bc7 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatPickupTime, resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Props for the first-pickup rehearsal callout. */ +export interface FirstPickupCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearPickup?: (atSeconds: number) => void; +} + +type PickupCopyValues = Readonly>; + +type HeardPickup = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + fromRoleId: string | null; + toRoleId: string; + atSeconds: number; +}>; + +/** Interpolate pickup placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatPickupCopy(template: string, values: PickupCopyValues): string { + return template.replace(/\{(from|to|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof PickupCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredPickupScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first pickup and offer only an action that the current surface can execute. */ +export function FirstPickupCallout({ + song, + actionMode = "workspace-scroll", + onHearPickup +}: FirstPickupCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const pickup = resolveFirstPickupHandoff(song); + const pickupSectionIndex = pickup ? song.sections.indexOf(pickup.section) : -1; + const [heardPickup, setHeardPickup] = useState(null); + + useEffect(() => { + setHeardPickup(null); + }, [ + song?.id, + pickupSectionIndex, + pickup?.section.id, + pickup?.fromRole?.id, + pickup?.toRole.id, + pickup?.atSeconds + ]); + + if (!pickup) { + return ( + + ); + } + + const heard = + heardPickup?.songId === song.id && + heardPickup.sectionId === pickup.section.id && + heardPickup.sectionIndex === pickupSectionIndex && + heardPickup.fromRoleId === (pickup.fromRole?.id ?? null) && + heardPickup.toRoleId === pickup.toRole.id && + heardPickup.atSeconds === pickup.atSeconds; + const at = formatPickupTime(pickup.atSeconds); + const copyValues: PickupCopyValues = { + from: pickup.fromRole?.name ?? "", + to: pickup.toRole.name, + section: translateSectionFormLabel(locale, pickup.section.label), + at + }; + const hasFrom = pickup.fromRole !== null; + const actionLabel = formatPickupCopy( + t( + actionMode === "callback-only" + ? hasFrom + ? "firstPickupAction" + : "firstPickupActionSolo" + : hasFrom + ? "firstPickupOpenAction" + : "firstPickupOpenActionSolo" + ), + copyValues + ); + const body = formatPickupCopy(t(hasFrom ? "firstPickupBody" : "firstPickupBodySolo"), copyValues); + const armed = formatPickupCopy(t(hasFrom ? "firstPickupArmed" : "firstPickupArmedSolo"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearPickup === "function"; + /** Record completion only after the owning surface has executed the selected pickup action. */ + const markPickupActionComplete = () => { + setHeardPickup({ + songId: song.id, + sectionId: pickup.section.id, + sectionIndex: pickupSectionIndex, + fromRoleId: pickup.fromRole?.id ?? null, + toRoleId: pickup.toRole.id, + atSeconds: pickup.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..217d423fd 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,37 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); -}); + + it("keeps analysis section ids out of song-structure DOM authority", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + + render(); + + const firstRenderedSection = screen.getByTestId("song-structure-grid").children.item(0); + expect(firstRenderedSection).toBeTruthy(); + expect(firstRenderedSection?.hasAttribute("id")).toBe(false); + }); + + it("names tonight's first pickup as workspace navigation", () => { + render(); + + const section = screen.getByTestId("song-structure-grid").children.item(0); + expect(section).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(section!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }); + fireEvent.click(action); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..048cb9536 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 { FirstPickupCallout } from "./FirstPickupCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -91,8 +92,8 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -353,6 +354,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts new file mode 100644 index 000000000..f8bb22f01 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstPickupHandoff activity-type authority", () => { + it("does not treat a string false flag as an active labeled pickup receiver", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("active-bass"); + }); + + it("does not treat a string false flag as an active labeled pickup source", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[0]!, + id: "resting-bass", + name: "Resting Bass", + rehearsalPriority: "medium" + }, + { + ...section.roles[2]!, + id: "pickup-vocal", + name: "Pickup Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-bass", + is_active: runtimeStringFalse, + handoff_to: ["pickup-vocal"], + handoff_from: [] + }, + { + role_id: "pickup-vocal", + is_active: true, + handoff_to: [], + handoff_from: ["resting-bass"] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.toRole.id).toBe("pickup-vocal"); + expect(pickup?.fromRole).toBeNull(); + }); + + it("does not treat a string false flag as an active generic handoff source", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.partGraph = section.partGraph.map((node) => + node.role_id === "bass-guitar" ? { ...node, is_active: runtimeStringFalse } : node + ); + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.duplicate-local-id.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.duplicate-local-id.test.ts new file mode 100644 index 000000000..46054e055 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.duplicate-local-id.test.ts @@ -0,0 +1,42 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff section-local identity authority", () => { + it("rejects a labeled pickup when the selected role identity is duplicated", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const selectedRole = section.roles[2]!; + section.label = "pickup"; + section.roles = [selectedRole, { ...selectedRole, name: "Duplicate Lead" }]; + section.partGraph = [ + { + role_id: selectedRole.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("rejects a labeled pickup when graph authority for the selected role is duplicated", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const selectedRole = section.roles[2]!; + const activeNode = { + role_id: selectedRole.id, + is_active: true, + handoff_to: [], + handoff_from: [] + }; + section.label = "pickup"; + section.roles = [selectedRole]; + section.partGraph = [activeNode, { ...activeNode }]; + song.sections = [section]; + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts new file mode 100644 index 000000000..4e47b7405 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff labeled pickup receiver authority", () => { + it("does not announce an inactive higher-priority role as the labeled pickup", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.toRole.id).toBe("active-bass"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts new file mode 100644 index 000000000..d0540cb15 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff labeled pickup authority", () => { + it("does not name an inactive outgoing role as the source of a labeled pickup", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[0]!, + id: "resting-bass", + name: "Bass Guitar", + rehearsalPriority: "medium" + }, + { + ...section.roles[2]!, + id: "pickup-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-bass", + is_active: false, + handoff_to: ["pickup-vocal"], + handoff_from: [] + }, + { + role_id: "pickup-vocal", + is_active: true, + handoff_to: [], + handoff_from: ["resting-bass"] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.toRole.id).toBe("pickup-vocal"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..a7f6634d6 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff runtime role identity", () => { + it("ignores an active pickup role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + + const safeRole = { + ...section.roles[2]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "high" as const + }; + const malformedRole = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("safe-vocal"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-name.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-name.test.ts new file mode 100644 index 000000000..2ed26420c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-name.test.ts @@ -0,0 +1,31 @@ +import { createDemoRehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff role display authority", () => { + it("does not select a higher-priority active role whose runtime name is not usable copy", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const invalidHigh = { + ...section.roles[0]!, + id: "invalid-high", + name: 42, + rehearsalPriority: "high" + } as unknown as RehearsalRole; + const validMedium = { + ...section.roles[2]!, + id: "valid-medium", + name: "Lead Vocal", + rehearsalPriority: "medium" + } satisfies RehearsalRole; + section.label = "pickup"; + section.roles = [invalidHigh, validMedium]; + section.partGraph = [ + { role_id: invalidHigh.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: validMedium.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [section]; + + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe(validMedium.id); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts new file mode 100644 index 000000000..14e0bf450 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts @@ -0,0 +1,38 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Cast runtime input through the static song contract to exercise the resolver trust boundary. */ +function runtimeSong(value: unknown): RehearsalSong { + return value as RehearsalSong; +} + +/** Replace the section collection with untrusted runtime evidence. */ +function songWithRuntimeSections(sections: unknown): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = sections as RehearsalSong["sections"]; + return song; +} + +describe("resolveFirstPickupHandoff runtime section collection", () => { + it("fails closed when the runtime song root is null", () => { + const song = runtimeSong(null); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("fails closed when the runtime section collection is not an array", () => { + const song = songWithRuntimeSections(null); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements instead of dereferencing them", () => { + const song = songWithRuntimeSections([null, 42]); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-members.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-members.test.ts new file mode 100644 index 000000000..912596908 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-members.test.ts @@ -0,0 +1,44 @@ +import { createDemoRehearsalSong, type RehearsalSection } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Force the demo verse through the labeled-pickup path for runtime-boundary tests. */ +function labeledPickupSection(): RehearsalSection { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.label = "pickup"; + return section; +} + +describe("resolveFirstPickupHandoff runtime section members", () => { + it("fails closed when a labeled pickup has a non-array role collection", () => { + const song = createDemoRehearsalSong(); + const section = labeledPickupSection(); + section.roles = null as unknown as RehearsalSection["roles"]; + song.sections = [section]; + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("fails closed when a labeled pickup has a non-array graph collection", () => { + const song = createDemoRehearsalSong(); + const section = labeledPickupSection(); + section.partGraph = null as unknown as RehearsalSection["partGraph"]; + song.sections = [section]; + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("ignores malformed role and graph elements instead of dereferencing them", () => { + const song = createDemoRehearsalSong(); + const section = labeledPickupSection(); + section.roles = [null, 42] as unknown as RehearsalSection["roles"]; + section.partGraph = [null, 42] as unknown as RehearsalSection["partGraph"]; + song.sections = [section]; + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-time-range.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-time-range.test.ts new file mode 100644 index 000000000..42b8ac5bc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-time-range.test.ts @@ -0,0 +1,33 @@ +import { + MAX_SECTION_TIME_SECONDS, + createDemoRehearsalSong, + type RehearsalSection +} from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Build one otherwise-valid labeled pickup with a caller-supplied runtime window. */ +function songWithPickupWindow(start: number, end: number) { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.label = "pickup"; + section.timeRange = { start, end } as RehearsalSection["timeRange"]; + song.sections = [section]; + return song; +} + +describe("resolveFirstPickupHandoff time-range contract", () => { + it("rejects fractional pickup windows", () => { + expect(resolveFirstPickupHandoff(songWithPickupWindow(10.5, 11.5))).toBeNull(); + }); + + it("rejects zero-duration pickup windows", () => { + expect(resolveFirstPickupHandoff(songWithPickupWindow(10, 10))).toBeNull(); + }); + + it("rejects pickup windows beyond the shared section-time ceiling", () => { + expect( + resolveFirstPickupHandoff(songWithPickupWindow(10, MAX_SECTION_TIME_SECONDS + 1)) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts new file mode 100644 index 000000000..59ea7f6c1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatPickupTime, resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff", () => { + it("picks the incoming catch of the earliest explicit handoff", () => { + const song = createDemoRehearsalSong(); + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.section.id).toBe("verse-1"); + expect(pickup?.fromRole?.id).toBe("bass-guitar"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + expect(pickup?.atSeconds).toBe(30); + expect(formatPickupTime(pickup?.atSeconds ?? -1)).toBe("0:30"); + expect(formatPickupTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no part is ready to pick up", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("prefers an explicit pickup section over a later incoming handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const pickupSection = structuredClone(verse); + pickupSection.id = "pickup-1"; + pickupSection.label = "pickup"; + pickupSection.timeRange = { start: 8, end: 10 }; + pickupSection.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal-pickup", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + pickupSection.partGraph = [ + { + role_id: "lead-vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, pickupSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("pickup-1"); + expect(pickup?.toRole.id).toBe("lead-vocal-pickup"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); + + it("keeps a labeled pickup's incoming partner when the graph corroborates it", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const pickupSection = structuredClone(verse); + pickupSection.id = "pickup-1"; + pickupSection.label = "pickup"; + pickupSection.timeRange = { start: 8, end: 10 }; + pickupSection.roles = [ + { + ...verse.roles[0]!, + id: "bass-pickup", + name: "Bass Guitar", + rehearsalPriority: "medium" + }, + { + ...verse.roles[2]!, + id: "vocal-pickup", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + pickupSection.partGraph = [ + { + role_id: "bass-pickup", + is_active: true, + handoff_to: ["vocal-pickup"], + handoff_from: [] + }, + { + role_id: "vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: ["bass-pickup"] + } + ]; + song.sections = [pickupSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.toRole.id).toBe("vocal-pickup"); + expect(pickup?.fromRole?.id).toBe("bass-pickup"); + expect(pickup?.atSeconds).toBe(8); + }); + + it("skips an earlier section that only has inactive or empty handoff lists", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "chorus-1"; + later.label = "chorus"; + later.timeRange = { start: 40, end: 70 }; + later.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar-chorus", + name: "Bass Guitar" + }, + { + ...verse.roles[2]!, + id: "lead-vocal-chorus", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "bass-guitar-chorus", + is_active: true, + handoff_to: ["lead-vocal-chorus"], + handoff_from: [] + }, + { + role_id: "lead-vocal-chorus", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar-chorus"] + } + ]; + song.sections = [ + { + ...verse, + partGraph: [ + { + role_id: "bass-guitar", + is_active: false, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "keys-right", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }, + later + ]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("chorus-1"); + expect(pickup?.fromRole?.id).toBe("bass-guitar-chorus"); + expect(pickup?.toRole.id).toBe("lead-vocal-chorus"); + expect(pickup?.atSeconds).toBe(70); + }); + + it("does not resolve a section pickup against a role that exists only in another section", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + } + ]; + + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 70 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "chorus-bass", + rehearsalPriority: "medium" + }, + { + ...chorus.roles[2]!, + id: "future-lead", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "chorus-bass", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + }, + { + role_id: "future-lead", + is_active: false, + handoff_to: [], + handoff_from: ["chorus-bass"] + } + ]; + song.sections = [verse, chorus]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("chorus-1"); + expect(pickup?.fromRole?.id).toBe("chorus-bass"); + expect(pickup?.toRole.id).toBe("future-lead"); + }); + + it("rejects an outgoing handoff that the target node does not corroborate", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, handoff_from: [] } : node + ); + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("accepts a reciprocal receiver that is inactive until the next section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ); + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.fromRole?.id).toBe("bass-guitar"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + }); + + it("prefers the higher-priority incoming part when two pickups share a section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections[0] = { + ...verse, + roles: verse.roles.map((role) => + role.id === "keys-right" ? { ...role, rehearsalPriority: "low" as const } : role + ), + partGraph: [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["keys-right", "lead-vocal"], + handoff_from: [] + }, + { + role_id: "keys-right", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar"] + }, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar"] + } + ] + }; + + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("lead-vocal"); + }); + + it("prefers the earlier pickup when a later section also hands off", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "bridge-1"; + later.label = "bridge"; + later.timeRange = { start: 80, end: 100 }; + later.roles = [ + { + ...verse.roles[1]!, + id: "keys-bridge", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high" + }, + { + ...verse.roles[2]!, + id: "vocal-bridge", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "keys-bridge", + is_active: true, + handoff_to: ["vocal-bridge"], + handoff_from: [] + }, + { + role_id: "vocal-bridge", + is_active: false, + handoff_to: [], + handoff_from: ["keys-bridge"] + } + ]; + song.sections = [verse, later]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("verse-1"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + expect(pickup?.atSeconds).toBe(30); + }); + + it("skips non-finite ends, unknown priorities, missing roles, and self-handoffs", () => { + const song = createDemoRehearsalSong(); + const invalidEnd = structuredClone(song.sections[0]!); + invalidEnd.id = "invalid-end"; + invalidEnd.timeRange = { start: 0, end: Number.NaN }; + invalidEnd.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + } + ]; + + const validSection = structuredClone(song.sections[0]!); + validSection.id = "valid-chorus"; + validSection.label = "chorus"; + validSection.timeRange = { start: 20, end: 50 }; + const invalidPriorityRole = { + ...validSection.roles[0]!, + id: "invalid-priority" + }; + (invalidPriorityRole as unknown as { rehearsalPriority: string }).rehearsalPriority = "urgent"; + validSection.roles = [ + invalidPriorityRole, + { + ...validSection.roles[2]!, + id: "safe-lead", + rehearsalPriority: "high" + }, + { + ...validSection.roles[0]!, + id: "safe-bass", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + validSection.partGraph = [ + { + role_id: "missing-role", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "invalid-priority", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "safe-bass", + is_active: true, + handoff_to: ["safe-bass", " ", "nobody", "safe-lead"], + handoff_from: [] + }, + { + role_id: "safe-lead", + is_active: false, + handoff_to: [], + handoff_from: ["safe-bass"] + } + ]; + + song.sections = [invalidEnd, validSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("valid-chorus"); + expect(pickup?.fromRole?.id).toBe("safe-bass"); + expect(pickup?.toRole.id).toBe("safe-lead"); + expect(pickup?.atSeconds).toBe(50); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts new file mode 100644 index 000000000..71a3dff5f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -0,0 +1,287 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first pickup: the incoming part that catches a handoff or a labeled pickup section. */ +export type FirstPickupHandoff = { + section: RehearsalSection; + fromRole: RehearsalRole | null; + toRole: RehearsalRole; + atSeconds: number; +}; + +/** Format a non-negative pickup time as m:ss for rehearsal copy. */ +export function formatPickupTime(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}`; +} + +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return whether every numeric index is present in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!(index in value)) { + return false; + } + } + return true; +} + +/** Return whether safe non-empty identities occur at most once in a collection. */ +function hasUniqueIds(ids: string[]): boolean { + return new Set(ids).size === ids.length; +} + +/** Return whether section-local roles and graph nodes are dense, inspectable, and unambiguous. */ +function hasSafeSectionMembers(section: RehearsalSection): boolean { + if ( + !isDenseRuntimeArray(section.roles) || + !section.roles.every(isRuntimeObject) || + !isDenseRuntimeArray(section.partGraph) || + !section.partGraph.every(isRuntimeObject) + ) { + return false; + } + const roleIds = section.roles + .map((role) => role.id) + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0); + const graphRoleIds = section.partGraph + .map((node) => node.role_id) + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0); + return hasUniqueIds(roleIds) && hasUniqueIds(graphRoleIds); +} + +/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + typeof role.name === "string" && + role.name.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Require an active outgoing graph node to authorize a handoff source. */ +function hasActiveOutgoingHandoff( + section: RehearsalSection, + fromRoleId: string, + toRoleId: string +): boolean { + return section.partGraph.some( + (candidate) => + candidate.role_id === fromRoleId && + candidate.is_active === true && + Array.isArray(candidate.handoff_to) && + candidate.handoff_to.includes(toRoleId) + ); +} + +/** Require the receiving graph node to corroborate the outgoing edge. */ +function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { + return section.partGraph.some( + (candidate) => + candidate.role_id === toRoleId && + Array.isArray(candidate.handoff_from) && + candidate.handoff_from.includes(fromRoleId) + ); +} + +/** Return ranked roles in a section, optionally requiring an active graph node. */ +function rankedRolesInSection(section: RehearsalSection, requireActive: boolean): RehearsalRole[] { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const activeIds = new Set( + section.partGraph.filter((node) => node.is_active === true).map((node) => node.role_id) + ); + + return section.roles.filter((role) => { + if (!hasRankedPriority(role) || rolesInSection.get(role.id) !== role) { + return false; + } + return !requireActive || activeIds.has(role.id); + }); +} + +/** Prefer the highest-priority ranked role, then a stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Resolve an incoming corroborating partner for a pickup role, if one exists. */ +function resolveIncomingPartner(section: RehearsalSection, toRole: RehearsalRole): RehearsalRole | null { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const incomingIds = section.partGraph + .filter((node) => node.role_id === toRole.id && Array.isArray(node.handoff_from)) + .flatMap((node) => node.handoff_from); + + const partners = incomingIds + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) + .map((roleId) => rolesInSection.get(roleId) ?? null) + .filter( + (role): role is RehearsalRole => + role !== null && + hasRankedPriority(role) && + role.id !== toRole.id && + hasActiveOutgoingHandoff(section, role.id, toRole.id) && + hasReciprocalHandoff(section, role.id, toRole.id) + ); + + return pickHighestPriorityRole(partners); +} + +/** Return whether a section obeys the shared bounded positive integer rehearsal-window contract. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!isRuntimeObject(section.timeRange)) { + return false; + } + const start = section.timeRange.start; + const end = section.timeRange.end; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Prefer an explicit pickup form label before falling back to an incoming handoff. */ +function resolveLabeledPickupSection(song: RehearsalSong): FirstPickupHandoff | null { + const pickupSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "pickup" && + hasBoundedTimeRange(section) && + hasSafeSectionMembers(section) + ) + .sort((left, right) => left.timeRange.start - right.timeRange.start); + + for (const section of pickupSections) { + const toRole = pickHighestPriorityRole(rankedRolesInSection(section, true)); + if (!toRole) { + continue; + } + return { + section, + fromRole: resolveIncomingPartner(section, toRole), + toRole, + atSeconds: section.timeRange.start + }; + } + + return null; +} + +/** Return the first validated incoming pickup, or null when no safe candidate remains. */ +export function resolveFirstPickupHandoff(song: RehearsalSong): FirstPickupHandoff | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const labeled = resolveLabeledPickupSection(song); + if (labeled) { + return labeled; + } + + const sections = song.sections + .filter( + (section) => + isRuntimeObject(section) && hasBoundedTimeRange(section) && hasSafeSectionMembers(section) + ) + .sort((left, right) => { + if (left.timeRange.end !== right.timeRange.end) { + return left.timeRange.end - right.timeRange.end; + } + return left.timeRange.start - right.timeRange.start; + }); + + const candidates: FirstPickupHandoff[] = []; + + for (const section of sections) { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + + for (const node of section.partGraph) { + if ( + node.is_active !== true || + !Array.isArray(node.handoff_to) || + node.handoff_to.length === 0 + ) { + continue; + } + + const fromRole = rolesInSection.get(node.role_id); + if (!fromRole || !hasRankedPriority(fromRole)) { + continue; + } + + const targets = node.handoff_to + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) + .map((roleId) => rolesInSection.get(roleId) ?? null) + .filter( + (role): role is RehearsalRole => + role !== null && + hasRankedPriority(role) && + role.id !== fromRole.id && + hasReciprocalHandoff(section, fromRole.id, role.id) + ); + + const toRole = pickHighestPriorityRole(targets); + if (!toRole) { + continue; + } + + candidates.push({ + section, + fromRole, + toRole, + atSeconds: section.timeRange.end + }); + } + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return PRIORITY_RANK[left.toRole.rehearsalPriority] - PRIORITY_RANK[right.toRole.rehearsalPriority]; + }); + + return candidates[0] ?? null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..b1fffa1fa 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -74,5 +74,14 @@ describe("i18n", () => { koDictionary.appSubtitle = originalSubtitle; } }); + + it("keeps first-pickup keys in both baseline locales", () => { + const tEn = createTranslator("en"); + const tKo = createTranslator("ko"); + expect(tEn("firstPickupLabel")).toBe("Tonight's first pickup"); + expect(tKo("firstPickupLabel")).toBe("오늘 첫 픽업"); + expect(tEn("firstPickupOpenAction")).toContain("{to}"); + expect(tKo("firstPickupOpenAction")).toContain("{to}"); + }); }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..aec0b6f9d 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,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +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: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized buyer copy for a validated section form label. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label]; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..fbf02f9f2 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,17 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstPickupLabel": "Tonight's first pickup", + "firstPickupAction": "Hear {to} pick up from {from} at {at}", + "firstPickupActionSolo": "Hear {to} pick up at {at}", + "firstPickupOpenAction": "Open {to} pickup from {from} at {at}", + "firstPickupOpenActionSolo": "Open {to} pickup at {at}", + "firstPickupBody": "{to} picks up from {from} at the end of the {section} ({at}).", + "firstPickupBodySolo": "{to} picks up the {section} at {at}.", + "firstPickupArmed": "Start {to}'s pickup from {from} before the next downbeat ({at}).", + "firstPickupArmedSolo": "Start {to}'s pickup into the {section} ({at}).", + "firstPickupUnavailable": "No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.", + "firstPickupNeedsSong": "Analyze tonight's song first, then hear the first pickup from this player.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..caf55bf46 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,17 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstPickupLabel": "오늘 첫 픽업", + "firstPickupAction": "{at}에 {from}에게서 받는 {to} 듣기", + "firstPickupActionSolo": "{at}에 받는 {to} 듣기", + "firstPickupOpenAction": "{at} {from}→{to} 픽업 위치 열기", + "firstPickupOpenActionSolo": "{at} {to} 픽업 위치 열기", + "firstPickupBody": "{at} {section} 끝에서 {to} 파트가 {from}의 넘김을 받습니다.", + "firstPickupBodySolo": "{at} {section}에서 {to} 파트가 픽업합니다.", + "firstPickupArmed": "다음 다운비트 전에 {from}에게서 받는 {to} 픽업을 시작하세요 ({at}).", + "firstPickupArmedSolo": "{section}으로 들어가는 {to} 픽업을 시작하세요 ({at}).", + "firstPickupUnavailable": "아직 픽업이 없습니다. 파트가 넘김을 받을 준비가 될 때까지 오늘 지도에 머무르세요.", + "firstPickupNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 픽업을 들으세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..ab738d16f 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | 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. | +| First Pickup Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstPickupCallout.tsx` | Name the incoming part, outgoing partner when the graph corroborates it, section, and time. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearPickup` exists and delegates the exact pickup second to that callback. Keep the unavailable state guidance-only. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-pickup-navigation.md b/docs/doctoring/reduced-motion-first-pickup-navigation.md new file mode 100644 index 000000000..c8078b91d --- /dev/null +++ b/docs/doctoring/reduced-motion-first-pickup-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-pickup navigation + +Workspace map navigation for tonight's first pickup follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstPickupCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Pickup resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`.