From 58c2faf836d5c92f57357459ce1db3e64da764aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:14:31 +0000 Subject: [PATCH 1/3] feat(workspace): guide tonight's first chorus on map and player Name the earliest labeled chorus so the room can open the lift on the map or hear it from the player. Fail closed on verse, pre-chorus, pickup, stop, and handoff labels, and keep Hear behind a real seek callback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 97 ++++++++++ apps/desktop/src/features/player/index.tsx | 70 ++++++- ...FirstChorusCallout.reduced-motion.test.tsx | 69 +++++++ .../workspace/FirstChorusCallout.test.tsx | 162 ++++++++++++++++ .../features/workspace/FirstChorusCallout.tsx | 147 ++++++++++++++ .../src/features/workspace/Workspace.tsx | 3 + .../features/workspace/firstChorus.test.ts | 166 ++++++++++++++++ .../src/features/workspace/firstChorus.ts | 181 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 13 +- apps/desktop/src/i18n/index.ts | 13 ++ apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + 17 files changed, 939 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstChorusCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstChorusCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstChorus.test.ts create mode 100644 apps/desktop/src/features/workspace/firstChorus.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..f6434a9b4 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 labeled chorus with the holding part when an active role is corroborated, the labeled lift, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..25bf3d68b 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 labeled chorus must name the holding part when corroborated, the labeled lift, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..5389b9acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first labeled chorus on the workspace and player so the room can catch the lift; 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. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..19f5e2e53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first labeled chorus so the room can catch the lift; 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. `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..d89195668 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,97 @@ +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"; + +function songWithChorus() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + chorus.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, chorus]; + return song; +} + +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 chorus from this player.") + ).toBeTruthy(); + }); + + it("keeps the chorus hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal lift at 0:30" })).toBeNull(); + expect(screen.getByText("Lead Vocal lifts the chorus at 0:30.")).toBeTruthy(); + }); + + it("delegates the chorus hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal lift at 0:30" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(30); + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithChorus(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No chorus yet. Stay on tonight's map until the lift is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithChorus(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No chorus yet. Stay on tonight's map until the lift is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithChorus(); + song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithChorus(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..10a2e2c0c 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,46 +1,96 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstChorusCallout } from "../workspace/FirstChorusCallout"; +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; +}; + +/** Return whether one runtime section is safe to summarize in the player. */ +function isPlayerSummarySection(value: unknown): value is RehearsalSection { + if (value === null || typeof value !== "object") { + return false; + } + const section = value as Partial; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) + ); +} + +/** Return dense, individually valid sections without trusting runtime collection metadata. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = song.sections as unknown; + if (!Array.isArray(sections)) { + return []; + } + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + for (let index = 0; index < length; index += 1) { + if (!(index in sections)) { + return []; + } + } + return sections.filter(isPlayerSummarySection); +} + +/** Player surface that names tonight's first labeled chorus 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("firstChorusNeedsSong")}

); } + const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; + return (

{title}

+
- {song.title} + {songTitle} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section) => ( + {sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstChorusCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstChorusCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..31a06847a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstChorusCallout.reduced-motion.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstChorusCallout } from "./FirstChorusCallout"; + +function songWithChorus() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + chorus.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstChorusCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal chorus at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx b/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx new file mode 100644 index 000000000..08973f1e1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstChorusCallout } from "./FirstChorusCallout"; + +function songWithChorus() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + chorus.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstChorusCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the first chorus 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 chorus at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch Lead Vocal's lift at 0:30. Sing the next line./)).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal chorus at 0:30" })); + + expect(screen.getByText("Lead Vocal lifts the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Catch Lead Vocal's lift at 0:30. Sing the next line./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearChorus = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal chorus at 0:30" })); + expect(onHearChorus).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 = songWithChorus(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal chorus at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first chorus changes or returns later", () => { + const initialSong = songWithChorus(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal chorus at 0:30" })); + expect(screen.getByText(/Catch Lead Vocal's lift at 0:30. Sing the next line./)).toBeTruthy(); + + const nextSong = songWithChorus(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 64, end: 80 }; + rerender(); + expect(screen.getByText("Lead Vocal lifts the chorus at 1:04.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable chorus guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No chorus yet. Stay on tonight's map until the lift is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide lift when no part holds the chorus", () => { + const song = songWithChorus(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first chorus at 0:30" })).toBeTruthy(); + expect(screen.getByText("The band lifts the chorus at 0:30.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearChorus = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal lift at 0:30" })); + expect(onHearChorus).toHaveBeenCalledWith(30); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal lifts the chorus at 0:30.")).toBeTruthy(); + }); + + it("localizes the chorus form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithChorus(); + song.sections[1]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("리드 보컬이 0:30 후렴에서 올립니다.")).toBeTruthy(); + expect(screen.queryByText(/chorus에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstChorusCallout.tsx b/apps/desktop/src/features/workspace/FirstChorusCallout.tsx new file mode 100644 index 000000000..68ecbf262 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstChorusCallout.tsx @@ -0,0 +1,147 @@ +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 { formatChorusTime, resolveFirstChorus } from "./firstChorus"; + +/** Props for the first-chorus rehearsal callout. */ +export interface FirstChorusCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearChorus?: (atSeconds: number) => void; +} + +type ChorusCopyValues = Readonly>; + +type HeardChorus = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate chorus placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatChorusCopy(template: string, values: ChorusCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof ChorusCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredChorusScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled chorus and offer only an action that the current surface can execute. */ +export function FirstChorusCallout({ + song, + actionMode = "workspace-scroll", + onHearChorus +}: FirstChorusCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const chorus = resolveFirstChorus(song); + const chorusSectionIndex = chorus ? song.sections.indexOf(chorus.section) : -1; + const [heardChorus, setHeardChorus] = useState(null); + + useEffect(() => { + setHeardChorus(null); + }, [song?.id, chorusSectionIndex, chorus?.section.id, chorus?.holdingRole?.id, chorus?.atSeconds]); + + if (!chorus) { + return ( + + ); + } + + const heard = + heardChorus?.songId === song.id && + heardChorus.sectionId === chorus.section.id && + heardChorus.sectionIndex === chorusSectionIndex && + heardChorus.holdingRoleId === (chorus.holdingRole?.id ?? null) && + heardChorus.atSeconds === chorus.atSeconds; + const at = formatChorusTime(chorus.atSeconds); + const copyValues: ChorusCopyValues = { + role: chorus.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, chorus.section.label), + at + }; + const hasRole = chorus.holdingRole !== null; + const actionLabel = formatChorusCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstChorusAction" + : "firstChorusActionBand" + : hasRole + ? "firstChorusOpenAction" + : "firstChorusOpenActionBand" + ), + copyValues + ); + const body = formatChorusCopy(t(hasRole ? "firstChorusBody" : "firstChorusBodyBand"), copyValues); + const armed = formatChorusCopy(t(hasRole ? "firstChorusArmed" : "firstChorusArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearChorus === "function"; + /** Record completion only after the owning surface has executed the selected chorus action. */ + const markChorusActionComplete = () => { + setHeardChorus({ + songId: song.id, + sectionId: chorus.section.id, + sectionIndex: chorusSectionIndex, + holdingRoleId: chorus.holdingRole?.id ?? null, + atSeconds: chorus.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..12890aeb7 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 { FirstChorusCallout } from "./FirstChorusCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstChorus.test.ts b/apps/desktop/src/features/workspace/firstChorus.test.ts new file mode 100644 index 000000000..4345f4b7b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstChorus.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatChorusTime, resolveFirstChorus } from "./firstChorus"; + +function withChorusSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = overrides.id ?? "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: overrides.start ?? 30, end: overrides.end ?? 46 }; + const roleId = overrides.roleId ?? "lead-vocal"; + chorus.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + chorus.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("resolveFirstChorus", () => { + it("returns null when the demo song has no labeled chorus", () => { + expect(resolveFirstChorus(createDemoRehearsalSong())).toBeNull(); + expect(formatChorusTime(Number.NaN)).toBe("0:00"); + expect(formatChorusTime(-4)).toBe("0:00"); + }); + + it("does not invent a chorus from a verse, pre-chorus, pickup, stop, or handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const preChorus = structuredClone(verse); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + const pickup = structuredClone(verse); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [verse, pickup, stop, preChorus, handoff]; + + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("picks the earliest labeled chorus and the part that carries the lift", () => { + const song = withChorusSection({ start: 30, end: 46 }); + const chorus = resolveFirstChorus(song); + + expect(chorus?.section.id).toBe("chorus-1"); + expect(chorus?.holdingRole?.id).toBe("lead-vocal"); + expect(chorus?.atSeconds).toBe(30); + expect(formatChorusTime(chorus?.atSeconds ?? -1)).toBe("0:30"); + }); + + it("prefers the earlier of two labeled choruses", () => { + const song = withChorusSection({ id: "chorus-late", start: 80, end: 96 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "chorus-early"; + earlier.timeRange = { start: 30, end: 46 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[0]!, song.sections[1]!, earlier]; + + const chorus = resolveFirstChorus(song); + expect(chorus?.section.id).toBe("chorus-early"); + expect(chorus?.holdingRole?.id).toBe("bass-guitar"); + expect(chorus?.atSeconds).toBe(30); + }); + + it("keeps a band-wide lift when no active ranked role holds it", () => { + const song = withChorusSection({ isActive: false }); + const chorus = resolveFirstChorus(song); + expect(chorus?.section.id).toBe("chorus-1"); + expect(chorus?.holdingRole).toBeNull(); + expect(chorus?.atSeconds).toBe(30); + }); + + it("skips a chorus whose rehearsal window is unbounded", () => { + const song = withChorusSection({ start: Number.NaN, end: 46 }); + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("skips a chorus whose end precedes its start", () => { + const song = withChorusSection({ start: 46, end: 30 }); + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("skips a zero-length chorus window", () => { + const song = withChorusSection({ start: 30, end: 30 }); + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("skips a chorus whose endpoint overflows the shared timing bound", () => { + const song = withChorusSection({ start: MAX_SECTION_TIME_SECONDS, end: MAX_SECTION_TIME_SECONDS + 1 }); + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstChorus(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withChorusSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstChorus(song)).toBeNull(); + }); + + it("keeps the lift band-wide when role identities are duplicated", () => { + const song = withChorusSection(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const chorus = resolveFirstChorus(song); + expect(chorus?.section.id).toBe("chorus-1"); + expect(chorus?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstChorus.ts b/apps/desktop/src/features/workspace/firstChorus.ts new file mode 100644 index 000000000..834d72d26 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstChorus.ts @@ -0,0 +1,181 @@ +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 labeled chorus: the earliest lift and the part that carries it. */ +export type FirstChorus = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative chorus time as m:ss for rehearsal copy. */ +export function formatChorusTime(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 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) + ); +} + +/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer 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 + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the first labeled chorus, or null when no safe lift remains. */ +export function resolveFirstChorus(song: RehearsalSong): FirstChorus | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const chorusSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "chorus" && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return left.id.localeCompare(right.id); + }); + + const section = chorusSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..4fb8afb4b 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,15 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes a chorus label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "chorus")).toBe("후렴"); + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); + }); + + it("preserves unlabeled form values as data", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..0d8129725 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,13 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + en: { chorus: "chorus" }, + ko: { chorus: "후렴" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for a section form label, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label] ?? label; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..b3e487217 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstChorusLabel": "Tonight's first chorus", + "firstChorusAction": "Hear {role} lift at {at}", + "firstChorusActionBand": "Hear the first chorus at {at}", + "firstChorusOpenAction": "Open {role} chorus at {at}", + "firstChorusOpenActionBand": "Open the first chorus at {at}", + "firstChorusBody": "{role} lifts the {section} at {at}.", + "firstChorusBodyBand": "The band lifts the {section} at {at}.", + "firstChorusArmed": "Catch {role}'s lift at {at}. Sing the next line.", + "firstChorusArmedBand": "Catch the lift at {at}. Sing the next line.", + "firstChorusUnavailable": "No chorus yet. Stay on tonight's map until the lift is labeled.", + "firstChorusNeedsSong": "Analyze tonight's song first, then hear the first chorus from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..0f9fd1377 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstChorusLabel": "오늘 첫 후렴", + "firstChorusAction": "{at}에 {role} 후렴 듣기", + "firstChorusActionBand": "{at} 첫 후렴 듣기", + "firstChorusOpenAction": "{at} {role} 후렴 위치 열기", + "firstChorusOpenActionBand": "{at} 첫 후렴 위치 열기", + "firstChorusBody": "{role}이 {at} {section}에서 올립니다.", + "firstChorusBodyBand": "밴드가 {at} {section}에서 올립니다.", + "firstChorusArmed": "{at}에서 {role} 후렴을 잡으세요. 다음 가사를 부르세요.", + "firstChorusArmedBand": "{at}에서 후렴을 잡으세요. 다음 가사를 부르세요.", + "firstChorusUnavailable": "아직 후렴이 없습니다. 후렴이 표시될 때까지 오늘 지도에 머무르세요.", + "firstChorusNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 후렴을 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..d847bcde8 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 Chorus Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstChorusCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `chorus` lift, and the time. Do not invent a lift from `verse`, `pre-chorus`, `pickup`, `stop`, or `handoff`. `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 `onHearChorus` exists and delegates the exact chorus second to that callback. Keep the unavailable state guidance-only. Distinct from pickup #916, stop #934, and labeled handoff #937. | | 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()`. | From 441838db5762837ae11a6275ab95f72d117fcd5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:07:09 -0700 Subject: [PATCH 2/3] test(workspace): lock particle-safe Korean chorus copy --- .../features/workspace/FirstChorusCallout.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx b/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx index 08973f1e1..7222d56ab 100644 --- a/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstChorusCallout.test.tsx @@ -156,7 +156,18 @@ describe("FirstChorusCallout", () => { render(); - expect(screen.getByText("리드 보컬이 0:30 후렴에서 올립니다.")).toBeTruthy(); + expect(screen.getByText("0:30 후렴에서 리드 보컬 파트가 올립니다.")).toBeTruthy(); expect(screen.queryByText(/chorus에서/)).toBeNull(); }); + + it("keeps dynamic Korean role names particle-safe without guessing Hangul morphology", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithChorus(); + song.sections[1]!.roles[0]!.name = "피아노"; + + render(); + + expect(screen.getByText("0:30 후렴에서 피아노 파트가 올립니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:30 후렴에서 올립니다.")).toBeNull(); + }); }); From d3c852894bc51425653d6e296c4ea9c69fe533cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:08:02 -0700 Subject: [PATCH 3/3] fix(workspace): keep Korean chorus role copy particle-safe --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f9fd1377..62130083c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,7 +154,7 @@ "firstChorusActionBand": "{at} 첫 후렴 듣기", "firstChorusOpenAction": "{at} {role} 후렴 위치 열기", "firstChorusOpenActionBand": "{at} 첫 후렴 위치 열기", - "firstChorusBody": "{role}이 {at} {section}에서 올립니다.", + "firstChorusBody": "{at} {section}에서 {role} 파트가 올립니다.", "firstChorusBodyBand": "밴드가 {at} {section}에서 올립니다.", "firstChorusArmed": "{at}에서 {role} 후렴을 잡으세요. 다음 가사를 부르세요.", "firstChorusArmedBand": "{at}에서 후렴을 잡으세요. 다음 가사를 부르세요.",