From 4e5421935e0a9792f1cc066475f4f690de840957 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:19:24 +0000 Subject: [PATCH 01/38] feat(workspace): hear tonight's first lyric cue from the map Name the first lyric, part, section, and start time on the workspace and player so the singer can take the next rehearsal action instead of a generic ready card. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 22 ++++++ apps/desktop/src/features/player/index.tsx | 9 ++- .../workspace/FirstLyricCueCallout.test.tsx | 26 +++++++ .../workspace/FirstLyricCueCallout.tsx | 72 +++++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 16 ++++ .../src/features/workspace/Workspace.tsx | 5 +- .../features/workspace/firstLyricCue.test.ts | 78 +++++++++++++++++++ .../src/features/workspace/firstLyricCue.ts | 62 +++++++++++++++ apps/desktop/src/locales/en/common.json | 8 +- apps/desktop/src/locales/ko/common.json | 8 +- docs/design-system/component-contract.md | 1 + 15 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstLyricCue.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLyricCue.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..f4c138f78 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, 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..1c4869ee2 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 eea696893..eff994e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first lyric cue on the workspace and player so the singer can hear the words they enter on. - 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..f73c96da1 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 lyric cue so the singer can hear the words they enter on. `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..25c0674f1 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } 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("names tonight's first lyric cue once a song is loaded", () => { + render(); + expect( + screen.getByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..1e862a9f6 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,14 +1,17 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstLyricCueCallout } from "../workspace/FirstLyricCueCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ +/** Player surface that names tonight's first lyric cue so the singer can start. */ export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { const { title, song } = props; + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

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

+

{t("firstLyricCueNeedsSong")}

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

{title}

+
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..1a98c4ae6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstLyricCueCallout } from "./FirstLyricCueCallout"; + +describe("FirstLyricCueCallout", () => { + it("names the first lyric cue and arms that action", () => { + render(); + + const action = screen.getByRole("button", { + name: "Hear Lead Vocal enter on “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(); + }); + + 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..9e2a8eeac --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx @@ -0,0 +1,72 @@ +import { 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; +} + +/** Name tonight's first lyric cue and let the singer hear the words they enter on. */ +export function FirstLyricCueCallout({ song }: FirstLyricCueCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const cue = resolveFirstLyricCue(song); + const [heard, setHeard] = useState(false); + + if (!cue) { + return ( + + ); + } + + const start = formatLyricCueTime(cue.startSeconds); + const actionLabel = t("firstLyricCueAction") + .replace("{role}", cue.role.name) + .replace("{section}", cue.section.label) + .replace("{start}", start) + .replace("{lyric}", cue.lyric); + const body = t("firstLyricCueBody") + .replace("{role}", cue.role.name) + .replace("{section}", cue.section.label) + .replace("{start}", start) + .replace("{lyric}", cue.lyric); + const armed = t("firstLyricCueArmed") + .replace("{role}", cue.role.name) + .replace("{section}", cue.section.label) + .replace("{start}", start) + .replace("{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 a3da5ffe6..62c526213 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,20 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first lyric cue so the singer can hear it", () => { + render(); + + expect( + screen.getByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { + name: "Hear Lead Vocal enter on “city lights” in the verse at 0:10" + }) + ); + 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 71546b524..f35f1cbae 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 { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,7 +92,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > {sections.map((section) => ( -
+

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

@@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
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..bb4ff7d53 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLyricCue.test.ts @@ -0,0 +1,78 @@ +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"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLyricCue.ts b/apps/desktop/src/features/workspace/firstLyricCue.ts new file mode 100644 index 000000000..b0b8aecf6 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLyricCue.ts @@ -0,0 +1,62 @@ +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}`; +} + +function lyricText(role: RehearsalRole): string | null { + if (role.cue.kind !== "lyric") { + return null; + } + const lyric = role.cue.value.trim(); + return lyric ? lyric : null; +} + +/** Return the first lyric the room should hear, or null when no part has a lyric. */ +export function resolveFirstLyricCue(song: RehearsalSong): FirstLyricCue | null { + const sections = [...song.sections].sort((left, right) => left.timeRange.start - right.timeRange.start); + + for (const section of sections) { + const lyricRoles = section.roles.filter((role) => 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; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..64f98997a 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstLyricCueLabel": "Tonight's first lyric cue", + "firstLyricCueAction": "Hear {role} enter on “{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." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..5159cad72 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstLyricCueLabel": "오늘 첫 가사 큐", + "firstLyricCueAction": "{start} {section}에서 “{lyric}”으로 들어오는 {role} 듣기", + "firstLyricCueBody": "{role}이 {start} {section}에서 “{lyric}”으로 들어옵니다.", + "firstLyricCueArmed": "{section}의 {role}을 “{lyric}” ({start})에서 시작하세요.", + "firstLyricCueUnavailable": "가사 큐가 아직 없습니다. 가사가 있는 파트가 생길 때까지 오늘 지도에 머무르세요.", + "firstLyricCueNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 가사 큐를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..65bc3e804 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; keep the Hear button visible. | | 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 193b0d79981870600f14cf742324fdefaa0561b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:05:16 -0700 Subject: [PATCH 02/38] fix(workspace): document lyric cue helper --- apps/desktop/src/features/workspace/firstLyricCue.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/features/workspace/firstLyricCue.ts b/apps/desktop/src/features/workspace/firstLyricCue.ts index b0b8aecf6..630351d39 100644 --- a/apps/desktop/src/features/workspace/firstLyricCue.ts +++ b/apps/desktop/src/features/workspace/firstLyricCue.ts @@ -20,6 +20,7 @@ export function formatLyricCueTime(totalSeconds: number): string { 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") { return null; From 247d1447d494354f4baee62843b904893ea64fe3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:07:03 -0700 Subject: [PATCH 03/38] test(workspace): keep lyric cue placeholders literal --- .../workspace/FirstLyricCueCallout.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx b/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx index 1a98c4ae6..1eaa0af8f 100644 --- a/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.test.tsx @@ -15,6 +15,19 @@ describe("FirstLyricCueCallout", () => { expect(screen.getByText(/Start on Lead Vocal in the verse at “city lights” \(0:10\)/)).toBeTruthy(); }); + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2]!.name = "{section}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Hear {section} enter on “city lights” in the verse at 0:10" + }) + ).toBeTruthy(); + }); + it("tells the room to stay on the map when no lyric exists", () => { const song = createDemoRehearsalSong(); song.sections = []; From 7688cd73f3914b834e6a1923de9d7f6b9a5552c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:10:01 -0700 Subject: [PATCH 04/38] fix(workspace): interpolate lyric cue copy once --- .../workspace/FirstLyricCueCallout.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx b/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx index 9e2a8eeac..a3de37ecc 100644 --- a/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstLyricCueCallout.tsx @@ -9,6 +9,16 @@ export interface FirstLyricCueCalloutProps { song: RehearsalSong; } +type LyricCueCopyValues = Readonly>; + +/** 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; + }); +} + /** Name tonight's first lyric cue and let the singer hear the words they enter on. */ export function FirstLyricCueCallout({ song }: FirstLyricCueCalloutProps) { const t = createTranslator(detectPreferredLocale()); @@ -29,21 +39,15 @@ export function FirstLyricCueCallout({ song }: FirstLyricCueCalloutProps) { } const start = formatLyricCueTime(cue.startSeconds); - const actionLabel = t("firstLyricCueAction") - .replace("{role}", cue.role.name) - .replace("{section}", cue.section.label) - .replace("{start}", start) - .replace("{lyric}", cue.lyric); - const body = t("firstLyricCueBody") - .replace("{role}", cue.role.name) - .replace("{section}", cue.section.label) - .replace("{start}", start) - .replace("{lyric}", cue.lyric); - const armed = t("firstLyricCueArmed") - .replace("{role}", cue.role.name) - .replace("{section}", cue.section.label) - .replace("{start}", start) - .replace("{lyric}", cue.lyric); + const copyValues: LyricCueCopyValues = { + role: cue.role.name, + section: cue.section.label, + start, + lyric: cue.lyric + }; + const actionLabel = formatLyricCueCopy(t("firstLyricCueAction"), copyValues); + const body = formatLyricCueCopy(t("firstLyricCueBody"), copyValues); + const armed = formatLyricCueCopy(t("firstLyricCueArmed"), copyValues); return (