diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..af9168b79 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 lyric cue with the part, words, section, and start time so the singer's 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..3aeb7c772 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 lyric cue must name the part, words, section, and start time so the singer's next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..0e57e319f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first lyric cue on the workspace and player with part, lyric, section, and start time; the workspace action arms the cue, opens the matching map section, and honors the user's reduced-motion preference for that navigation, 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -74,4 +75,5 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..60e676a65 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 lyric cue with its part, lyric, section, and start time; the workspace action arms the cue and opens the mapped section, while the player renders the 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. @@ -72,4 +72,4 @@ Supporting packages: - Documents under `docs/plans/` must include `Security Notes`; `scripts/checks/verify_security_notes.py` enforces this mechanically. - Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`. - CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them. -- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). +- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). \ No newline at end of file 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..1410062d6 --- /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 lyric cue from this player.") + ).toBeTruthy(); + }); + + it("keeps the lyric hear action unavailable without a player playback callback", () => { + render(); + + expect( + screen.queryByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ).toBeNull(); + expect(screen.getByText("Lead Vocal enters the verse on “city lights” at 0:10.")).toBeTruthy(); + }); + + it("delegates the lyric hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(10); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..1f2e541f2 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 { FirstLyricCueCallout } from "../workspace/FirstLyricCueCallout"; +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 lyric cue 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("firstLyricCueNeedsSong")}

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

{title}

+
@@ -53,4 +67,4 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstLyricCueCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstLyricCueCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..d9271350e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.reduced-motion.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstLyricCueCallout } from "./FirstLyricCueCallout"; + +afterEach(() => { + vi.unstubAllGlobals(); + document.querySelector('[data-testid="song-structure-grid"]')?.remove(); +}); + +/** Mount the renderer-owned song-structure target used by workspace navigation. */ +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 scrollIntoView; +} + +describe("FirstLyricCueCallout reduced-motion navigation", () => { + it("avoids smooth scrolling when the user requests reduced motion", () => { + const scrollIntoView = appendSongStructureTarget(); + vi.stubGlobal( + "matchMedia", + vi.fn((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() + })) + ); + + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:10" + }) + ); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "auto" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx b/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx new file mode 100644 index 000000000..55055f550 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx @@ -0,0 +1,232 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstLyricCueCallout } from "./FirstLyricCueCallout"; + +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 }; +} + +/** Mount a renderer-owned map whose children mirror rendered section order one-to-one. */ +function appendSongStructureGrid(childCount: number) { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const scrolls = Array.from({ length: childCount }, () => { + const child = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(child, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(child); + return scrollIntoView; + }); + document.body.appendChild(grid); + return { grid, scrolls }; +} + +describe("FirstLyricCueCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => node.remove()); + }); + + it("names the first lyric cue 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 lyric cue “city lights” in the verse at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Start on Lead Vocal in the verse at “city lights” \(0:10\)/)).toBeTruthy(); + + 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 lyric cue “city lights” in the verse at 0:10" + }) + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first lyric cue changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + try { + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:10" + }) + ); + expect(screen.getByText(/Start on Lead Vocal in the verse at “city lights” \(0:10\)/)).toBeTruthy(); + + const replacementSong = createDemoRehearsalSong(); + replacementSong.id = "demo-song-replacement"; + replacementSong.sections[0]!.roles[2]!.cue.value = "hold on"; + rerender(); + expect(screen.getByText("Lead Vocal enters the verse on “hold on” at 0:10.")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Lead Vocal enters the verse on “city lights” at 0:10.")).toBeTruthy(); + } finally { + 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} lyric cue “city lights” in the verse at 0:10" + }) + ).toBeTruthy(); + }); + + it("keeps dynamic Korean role names particle-safe without guessing Hangul morphology", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2]!.name = "피아노"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("0:10 verse에서 피아노 파트가 “city lights”으로 들어옵니다.")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { + name: "0:10 verse의 피아노 가사 큐 “city lights” 위치 열기" + }) + ); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("verse의 피아노 파트를 “city lights” (0:10)에서 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노을|피아노이/)).toBeNull(); + + grid.remove(); + }); + + it("navigates by rendered section position even when analysis start times are unordered", () => { + const base = createDemoRehearsalSong().sections[0]!; + const ghost = structuredClone(base); + ghost.id = "rendered-first-invalid-start"; + ghost.timeRange.start = Number.NaN; + const late = structuredClone(base); + late.id = "rendered-second-later-start"; + late.timeRange.start = 30; + const early = structuredClone(base); + early.id = "rendered-third-earliest-start"; + early.timeRange.start = 5; + const song = createDemoRehearsalSong(); + song.sections = [ghost, late, early]; + + const { grid, scrolls } = appendSongStructureGrid(3); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:05" + }) + ); + + expect(scrolls[2]).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(scrolls[0]).not.toHaveBeenCalled(); + expect(scrolls[1]).not.toHaveBeenCalled(); + + grid.remove(); + }); + + it("stays on fresh guidance when the rendered map is missing entirely", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:10" + }) + ); + + expect(screen.getByText("Lead Vocal enters the verse on “city lights” at 0:10.")).toBeTruthy(); + }); + + it("stays on fresh guidance when the rendered map lacks the cue's section node", () => { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + document.body.appendChild(grid); + + try { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:10" + }) + ); + + expect(screen.getByText("Lead Vocal enters the verse on “city lights” at 0:10.")).toBeTruthy(); + } finally { + grid.remove(); + } + }); + + it("arms callback-only playback once the player accepts the cue", () => { + const onHearLyricCue = vi.fn(); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ); + + expect(onHearLyricCue).toHaveBeenCalledTimes(1); + expect(onHearLyricCue).toHaveBeenCalledWith(10); + expect(screen.getByText(/Start on Lead Vocal in the verse at “city lights” \(0:10\)/)).toBeTruthy(); + }); + + it("tells the room to stay on the map when no lyric exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect( + screen.getByText("No lyric cue yet. Stay on tonight's map until a part has words to hear.") + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx b/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx new file mode 100644 index 000000000..4f0f95e7e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatLyricCueTime, resolveFirstLyricCue } from "./firstLyricCue"; + +/** Props for the first-lyric-cue rehearsal callout. */ +export interface FirstLyricCueCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearLyricCue?: (startSeconds: number) => void; +} + +type LyricCueCopyValues = Readonly>; + +type HeardLyricCue = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + roleId: string; + startSeconds: number; + lyric: string; +}>; + +/** Interpolate lyric-cue placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatLyricCueCopy(template: string, values: LyricCueCopyValues): string { + return template.replace(/\{(role|section|start|lyric)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof LyricCueCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredLyricCueScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first lyric cue and offer only an action that the current surface can execute. */ +export function FirstLyricCueCallout({ + song, + actionMode = "workspace-scroll", + onHearLyricCue +}: FirstLyricCueCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const cue = resolveFirstLyricCue(song); + const cueSectionIndex = cue ? song.sections.indexOf(cue.section) : -1; + const [heardCue, setHeardCue] = useState(null); + + useEffect(() => { + setHeardCue(null); + }, [song.id, cueSectionIndex, cue?.section.id, cue?.role.id, cue?.startSeconds, cue?.lyric]); + + if (!cue) { + return ( + + ); + } + + const heard = + heardCue?.songId === song.id && + heardCue.sectionId === cue.section.id && + heardCue.sectionIndex === cueSectionIndex && + heardCue.roleId === cue.role.id && + heardCue.startSeconds === cue.startSeconds && + heardCue.lyric === cue.lyric; + const start = formatLyricCueTime(cue.startSeconds); + const copyValues: LyricCueCopyValues = { + role: cue.role.name, + section: cue.section.label, + start, + lyric: cue.lyric + }; + const actionLabel = formatLyricCueCopy( + t(actionMode === "callback-only" ? "firstLyricCueAction" : "firstLyricCueOpenAction"), + copyValues + ); + const body = formatLyricCueCopy(t("firstLyricCueBody"), copyValues); + const armed = formatLyricCueCopy(t("firstLyricCueArmed"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || onHearLyricCue !== undefined; + /** Arm the confirmation only after the chosen surface actually accepts the action. */ + const rememberHeardCue = () => + setHeardCue({ + songId: song.id, + sectionId: cue.section.id, + sectionIndex: cueSectionIndex, + roleId: cue.role.id, + startSeconds: cue.startSeconds, + lyric: cue.lyric + }); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..8837b73f2 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -72,6 +72,17 @@ describe("Workspace", () => { expect(grid.style.gridTemplateColumns).toContain("repeat(1"); }); + 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("falls back to safe timeline text for malformed section times", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -326,4 +337,15 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first lyric cue as workspace navigation", () => { + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal lyric cue “city lights” in the verse at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(screen.getByText(/Start on Lead Vocal in the verse at “city lights” \(0:10\)/)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..c3cb5f637 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 { FirstLyricCueCallout } from "./FirstLyricCueCallout"; 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/firstLyricCue.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstLyricCue.invalid-role-id.test.ts new file mode 100644 index 000000000..d1dd25d68 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLyricCue.invalid-role-id.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLyricCue } from "./firstLyricCue"; + +describe("resolveFirstLyricCue runtime role identity", () => { + it("ignores a lyric role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + + const safeRole = { + ...section.roles[0]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "medium" as const, + cue: { kind: "lyric" as const, value: "safe lyric" } + }; + const malformedRole = { + ...section.roles[2]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const, + cue: { kind: "lyric" as const, value: "unsafe lyric" } + }; + + section.roles = [safeRole, malformedRole]; + song.sections = [section]; + + const cue = resolveFirstLyricCue(song); + expect(cue?.role.id).toBe("safe-vocal"); + expect(cue?.lyric).toBe("safe lyric"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLyricCue.test.ts b/apps/desktop/src/features/workspace/firstLyricCue.test.ts new file mode 100644 index 000000000..e10fc3732 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLyricCue.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatLyricCueTime, resolveFirstLyricCue } from "./firstLyricCue"; + +describe("resolveFirstLyricCue", () => { + it("picks the earliest lyric cue, not the first instrumental entrance", () => { + const song = createDemoRehearsalSong(); + const cue = resolveFirstLyricCue(song); + + expect(cue?.section.id).toBe("verse-1"); + expect(cue?.role.id).toBe("lead-vocal"); + expect(cue?.lyric).toBe("city lights"); + expect(cue?.startSeconds).toBe(10); + expect(formatLyricCueTime(cue?.startSeconds ?? -1)).toBe("0:10"); + expect(formatLyricCueTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no part has a lyric to hear", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstLyricCue(song)).toBeNull(); + }); + + it("skips an earlier section that only has count or transition cues", () => { + 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[2]!, + id: "lead-vocal-chorus", + cue: { kind: "lyric", value: " stay up " } + } + ]; + song.sections = [ + { + ...verse, + roles: verse.roles.map((role) => ({ + ...role, + cue: { kind: "transition", value: "Hold the pickup." } + })) + }, + later + ]; + + const cue = resolveFirstLyricCue(song); + expect(cue?.section.id).toBe("chorus-1"); + expect(cue?.lyric).toBe("stay up"); + }); + + it("prefers the higher-priority lyric when two parts share a section", () => { + const song = createDemoRehearsalSong(); + song.sections[0] = { + ...song.sections[0]!, + roles: [ + { + ...song.sections[0]!.roles[2]!, + id: "backing-vocal", + name: "Backing Vocal", + rehearsalPriority: "low", + cue: { kind: "lyric", value: "ooo" } + }, + { + ...song.sections[0]!.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high", + cue: { kind: "lyric", value: "city lights" } + } + ] + }; + + expect(resolveFirstLyricCue(song)?.role.id).toBe("lead-vocal"); + }); + + it("skips non-finite starts, unknown priorities, and malformed lyric values", () => { + const song = createDemoRehearsalSong(); + const invalidSection = structuredClone(song.sections[0]!); + invalidSection.id = "invalid-start"; + invalidSection.timeRange = { start: Number.NaN, end: 20 }; + + const validSection = structuredClone(song.sections[0]!); + validSection.id = "valid-chorus"; + validSection.label = "chorus"; + validSection.timeRange = { start: 20, end: 50 }; + const invalidPriorityRole = { + ...validSection.roles[2]!, + id: "invalid-priority", + cue: { kind: "lyric" as const, value: "ignore me" } + }; + (invalidPriorityRole as unknown as { rehearsalPriority: string }).rehearsalPriority = "urgent"; + const invalidLyricRole = { + ...validSection.roles[2]!, + id: "invalid-lyric", + rehearsalPriority: "medium" as const, + cue: { kind: "lyric" as const, value: "placeholder" } + }; + (invalidLyricRole.cue as unknown as { value: unknown }).value = 42; + validSection.roles = [ + invalidPriorityRole, + invalidLyricRole, + { + ...validSection.roles[2]!, + id: "safe-lead", + rehearsalPriority: "high", + cue: { kind: "lyric", value: "safe lyric" } + } + ]; + + song.sections = [invalidSection, validSection]; + + const cue = resolveFirstLyricCue(song); + expect(cue?.section.id).toBe("valid-chorus"); + expect(cue?.role.id).toBe("safe-lead"); + expect(cue?.lyric).toBe("safe lyric"); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstLyricCue.ts b/apps/desktop/src/features/workspace/firstLyricCue.ts new file mode 100644 index 000000000..1d38b8b56 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLyricCue.ts @@ -0,0 +1,71 @@ +import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first lyric cue: earliest section with a lyric, then the highest-priority lyric role. */ +export type FirstLyricCue = { + section: RehearsalSection; + role: RehearsalRole; + startSeconds: number; + lyric: string; +}; + +/** Format a non-negative section start as m:ss for rehearsal copy. */ +export function formatLyricCueTime(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 a trimmed lyric cue only when the role carries non-blank lyric evidence. */ +function lyricText(role: RehearsalRole): string | null { + if (role.cue.kind !== "lyric" || typeof role.cue.value !== "string") { + return null; + } + const lyric = role.cue.value.trim(); + return lyric ? lyric : null; +} + +/** Return the first validated lyric the room should hear, or null when no safe candidate remains. */ +export function resolveFirstLyricCue(song: RehearsalSong): FirstLyricCue | null { + const sections = song.sections + .filter((section) => Number.isFinite(section.timeRange.start) && section.timeRange.start >= 0) + .sort((left, right) => left.timeRange.start - right.timeRange.start); + + for (const section of sections) { + const lyricRoles = section.roles.filter( + (role) => + typeof role.id === "string" && + role.id.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) && + lyricText(role) !== null + ); + if (lyricRoles.length === 0) { + continue; + } + + const role = [...lyricRoles].sort( + (left, right) => PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority] + )[0]; + if (!role) { + continue; + } + + const lyric = lyricText(role); + if (!lyric) { + continue; + } + + return { + section, + role, + startSeconds: section.timeRange.start, + lyric + }; + } + + return null; +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..f2a0a6ea1 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,13 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstLyricCueLabel": "Tonight's first lyric cue", + "firstLyricCueAction": "Hear {role} enter on “{lyric}” in the {section} at {start}", + "firstLyricCueOpenAction": "Open {role} lyric cue “{lyric}” in the {section} at {start}", + "firstLyricCueBody": "{role} enters the {section} on “{lyric}” at {start}.", + "firstLyricCueArmed": "Start on {role} in the {section} at “{lyric}” ({start}).", + "firstLyricCueUnavailable": "No lyric cue yet. Stay on tonight's map until a part has words to hear.", + "firstLyricCueNeedsSong": "Analyze tonight's song first, then hear the first lyric cue 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..11e31a332 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,13 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstLyricCueLabel": "오늘 첫 가사 큐", + "firstLyricCueAction": "{start} {section}에서 “{lyric}”으로 들어오는 {role} 듣기", + "firstLyricCueOpenAction": "{start} {section}의 {role} 가사 큐 “{lyric}” 위치 열기", + "firstLyricCueBody": "{start} {section}에서 {role} 파트가 “{lyric}”으로 들어옵니다.", + "firstLyricCueArmed": "{section}의 {role} 파트를 “{lyric}” ({start})에서 시작하세요.", + "firstLyricCueUnavailable": "가사 큐가 아직 없습니다. 가사가 있는 파트가 생길 때까지 오늘 지도에 머무르세요.", + "firstLyricCueNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 가사 큐를 들으세요.", "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..b6a3d88d1 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 Lyric Cue Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx` | Name the first lyric, part, section, and start time; show the Hear button only when a lyric cue is available, and keep the unavailable state guidance-only without a Hear button. | | 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-lyric-cue-navigation.md b/docs/doctoring/reduced-motion-first-lyric-cue-navigation.md new file mode 100644 index 000000000..e29329a7f --- /dev/null +++ b/docs/doctoring/reduced-motion-first-lyric-cue-navigation.md @@ -0,0 +1,33 @@ +# Reduced-motion first-lyric-cue navigation + +## Scope + +The workspace First Lyric Cue action is an intentional user interaction that scrolls the song-structure map to the resolved section. Smooth scrolling is non-essential to finding that section, so BandScope uses the operating-system/user-agent reduced-motion preference to choose the navigation animation behavior. + +This record applies only to the renderer-owned workspace scroll performed by `FirstLyricCueCallout`. Player playback remains owned by the explicit playback callback and is not altered by this decision. + +## Contract + +- The default workspace path preserves the existing smooth scroll to the renderer-owned section position. +- When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is true, the same action uses immediate (`auto`) scrolling instead of smooth animation. +- If `matchMedia` is unavailable, BandScope preserves the existing smooth behavior rather than inventing a preference. +- Reduced-motion handling does not change lyric-cue selection, section authority, playback authority, or the fail-closed metadata validation contract. +- The regression test covers the preference-aware JavaScript path directly; deterministic product/security/coverage gates remain independent of model judgment. + +## Standards rationale + +WCAG 2.2 Success Criterion 2.3.3, Animation from Interactions (Level AAA), requires interaction-triggered motion animation to be disableable when it is not essential. W3C's Understanding document recommends honoring user motion preferences, and Technique SCR40 documents evaluating `prefers-reduced-motion` in JavaScript to prevent interaction-triggered motion. The First Lyric Cue scroll animation is not essential to conveying which section is selected, so respecting the preference is the narrower behavior-preserving implementation. + +This is an implementation rationale and evidence record, not a claim that BandScope is WCAG certified or that this single behavior establishes conformance. + +## Verification + +`apps/desktop/src/features/workspace/FirstLyricCueCallout.reduced-motion.test.tsx` sets the reduced-motion media query to `reduce`, activates the exact buyer-visible First Lyric Cue workspace action, and requires the renderer-owned target to receive `scrollIntoView({ block: "nearest", behavior: "auto" })`. Existing First Lyric Cue tests continue to cover the default smooth-scroll contract. + +## References + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, December 12, 2024). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Understanding Success Criterion 2.3.3: Animation from interactions*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *SCR40: Using the CSS prefers-reduced-motion query in JavaScript to prevent motion*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR40