diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..e9e50574b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# AGENTS.md
## Project overview
-- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
+- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. After a named part is selected, the ready workspace names that part's first trusted lyric, count, or transition cue as the next entrance.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..0f837fa4a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -83,6 +83,7 @@ Last updated: 2026-03-11
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
+ - the selected part's first trusted lyric, count, or transition cue as the next entrance
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..9f092604b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- After a named part is selected, name that part's first trusted lyric, count, or transition cue and tell the player to catch it before the entrance.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..ac7ab6ba3 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). The ready workspace names tonight's first playable range and the next instrument check. After a named part is selected, it also names that part's first trusted lyric, count, or transition cue as the next entrance. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx
index 575684c50..3a7dbe730 100644
--- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx
+++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx
@@ -72,4 +72,27 @@ describe("RoleSwitcher", () => {
expect(tabValueToRoleId("role:unknown-role", roles)).toBeNull();
expect(tabValueToRoleId("raw-unknown-role", roles)).toBeNull();
});
+
+ it("clears an active role that is absent from the current song role allowlist", () => {
+ const onRoleChange = vi.fn();
+ const { rerender } = render(
+
+ );
+
+ expect(onRoleChange).not.toHaveBeenCalled();
+
+ rerender(
+
+ );
+
+ expect(onRoleChange).toHaveBeenCalledWith(null);
+ });
});
diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx
index f5275964d..f37292438 100644
--- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx
+++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users } from "lucide-react";
@@ -40,6 +41,12 @@ export function tabValueToRoleId(value: string, roles: RehearsalRoleOption[]): s
export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) {
const t = createTranslator(detectPreferredLocale());
+ useEffect(() => {
+ if (activeRole !== null && !roles.some((role) => role.id === activeRole)) {
+ onRoleChange(null);
+ }
+ }, [activeRole, onRoleChange, roles]);
+
return (
diff --git a/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx
new file mode 100644
index 000000000..2ca8a0e5c
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx
@@ -0,0 +1,53 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language
+ });
+}
+
+describe("Workspace selected-part confirmed chord", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ });
+
+ it("stays hidden until a part with a room-confirmed chord is selected", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+ expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull();
+ });
+
+ it("names the selected part's confirmed chord and the next lock-in action", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ const callout = screen.getByTestId("selected-part-confirmed-chord");
+ expect(callout).toHaveTextContent("Tonight's confirmed chord");
+ expect(callout).toHaveTextContent(
+ "Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse."
+ );
+ });
+
+ it("keeps Korean copy particle-safe for arbitrary chord symbols", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("selected-part-confirmed-chord")).toHaveTextContent(
+ "verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요."
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx
new file mode 100644
index 000000000..f37ce1fa1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx
@@ -0,0 +1,99 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language
+ });
+}
+
+describe("Workspace selected-part entrance cue", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ });
+
+ it("hides the entrance cue until a named part is selected", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull();
+ });
+
+ it("names the selected bass part's transition as the next entrance", () => {
+ setNavigatorLanguage("en-US");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const callout = screen.getByTestId("selected-part-entrance-cue");
+ expect(callout).toHaveTextContent("Tonight's entrance cue");
+ expect(callout).toHaveTextContent(
+ "Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat."
+ );
+ });
+
+ it("names the selected vocal lyric as the next entrance", () => {
+ setNavigatorLanguage("en-US");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
+ 'Listen for "city lights" in verse, then Lead Vocal enters.'
+ );
+ });
+
+ it("keeps Korean copy particle-safe for a Latin role name", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const callout = screen.getByTestId("selected-part-entrance-cue");
+ expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐");
+ expect(callout).toHaveTextContent("Bass Guitar 파트");
+ expect(callout).not.toHaveTextContent("Bass Guitar으로");
+ expect(callout).toHaveTextContent(
+ "verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat."
+ );
+ });
+
+ it("tells the player to confirm a missing cue instead of hiding the next action", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ cue: { kind: "transition", value: "none" }
+ };
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
+ "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance."
+ );
+ });
+
+ it("clears selected-part guidance when the next project no longer contains the selected role", () => {
+ setNavigatorLanguage("en-US");
+ const firstSong = createDemoRehearsalSong();
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("selected-part-entrance-cue")).toBeInTheDocument();
+
+ const nextSong = createDemoRehearsalSong();
+ nextSong.id = "replacement-project";
+ nextSong.sections = nextSong.sections.map((section) => ({
+ ...section,
+ roles: section.roles.filter((role) => role.id !== "bass-guitar")
+ }));
+
+ rerender();
+
+ expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull();
+ expect(screen.queryByTestId("selected-part-first-pass")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx
new file mode 100644
index 000000000..8dee9a185
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language
+ });
+}
+
+describe("Workspace selected-part first-pass take", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ });
+
+ it("hides the first-pass take until a named part is selected", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ expect(screen.queryByTestId("selected-part-first-pass")).toBeNull();
+ });
+
+ it("names the selected bass part's simpler take as the first pass", () => {
+ setNavigatorLanguage("en-US");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const callout = screen.getByTestId("selected-part-first-pass");
+ expect(callout).toHaveTextContent("Tonight's first-pass take");
+ expect(callout).toHaveTextContent(
+ "First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest."
+ );
+ });
+
+ it("names the selected vocal part's simpler take as the first pass", () => {
+ setNavigatorLanguage("en-US");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
+ "First pass for Lead Vocal in verse: Keep the sustained note centered; skip the ad-lib on the first pass. Play that simpler take before adding the rest."
+ );
+ });
+
+ it("keeps Korean copy particle-safe for a Latin role name", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const callout = screen.getByTestId("selected-part-first-pass");
+ expect(callout).toHaveTextContent("오늘 이 파트의 첫 간소화");
+ expect(callout).toHaveTextContent("Bass Guitar 파트");
+ expect(callout).not.toHaveTextContent("Bass Guitar으로");
+ expect(callout).toHaveTextContent(
+ "verse에서 Bass Guitar 파트의 첫 패스: Stay on roots if the chorus entrance gets muddy. 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요."
+ );
+ });
+
+ it("tells the player to confirm a missing first-pass take instead of hiding the next action", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ simplification: "none"
+ };
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
+ "This part still needs a trusted first-pass take. Confirm the simpler version before the first run."
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..4f726ed30 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,7 +5,10 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale } from "../../i18n";
+import { fillEntranceCueCopy, firstEntranceCue } from "./firstEntranceCue";
+import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification";
+import { fillConfirmedChordCopy, selectedPartConfirmedChord } from "./selectedPartConfirmedChord";
+import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -118,6 +121,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
);
});
+/** Documented. */
+function entranceCueCopyKey(kind: "lyric" | "count" | "transition"): TranslationKey {
+ if (kind === "lyric") {
+ return "workspaceSelectedEntranceCueLyric";
+ }
+ if (kind === "count") {
+ return "workspaceSelectedEntranceCueCount";
+ }
+ return "workspaceSelectedEntranceCueTransition";
+}
+
/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState(null);
@@ -163,6 +177,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const confirmedChord = useMemo(
+ () => selectedPartConfirmedChord(song, activeRole),
+ [activeRole, song]
+ );
+ const confirmedChordCopy = confirmedChord
+ ? fillConfirmedChordCopy(t("workspaceConfirmedChordLock"), {
+ roleName: confirmedChord.roleName,
+ chord: confirmedChord.chord,
+ sectionLabel: confirmedChord.sectionLabel
+ })
+ : null;
+ const selectedEntranceCue = useMemo(
+ () => (activeRole ? firstEntranceCue(song, activeRole) : null),
+ [activeRole, song]
+ );
+ const selectedEntranceCueCopy =
+ selectedEntranceCue?.status === "ready"
+ ? fillEntranceCueCopy(t(entranceCueCopyKey(selectedEntranceCue.kind)), {
+ roleName: selectedEntranceCue.roleName,
+ sectionLabel: selectedEntranceCue.sectionLabel,
+ value: selectedEntranceCue.value
+ })
+ : t("workspaceSelectedEntranceCueUnavailable");
+ const selectedFirstPass = useMemo(
+ () => (activeRole ? firstPassSimplification(song, activeRole) : null),
+ [activeRole, song]
+ );
+ const selectedFirstPassCopy =
+ selectedFirstPass?.status === "ready"
+ ? fillFirstPassCopy(t("workspaceSelectedFirstPassReady"), {
+ roleName: selectedFirstPass.roleName,
+ sectionLabel: selectedFirstPass.sectionLabel,
+ value: selectedFirstPass.value
+ })
+ : t("workspaceSelectedFirstPassUnavailable");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -310,6 +359,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp