diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..dc73aae2e 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 verse with the holding part when an active role is corroborated, the labeled story line, and the time so the next action is obvious.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..ee389d318 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 verse must name the holding part when corroborated, the labeled story line, and the time so the next action is obvious.
## Security source
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..81568471a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Name tonight's first labeled verse on the workspace and player so the room can learn the first line; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -11,10 +12,11 @@
### Changed
- Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata.
+- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
### Fixed
-- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
+- Reset first-verse action completion when the runtime song object changes, so a replacement song cannot inherit success-shaped Open/Hear guidance merely because both songs have malformed or missing ids and the same verse metadata.
## [0.1.3] - 2026-04-29
@@ -74,4 +76,4 @@
- `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 요소에 대한 단위 테스트를 추가했습니다 (`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..d930e5b2d 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 labeled verse so the room can learn the first line; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx
new file mode 100644
index 000000000..6fdc99c48
--- /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 songWithVerse() {
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ const verse = structuredClone(seed);
+ verse.id = "verse-1";
+ verse.label = "verse";
+ verse.timeRange = { start: 10, end: 30 };
+ verse.roles = [
+ {
+ ...seed.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high"
+ }
+ ];
+ verse.partGraph = [
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse];
+ 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 verse from this player.")
+ ).toBeTruthy();
+ });
+
+ it("keeps the verse hear action unavailable without a player playback callback", () => {
+ render();
+
+ expect(screen.queryByRole("button", { name: "Hear Lead Vocal verse at 0:10" })).toBeNull();
+ expect(screen.getByText("Lead Vocal carries the verse at 0:10.")).toBeTruthy();
+ });
+
+ it("delegates the verse hear action to the owning player callback", () => {
+ const onPlayFromSeconds = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal verse at 0:10" }));
+
+ expect(onPlayFromSeconds).toHaveBeenCalledTimes(1);
+ expect(onPlayFromSeconds).toHaveBeenCalledWith(10);
+ });
+
+ it("renders a safe empty summary when the runtime section collection is not an array", () => {
+ const song = songWithVerse();
+ (song as unknown as { sections: unknown }).sections = null;
+
+ render();
+
+ expect(screen.getByText("No verse yet. Stay on tonight's map until the first verse is labeled.")).toBeTruthy();
+ expect(screen.getByText("0 sections")).toBeTruthy();
+ });
+
+ it("renders a safe empty summary when the runtime section collection is sparse", () => {
+ const song = songWithVerse();
+ const sparseSections: typeof song.sections = new Array(2);
+ sparseSections[1] = song.sections[0]!;
+ song.sections = sparseSections;
+
+ render();
+
+ expect(screen.getByText("No verse yet. Stay on tonight's map until the first verse is labeled.")).toBeTruthy();
+ expect(screen.getByText("0 sections")).toBeTruthy();
+ });
+
+ it("omits malformed runtime section elements without crashing the player summary", () => {
+ const song = songWithVerse();
+ 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 = songWithVerse();
+ (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..d876bf500 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 { FirstVerseCallout } from "../workspace/FirstVerseCallout";
+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 verse 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.