diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..02d151315 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. The ready workspace names tonight's first playable range and the first part-to-part handoff to lock before the next section.
- 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..64ed88475 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -82,7 +82,7 @@ Last updated: 2026-03-11
- likely harmony by section and by role
- 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
+ - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and the first part-to-part handoff to lock before the next section
- 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..fd5a89f14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- 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.
+- Name tonight's first part-to-part handoff on the ready rehearsal map and tell the player to lock that pass 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..ad2f86007 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, the next instrument check, and the first part-to-part handoff to lock before the next section. `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/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..10e1bd08e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -16,6 +16,26 @@ function setNavigatorLanguage(language: string) {
});
}
+function withHandoffDestination(song: RehearsalSong): RehearsalSong {
+ const source = song.sections[0];
+ return {
+ ...song,
+ sections: [
+ source,
+ {
+ ...source,
+ id: "chorus-1",
+ label: "chorus",
+ partGraph: source.partGraph.map((node) => ({
+ ...node,
+ handoff_to: [],
+ handoff_from: []
+ }))
+ }
+ ]
+ };
+}
+
describe("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
@@ -196,6 +216,43 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first part handoff and the next lock-in", () => {
+ setNavigatorLanguage("en-US");
+ const song = withHandoffDestination(createDemoRehearsalSong());
+
+ render();
+
+ const callout = screen.getByTestId("first-part-handoff");
+ expect(callout).toHaveTextContent("Tonight's first handoff");
+ expect(callout).toHaveTextContent(
+ "Bass Guitar hands off to Lead Vocal in chorus. Lock that pass before the chorus."
+ );
+ });
+
+ it("keeps the selected receiver in tonight's first handoff", () => {
+ setNavigatorLanguage("en-US");
+ const song = withHandoffDestination(createDemoRehearsalSong());
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("first-part-handoff")).toHaveTextContent(
+ "Bass Guitar hands off to Lead Vocal in chorus. Lock that pass before the chorus."
+ );
+ });
+
+ it("asks the player to pick a pass when the selected part is not on one", () => {
+ setNavigatorLanguage("en-US");
+ const song = withHandoffDestination(createDemoRehearsalSong());
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" }));
+
+ expect(screen.getByTestId("first-part-handoff")).toHaveTextContent(
+ "Tonight's first handoff still needs a pass. Pick who receives the next entrance and lock that pass before the first section."
+ );
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -325,5 +382,6 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
+ expect(screen.getByText("오늘 먼저 맞출 넘김")).toBeTruthy();
});
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..f08aef54a 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { firstHandoff } from "./firstHandoff";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +164,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const namedHandoff = useMemo(() => firstHandoff(song, activeRole), [activeRole, song]);
+ const firstHandoffCopy = namedHandoff
+ ? fillRangeCopy(t("workspaceFirstHandoffPass"), {
+ fromRole: namedHandoff.fromRole,
+ toRole: namedHandoff.toRole,
+ sectionLabel: namedHandoff.sectionLabel
+ })
+ : t("workspaceFirstHandoffMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -309,6 +318,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{t("workspaceFirstRangeTitle")}
{firstRangeCopy}
+
+ {t("workspaceFirstHandoffTitle")}
+ {firstHandoffCopy}
+
diff --git a/apps/desktop/src/features/workspace/firstHandoff.test.ts b/apps/desktop/src/features/workspace/firstHandoff.test.ts
new file mode 100644
index 000000000..b0621ba46
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstHandoff.test.ts
@@ -0,0 +1,245 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { fillRangeCopy } from "./firstRangeSqueeze";
+import { firstHandoff } from "./firstHandoff";
+
+function withDestination(song: RehearsalSong): RehearsalSong {
+ const source = song.sections[0];
+ return {
+ ...song,
+ sections: [
+ source,
+ {
+ ...source,
+ id: "chorus-1",
+ label: "chorus",
+ partGraph: source.partGraph.map((node) => ({
+ ...node,
+ handoff_to: [],
+ handoff_from: []
+ }))
+ }
+ ]
+ };
+}
+
+function withPartGraph(
+ song: RehearsalSong,
+ partGraph: RehearsalSong["sections"][number]["partGraph"]
+): RehearsalSong {
+ const readySong = withDestination(song);
+ return {
+ ...readySong,
+ sections: readySong.sections.map((section, index) =>
+ index === 0 ? { ...section, partGraph } : section
+ )
+ };
+}
+
+describe("firstHandoff", () => {
+ it("names the first active pass from the source graph at its destination section", () => {
+ expect(firstHandoff(withDestination(createDemoRehearsalSong()))).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Bass Guitar",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("names an analysis-derived handoff at the destination section", () => {
+ const song = createDemoRehearsalSong();
+ const source = song.sections[0];
+ const destination = {
+ ...source,
+ id: "chorus-1",
+ label: "chorus" as const,
+ partGraph: source.partGraph.map((node) => ({
+ ...node,
+ handoff_to: [],
+ handoff_from: []
+ }))
+ };
+
+ expect(firstHandoff({ ...song, sections: [source, destination] })).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Bass Guitar",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("resolves an activity-derived entering receiver from the destination roles", () => {
+ const song = createDemoRehearsalSong();
+ const template = song.sections[0];
+ const bass = template.roles.find((role) => role.id === "bass-guitar");
+ const vocal = template.roles.find((role) => role.id === "lead-vocal");
+ if (!bass || !vocal) {
+ throw new Error("Demo fixture must contain bass and lead vocal roles");
+ }
+
+ const source = {
+ ...template,
+ roles: [bass],
+ partGraph: [
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ]
+ };
+ const destination = {
+ ...template,
+ id: "chorus-1",
+ label: "chorus",
+ roles: [vocal],
+ partGraph: [
+ {
+ role_id: "bass-guitar",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: ["bass-guitar"]
+ }
+ ]
+ };
+
+ expect(firstHandoff({ ...song, sections: [source, destination] })).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Bass Guitar",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("fails closed when a transition handoff has no valid destination section", () => {
+ const song = createDemoRehearsalSong();
+ const malformed = {
+ ...song,
+ sections: [song.sections[0], null]
+ } as unknown as RehearsalSong;
+
+ expect(firstHandoff(malformed)).toBeNull();
+ });
+
+ it("fails closed when the destination section has no meaningful label", () => {
+ const song = withDestination(createDemoRehearsalSong());
+ const destination = { ...song.sections[1], label: " " };
+
+ expect(firstHandoff({ ...song, sections: [song.sections[0], destination] })).toBeNull();
+ });
+
+ it("does not invent a transition for a one-section song", () => {
+ expect(firstHandoff(createDemoRehearsalSong())).toBeNull();
+ });
+
+ it("skips inactive nodes until an active named receiver exists", () => {
+ const song = withPartGraph(createDemoRehearsalSong(), [
+ {
+ role_id: "bass-guitar",
+ is_active: false,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "keys-right",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: ["keys-right"]
+ }
+ ]);
+
+ expect(firstHandoff(song)).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Keyboard 1 Right Hand",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("skips blank, none, self, and unknown receivers", () => {
+ const song = withPartGraph(createDemoRehearsalSong(), [
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["", "none", "bass-guitar", "missing-role", "lead-vocal"],
+ handoff_from: []
+ }
+ ]);
+
+ expect(firstHandoff(song)).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Bass Guitar",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("limits the pass to the selected role as giver or receiver", () => {
+ expect(firstHandoff(withDestination(createDemoRehearsalSong()), "lead-vocal")).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Bass Guitar",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("returns null when the selected role is not on a named pass", () => {
+ const song = withDestination(createDemoRehearsalSong());
+ expect(firstHandoff(song, "keys-right")).toBeNull();
+ expect(firstHandoff(song, "missing-role")).toBeNull();
+ });
+
+ it("skips inherited or malformed part-graph evidence", () => {
+ const inherited = Object.create({
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["lead-vocal"]
+ }) as RehearsalSong["sections"][number]["partGraph"][number];
+ const song = withPartGraph(createDemoRehearsalSong(), [
+ inherited,
+ {
+ role_id: "keys-right",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ }
+ ]);
+
+ expect(firstHandoff(song)).toEqual({
+ sectionLabel: "chorus",
+ fromRole: "Keyboard 1 Right Hand",
+ toRole: "Lead Vocal"
+ });
+ });
+
+ it("fails closed on malformed runtime roots", () => {
+ for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) {
+ expect(firstHandoff(malformed as unknown as RehearsalSong)).toBeNull();
+ }
+ });
+});
+
+describe("handoff copy filling", () => {
+ it("keeps rehearsal values literal", () => {
+ expect(
+ fillRangeCopy("{fromRole} hands off to {toRole} in {sectionLabel}.", {
+ fromRole: "Bass {toRole}",
+ toRole: "Lead Vocal",
+ sectionLabel: "verse"
+ })
+ ).toBe("Bass {toRole} hands off to Lead Vocal in verse.");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstHandoff.ts b/apps/desktop/src/features/workspace/firstHandoff.ts
new file mode 100644
index 000000000..2d44e7fac
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstHandoff.ts
@@ -0,0 +1,121 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named part-to-part pass on the rehearsal map. */
+export type FirstHandoff = {
+ sectionLabel: string;
+ fromRole: string;
+ toRole: string;
+};
+
+/** Return whether an untrusted runtime value is a plain object record. */
+function isRuntimeObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Admit a named role id and display name from own properties only. */
+function namedRole(value: unknown): { id: string; name: string } | null {
+ if (
+ !isRuntimeObject(value) ||
+ !Object.prototype.hasOwnProperty.call(value, "id") ||
+ !Object.prototype.hasOwnProperty.call(value, "name")
+ ) {
+ return null;
+ }
+ const id = meaningfulRangeText(value.id);
+ const name = meaningfulRangeText(value.name);
+ if (!id || !name) {
+ return null;
+ }
+ return { id, name };
+}
+
+/** Build a named-role lookup from an untrusted section role collection. */
+function namedRolesById(values: unknown[]): Map {
+ const rolesById = new Map();
+ for (const roleValue of values) {
+ const role = namedRole(roleValue);
+ if (role && !rolesById.has(role.id)) {
+ rolesById.set(role.id, role.name);
+ }
+ }
+ return rolesById;
+}
+
+/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */
+function isOwnActive(value: Record): boolean {
+ return Object.prototype.hasOwnProperty.call(value, "is_active") && value.is_active === true;
+}
+
+/**
+ * Pick the first named part-to-part pass a player should lock before the next section.
+ *
+ * Analysis stores a transition on the source section's `partGraph.handoff_to`,
+ * while the rehearsal cue belongs to the immediately following section. The
+ * source section names the active giver; the destination section names a role
+ * that becomes active there. Inactive, inherited, blank, self, or unknown
+ * receivers are skipped. When a role is selected, only a pass that includes
+ * that role is named. Runtime roots and collection members are untrusted.
+ */
+export function firstHandoff(
+ song: RehearsalSong | unknown,
+ activeRole: string | null = null
+): FirstHandoff | null {
+ if (!isRuntimeObject(song) || !Array.isArray(song.sections)) {
+ return null;
+ }
+
+ for (let sectionIndex = 0; sectionIndex < song.sections.length - 1; sectionIndex += 1) {
+ const sectionValue = song.sections[sectionIndex];
+ const destinationValue = song.sections[sectionIndex + 1];
+ if (
+ !isRuntimeObject(sectionValue) ||
+ !Array.isArray(sectionValue.roles) ||
+ !Array.isArray(sectionValue.partGraph) ||
+ !isRuntimeObject(destinationValue) ||
+ !Array.isArray(destinationValue.roles)
+ ) {
+ continue;
+ }
+ const sectionLabel = meaningfulRangeText(destinationValue.label);
+ if (!sectionLabel) {
+ continue;
+ }
+
+ const sourceRolesById = namedRolesById(sectionValue.roles);
+ const destinationRolesById = namedRolesById(destinationValue.roles);
+
+ for (const nodeValue of sectionValue.partGraph) {
+ if (
+ !isRuntimeObject(nodeValue) ||
+ !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") ||
+ !isOwnActive(nodeValue) ||
+ !Array.isArray(nodeValue.handoff_to)
+ ) {
+ continue;
+ }
+ const fromId = meaningfulRangeText(nodeValue.role_id);
+ const fromRole = fromId ? sourceRolesById.get(fromId) : undefined;
+ if (!fromId || !fromRole) {
+ continue;
+ }
+
+ for (const receiverValue of nodeValue.handoff_to) {
+ const toId = meaningfulRangeText(receiverValue);
+ if (!toId || toId === fromId) {
+ continue;
+ }
+ const toRole = destinationRolesById.get(toId);
+ if (!toRole) {
+ continue;
+ }
+ if (activeRole && fromId !== activeRole && toId !== activeRole) {
+ continue;
+ }
+ return { sectionLabel, fromRole, toRole };
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..219353ad0 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -60,6 +60,7 @@ describe("i18n", () => {
const t = createTranslator("ko");
expect(t("appTitle")).toBe("BandScope");
expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구");
+ expect(t("workspaceFirstHandoffTitle")).toBe("오늘 먼저 맞출 넘김");
});
it("falls back to English when a Korean translation is missing", () => {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..e51b138d8 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,9 @@
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
+ "workspaceFirstHandoffTitle": "Tonight's first handoff",
+ "workspaceFirstHandoffPass": "{fromRole} hands off to {toRole} in {sectionLabel}. Lock that pass before the {sectionLabel}.",
+ "workspaceFirstHandoffMissing": "Tonight's first handoff still needs a pass. Pick who receives the next entrance and lock that pass before the first section.",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..27fb367f1 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,9 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstHandoffTitle": "오늘 먼저 맞출 넘김",
+ "workspaceFirstHandoffPass": "{sectionLabel}에서 {fromRole}이 {toRole}에게 넘깁니다. {sectionLabel} 들어가기 전에 그 넘김을 맞춰 보세요.",
+ "workspaceFirstHandoffMissing": "오늘 먼저 맞출 넘김은 아직 없습니다. 다음 시작을 받을 파트를 골라 첫 구간 전에 그 넘김을 맞춰 보세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..52e0b8ae1 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
- `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`.
- `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy.
- `EmptyState` must remain an actionable state card, not a blank placeholder panel.
+- Ready `Workspace` names tonight's first playable range and tonight's first part-to-part handoff so the map enables the next rehearsal action without opening files or export paths.
- If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging.
## Pattern Backlog
diff --git a/docs/doctoring/first-part-handoff.md b/docs/doctoring/first-part-handoff.md
new file mode 100644
index 000000000..4462641ab
--- /dev/null
+++ b/docs/doctoring/first-part-handoff.md
@@ -0,0 +1,17 @@
+# Tonight's first part handoff
+
+The ready rehearsal map names the first active part-to-part pass from existing `partGraph.handoff_to` evidence. Analysis records that transition on the source section, while the rehearsal cue belongs to the immediately following destination section. The workspace therefore names the destination section a player is preparing to enter. For activity-derived topology, the giving role is named from the source section's active roles and the receiving role is named from the destination section's active roles, matching the analysis engine's deactivate/activate transition contract. Heuristic fallback topology does not have signal-derived transition evidence, so it leaves `handoff_to` and `handoff_from` empty instead of manufacturing a pass. A one-section song has no transition to name and fails closed to the missing-pass next action. This is not the metadata-handoff export file.
+
+## Next action
+
+- Named: lock that pass on the giving and receiving parts before the named destination section.
+- Missing: pick who receives the next entrance and lock that pass before the first section.
+
+## Security Notes
+
+- Untrusted inputs: `RehearsalSong` JSON, source and destination section labels, role ids/names, `partGraph` nodes, `is_active`, and `handoff_to` values from analysis or a reopened project.
+- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property active source nodes, meaningful source/destination role names, and a meaningful immediately-following section label for copy.
+- Allowlist: `is_active` must be the own-property boolean `true`. Givers must be named roles in the source section; receivers must be named roles in the immediately following destination section and must not equal the giver. Inherited prototype members, blank names, `none` sentinels, unknown ids, malformed destinations, and blank destination labels fail closed.
+- Safe failure: malformed roots or missing destination sections return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a pass.
+- Logging/privacy: rejected or accepted role names are not logged. Copy interpolation keeps rehearsal values literal.
+- Tests: `firstHandoff.test.ts`, `services/analysis-engine/tests/test_roles.py`, and the Workspace callout cover destination labeling, signal-derived receivers that only become named in the destination section, heuristic fallback with no transition authority, absent/malformed destinations, inactive skip, selected-role scoping, inherited/malformed evidence, and literal copy filling.
diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py
index a0f092213..82ed7fdd2 100644
--- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py
+++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py
@@ -83,7 +83,7 @@ def extract(
extraction_method = (
"Extracted roles from real stem activity detection."
if activity_maps is not None
- else "Extracted roles and computed handoffs."
+ else "Extracted roles using heuristic fallback; handoffs unavailable."
)
return {
@@ -380,7 +380,7 @@ def _build_topology(
is_first: bool,
roles: dict[str, RehearsalRole],
) -> SectionRoleTopology:
- """Construct the topology including active roles and the part graph."""
+ """Construct heuristic topology without transition handoff authority."""
active_roles = [roles["bass"], roles["acoustic_guitar"]]
part_graph: list[PartGraphNode] = [
@@ -417,11 +417,6 @@ def _build_topology(
},
]
)
- for node in part_graph:
- if node["role_id"] == "bass-guitar":
- node["handoff_to"].append("lead-vocal")
- elif node["role_id"] == "lead-vocal":
- node["handoff_from"].append("bass-guitar")
else:
part_graph.extend(
[
diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py
index 45a2ddada..58e6359b0 100644
--- a/services/analysis-engine/tests/test_roles.py
+++ b/services/analysis-engine/tests/test_roles.py
@@ -43,6 +43,7 @@ def test_role_extractor_basic() -> None:
assert "topologies" in result
assert "extraction_notes" in result
+ assert result["extraction_notes"] == "Extracted roles using heuristic fallback; handoffs unavailable."
assert len(result["topologies"]) == 2
# Check intro section
@@ -61,9 +62,9 @@ def test_role_extractor_basic() -> None:
intro_graph = intro_topology["part_graph"]
graph_by_role = {n["role_id"]: n for n in intro_graph}
- # Check handoff relation
- assert "lead-vocal" in graph_by_role["bass-guitar"]["handoff_to"]
- assert "bass-guitar" in graph_by_role["lead-vocal"]["handoff_from"]
+ # Heuristic fallback cannot prove a cross-section transition.
+ assert graph_by_role["bass-guitar"]["handoff_to"] == []
+ assert graph_by_role["lead-vocal"]["handoff_from"] == []
# Check verse-1 section (only bass)
verse_topology = result["topologies"][1]