diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..6cbd3f72a 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, stop-time cutoffs, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- 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..48bf5c511 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -80,7 +80,7 @@ Last updated: 2026-03-11
- Core rehearsal artifacts should include:
- likely harmony by section and by role
- - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
+ - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs, with the ready workspace naming tonight's first stop and the next entrance
- 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
- simplification, transposition, capo, tuning, or setup cues where applicable
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..f4d5f98d3 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 stop on the ready rehearsal map and tell the band to cut together before the next entrance.
- 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..7c9d871ae 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, and names tonight's first stop so the room can cut together before 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/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
index 5b32019d2..5cab84b41 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
@@ -106,4 +106,40 @@ describe("SectionRoadmap", () => {
expect(onSongUpdate).not.toHaveBeenCalled();
});
+
+ it("names the stop next action only on the destination card", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-1", label: "verse" },
+ {
+ ...verse,
+ id: "stop-1",
+ label: "stop",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` }))
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
+ }
+ ];
+
+ render();
+
+ expect(screen.getByTestId("first-stop-action-stop-1")).toHaveTextContent(
+ "Cut together here, then come back in on chorus."
+ );
+ expect(screen.queryByTestId("first-stop-action-verse-1")).toBeNull();
+ expect(screen.queryByTestId("first-stop-action-chorus-1")).toBeNull();
+ });
+
+ it("omits the stop next action when no stop is named", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ expect(screen.queryByText(/Cut together here/i)).toBeNull();
+ });
});
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 834d1e8f0..1aad4ae43 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -3,6 +3,7 @@ import { useId, useMemo } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
+import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
+ const tonightStop = useMemo(() => firstStop(song), [song]);
/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
@@ -120,6 +122,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{t("sectionGrooveLabel")}
{section.groove}
+ {tonightStop && isStopTarget(tonightStop, section.id) ? (
+
+ {fillStopCopy(
+ t(tonightStop.nextSectionLabel ? "sectionStopNextAction" : "sectionStopNextActionBare"),
+ stopCopyValues(tonightStop)
+ )}
+
+ ) : null}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..6db9e9d83 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -196,6 +196,53 @@ describe("Workspace", () => {
);
});
+ it("asks for an ear check when the map has no named stop", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ expect(screen.getByTestId("first-stop")).toHaveTextContent("Tonight's first stop");
+ expect(screen.getByTestId("first-stop")).toHaveTextContent(
+ "Tonight's first stop still needs an ear check. Listen for the place everyone cuts out together before the next section."
+ );
+ });
+
+ it("names tonight's first stop and the next entrance", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-1", label: "verse" },
+ {
+ ...verse,
+ id: "stop-1",
+ label: "stop",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` }))
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
+ }
+ ];
+
+ render();
+
+ expect(screen.getByTestId("first-stop")).toHaveTextContent(
+ "Cut together in stop after verse, then come back in on chorus."
+ );
+ });
+
+ it("localizes the missing stop callout", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+
+ expect(screen.getByTestId("first-stop")).toHaveTextContent("오늘 먼저 맞출 스톱");
+ expect(screen.getByTestId("first-stop")).toHaveTextContent(
+ "오늘 먼저 맞출 스톱은 아직 귀로 확인이 필요합니다. 다음 구간 전에 다 같이 끊는 자리를 들어 보세요."
+ );
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..52feaaf1a 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 { fillStopCopy, firstStop, stopCopyValues } from "./firstStop";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +164,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const tonightStop = useMemo(() => firstStop(song), [song]);
+ const firstStopCopy = tonightStop
+ ? fillStopCopy(
+ t(
+ tonightStop.previousSectionLabel && tonightStop.nextSectionLabel
+ ? "workspaceFirstStopCheck"
+ : tonightStop.nextSectionLabel
+ ? "workspaceFirstStopCheckNoPrevious"
+ : tonightStop.previousSectionLabel
+ ? "workspaceFirstStopCheckNoNext"
+ : "workspaceFirstStopCheckBare"
+ ),
+ stopCopyValues(tonightStop)
+ )
+ : t("workspaceFirstStopMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -310,6 +326,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{firstRangeCopy}
+
+ {t("workspaceFirstStopTitle")}
+ {firstStopCopy}
+
+
{t("workspaceSongTimelineLabel")}
diff --git a/apps/desktop/src/features/workspace/firstStop.test.ts b/apps/desktop/src/features/workspace/firstStop.test.ts
new file mode 100644
index 000000000..09d5ae509
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStop.test.ts
@@ -0,0 +1,177 @@
+import { createDemoRehearsalSong, type RehearsalSong, type RehearsalSection } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop";
+
+function cloneSong(song: RehearsalSong): RehearsalSong {
+ return {
+ ...song,
+ sections: song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => ({ ...role }))
+ }))
+ };
+}
+
+function withSections(song: RehearsalSong, sections: RehearsalSection[]): RehearsalSong {
+ return { ...song, sections };
+}
+
+function namedSection(base: RehearsalSection, id: string, label: RehearsalSection["label"]): RehearsalSection {
+ return {
+ ...base,
+ id,
+ label,
+ roles: base.roles.map((role) => ({ ...role, id: `${role.id}-${id}` }))
+ };
+}
+
+describe("firstStop", () => {
+ it("returns null on the demo song so the map asks for an ear check", () => {
+ expect(firstStop(createDemoRehearsalSong())).toBeNull();
+ });
+
+ it("names the first form-labeled stop and the sections around it", () => {
+ const verse = createDemoRehearsalSong().sections[0]!;
+ const song = withSections(createDemoRehearsalSong(), [
+ namedSection(verse, "verse-1", "verse"),
+ namedSection(verse, "stop-1", "stop"),
+ namedSection(verse, "chorus-1", "chorus")
+ ]);
+
+ expect(firstStop(song)).toEqual({
+ sectionId: "stop-1",
+ sectionLabel: "stop",
+ previousSectionLabel: "verse",
+ nextSectionLabel: "chorus"
+ });
+ });
+
+ it("skips earlier non-stop sections and keeps the first stop only", () => {
+ const verse = createDemoRehearsalSong().sections[0]!;
+ const song = withSections(createDemoRehearsalSong(), [
+ namedSection(verse, "intro-1", "intro"),
+ namedSection(verse, "verse-1", "verse"),
+ namedSection(verse, "stop-1", "stop"),
+ namedSection(verse, "stop-2", "stop"),
+ namedSection(verse, "chorus-1", "chorus")
+ ]);
+
+ expect(firstStop(song)?.sectionId).toBe("stop-1");
+ });
+
+ it("omits previous or next labels when those neighbors are unnamed", () => {
+ const verse = createDemoRehearsalSong().sections[0]!;
+ const song = withSections(createDemoRehearsalSong(), [
+ { ...namedSection(verse, "verse-1", "verse"), label: "verse" },
+ namedSection(verse, "stop-1", "stop")
+ ]);
+ song.sections[0] = { ...song.sections[0]!, label: "none" as RehearsalSection["label"] };
+
+ expect(firstStop(song)).toEqual({
+ sectionId: "stop-1",
+ sectionLabel: "stop",
+ previousSectionLabel: undefined,
+ nextSectionLabel: undefined
+ });
+ });
+
+ it("does not invent a stop from groove or cue wording", () => {
+ const song = cloneSong(createDemoRehearsalSong());
+ song.sections[0] = {
+ ...song.sections[0]!,
+ groove: "Stop-time hits on beat 4",
+ roles: song.sections[0]!.roles.map((role) => ({
+ ...role,
+ cue: { kind: "transition", value: "Cut together on the stop." }
+ }))
+ };
+
+ expect(firstStop(song)).toBeNull();
+ });
+
+ it("fails closed on malformed runtime roots and members", () => {
+ expect(firstStop(null as unknown as RehearsalSong)).toBeNull();
+ expect(firstStop({ sections: "nope" } as unknown as RehearsalSong)).toBeNull();
+ expect(
+ firstStop({
+ ...createDemoRehearsalSong(),
+ sections: [null, "skip", { label: "stop" }]
+ } as unknown as RehearsalSong)
+ ).toBeNull();
+ });
+
+ it("fails closed when a section identity is missing", () => {
+ const song = cloneSong(createDemoRehearsalSong());
+ song.sections[0] = { ...song.sections[0]!, id: " " };
+
+ expect(firstStop(song)).toBeNull();
+ });
+
+ it("fails closed when repeated section ids cannot identify one destination card", () => {
+ const verse = createDemoRehearsalSong().sections[0]!;
+ const song = withSections(createDemoRehearsalSong(), [
+ namedSection(verse, "duplicate", "verse"),
+ namedSection(verse, "duplicate", "stop")
+ ]);
+
+ expect(firstStop(song)).toBeNull();
+ });
+
+ it("trims labels before matching the stop form", () => {
+ const verse = createDemoRehearsalSong().sections[0]!;
+ const song = withSections(createDemoRehearsalSong(), [
+ namedSection(verse, "verse-1", "verse"),
+ { ...namedSection(verse, "stop-1", "stop"), label: " stop " as RehearsalSection["label"] },
+ namedSection(verse, "chorus-1", "chorus")
+ ]);
+
+ expect(firstStop(song)).toEqual({
+ sectionId: "stop-1",
+ sectionLabel: "stop",
+ previousSectionLabel: "verse",
+ nextSectionLabel: "chorus"
+ });
+ });
+});
+
+describe("fillStopCopy", () => {
+ it("fills own-property tokens once and leaves unknown tokens literal", () => {
+ expect(
+ fillStopCopy("Cut together in {sectionLabel} after {previousSectionLabel}.", {
+ sectionLabel: "stop",
+ previousSectionLabel: "verse"
+ })
+ ).toBe("Cut together in stop after verse.");
+ expect(fillStopCopy("Keep {toString}", { sectionLabel: "stop" })).toBe("Keep {toString}");
+ });
+});
+
+describe("isStopTarget", () => {
+ it("matches only the named stop identity", () => {
+ const stop = {
+ sectionId: "stop-1",
+ sectionLabel: "stop",
+ previousSectionLabel: "verse",
+ nextSectionLabel: "chorus"
+ };
+
+ expect(isStopTarget(stop, "stop-1")).toBe(true);
+ expect(isStopTarget(stop, " verse-1 ")).toBe(false);
+ expect(isStopTarget(stop, " ")).toBe(false);
+ });
+});
+
+describe("stopCopyValues", () => {
+ it("exposes empty neighbor tokens when those labels are missing", () => {
+ expect(
+ stopCopyValues({
+ sectionId: "stop-1",
+ sectionLabel: "stop"
+ })
+ ).toEqual({
+ sectionLabel: "stop",
+ previousSectionLabel: "",
+ nextSectionLabel: ""
+ });
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStop.ts b/apps/desktop/src/features/workspace/firstStop.ts
new file mode 100644
index 000000000..ee34ca83b
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStop.ts
@@ -0,0 +1,108 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named stop on the rehearsal map. */
+export type FirstStop = {
+ sectionId: string;
+ sectionLabel: string;
+ previousSectionLabel?: string;
+ nextSectionLabel?: string;
+};
+
+const STOP_LABEL = "stop";
+const UNNAMED_SECTION_LABEL = "none";
+
+/** Return whether an untrusted runtime value is a plain object record. */
+function isRuntimeObject(runtimeValue: unknown): runtimeValue is Record {
+ return typeof runtimeValue === "object" && runtimeValue !== null && !Array.isArray(runtimeValue);
+}
+
+/**
+ * Pick the first named stop the band should cut together before the next entrance.
+ *
+ * Runtime roots and collection members are untrusted. Missing or duplicate
+ * section identities fail closed so a later stop cannot steal another card.
+ * Only the canonical form label `stop` becomes rehearsal-map authority;
+ * groove text and cue wording never invent a stop.
+ */
+export function firstStop(rehearsalSong: RehearsalSong): FirstStop | null {
+ const runtimeSong: unknown = rehearsalSong;
+ if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
+ return null;
+ }
+
+ const seenSectionIds = new Set();
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ return null;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ if (!sectionId) {
+ return null;
+ }
+ if (seenSectionIds.has(sectionId)) {
+ return null;
+ }
+ seenSectionIds.add(sectionId);
+ }
+
+ type NamedSection = {
+ sectionId: string;
+ sectionLabel: string;
+ };
+
+ const namedSections: NamedSection[] = [];
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ return null;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ const runtimeSectionLabel = meaningfulRangeText(sectionValue.label);
+ if (!sectionId) {
+ return null;
+ }
+ namedSections.push({
+ sectionId,
+ sectionLabel: runtimeSectionLabel === UNNAMED_SECTION_LABEL ? "" : runtimeSectionLabel ?? ""
+ });
+ }
+
+ for (let sectionIndex = 0; sectionIndex < namedSections.length; sectionIndex += 1) {
+ const currentSection = namedSections[sectionIndex];
+ if (!currentSection || currentSection.sectionLabel !== STOP_LABEL) {
+ continue;
+ }
+
+ const previousSectionLabel = namedSections[sectionIndex - 1]?.sectionLabel;
+ const nextSectionLabel = namedSections[sectionIndex + 1]?.sectionLabel;
+
+ return {
+ sectionId: currentSection.sectionId,
+ sectionLabel: currentSection.sectionLabel,
+ previousSectionLabel: previousSectionLabel || undefined,
+ nextSectionLabel: nextSectionLabel || undefined
+ };
+ }
+
+ return null;
+}
+
+/** Fill trusted `{token}` placeholders for stop rehearsal copy. */
+export function fillStopCopy(copyTemplate: string, copyValues: Record): string {
+ return fillRangeCopy(copyTemplate, copyValues);
+}
+
+/** True when this stable section identity owns tonight's first-stop action. */
+export function isStopTarget(firstStopResult: FirstStop, sectionId: string): boolean {
+ const normalizedSectionId = meaningfulRangeText(sectionId);
+ return Boolean(normalizedSectionId) && normalizedSectionId === firstStopResult.sectionId;
+}
+
+/** Tokens for the buyer-visible stop callout and roadmap next action. */
+export function stopCopyValues(firstStopResult: FirstStop): Record {
+ return {
+ sectionLabel: firstStopResult.sectionLabel,
+ previousSectionLabel: firstStopResult.previousSectionLabel ?? "",
+ nextSectionLabel: firstStopResult.nextSectionLabel ?? ""
+ };
+}
\ No newline at end of file
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..887bfcd55 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,14 @@
"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.",
+ "workspaceFirstStopTitle": "Tonight's first stop",
+ "workspaceFirstStopCheck": "Cut together in {sectionLabel} after {previousSectionLabel}, then come back in on {nextSectionLabel}.",
+ "workspaceFirstStopCheckNoPrevious": "Cut together in {sectionLabel}, then come back in on {nextSectionLabel}.",
+ "workspaceFirstStopCheckNoNext": "Cut together in {sectionLabel} after {previousSectionLabel}, then come back in on the next downbeat.",
+ "workspaceFirstStopCheckBare": "Cut together in {sectionLabel}, then come back in on the next downbeat.",
+ "workspaceFirstStopMissing": "Tonight's first stop still needs an ear check. Listen for the place everyone cuts out together before the next section.",
"sectionRangeLabel": "Range",
- "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
+ "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.",
+ "sectionStopNextAction": "Cut together here, then come back in on {nextSectionLabel}.",
+ "sectionStopNextActionBare": "Cut together here, then come back in on the next downbeat."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..698d465fb 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,14 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstStopTitle": "오늘 먼저 맞출 스톱",
+ "workspaceFirstStopCheck": "{previousSectionLabel} 다음 {sectionLabel}에서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.",
+ "workspaceFirstStopCheckNoPrevious": "{sectionLabel}에서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.",
+ "workspaceFirstStopCheckNoNext": "{previousSectionLabel} 다음 {sectionLabel}에서 같이 끊고, 다음 다운비트에 다시 들어오세요.",
+ "workspaceFirstStopCheckBare": "{sectionLabel}에서 같이 끊고, 다음 다운비트에 다시 들어오세요.",
+ "workspaceFirstStopMissing": "오늘 먼저 맞출 스톱은 아직 귀로 확인이 필요합니다. 다음 구간 전에 다 같이 끊는 자리를 들어 보세요.",
"sectionRangeLabel": "음역",
- "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
+ "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.",
+ "sectionStopNextAction": "여기서 같이 끊고, {nextSectionLabel}에서 다시 들어오세요.",
+ "sectionStopNextActionBare": "여기서 같이 끊고, 다음 다운비트에 다시 들어오세요."
}
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 3cf5261b9..a284cb268 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -9,7 +9,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c
## Core rehearsal artifacts
- likely harmony by section and by role
-- section roadmap with entries, dropouts, pickups, stops, and handoffs
+- section roadmap with entries, dropouts, pickups, stops, and handoffs; the ready workspace names tonight's first stop and the next entrance
- groove and timing cues
- role ranges, overlap warnings, and simplification guidance
- transposition, capo, tuning, or setup cues where relevant
diff --git a/docs/architecture/rehearsal-domain-model.md b/docs/architecture/rehearsal-domain-model.md
index 4b177dbf1..f61630ca2 100644
--- a/docs/architecture/rehearsal-domain-model.md
+++ b/docs/architecture/rehearsal-domain-model.md
@@ -24,6 +24,7 @@ BandScope models a song as rehearsal-facing roles, not only as a single global h
- A section model should support intro, verse, pre-chorus, chorus, bridge, outro, tags, pickups, stops, and handoffs.
- A rehearsal roadmap should expose who enters, who drops out, and where the band must re-enter together.
+- The ready workspace names tonight's first stop so the room can cut together before the next entrance.
- Cue anchors should support lyric phrases, count-based entries, or section-transition markers.
## Groove cues and rhythmic feel
diff --git a/services/analysis-engine/src/bandscope_analysis/roles/__init__.py b/services/analysis-engine/src/bandscope_analysis/roles/__init__.py
index e432ed3dd..49224362f 100644
--- a/services/analysis-engine/src/bandscope_analysis/roles/__init__.py
+++ b/services/analysis-engine/src/bandscope_analysis/roles/__init__.py
@@ -1,6 +1,6 @@
"""Role extraction and part graph models."""
-from .extractor import RoleExtractor
+from .coordinated_extractor import CoordinatedRoleExtractor
from .model import (
CueAnchorKind,
PartGraphNode,
@@ -12,8 +12,13 @@
)
from .tuning import get_setup_note
+# Preserve the established package-level import while routing the analysis
+# pipeline through the cross-role temporal coordination boundary.
+RoleExtractor = CoordinatedRoleExtractor
+
__all__ = [
"RoleExtractor",
+ "CoordinatedRoleExtractor",
"CueAnchorKind",
"PartGraphNode",
"RehearsalPriority",
diff --git a/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py
new file mode 100644
index 000000000..bde5a3233
--- /dev/null
+++ b/services/analysis-engine/src/bandscope_analysis/roles/coordinated_extractor.py
@@ -0,0 +1,73 @@
+"""Cross-role rehearsal extraction that binds temporal stop-time to stable section cards."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..temporal import hits as temporal_hits
+from .extractor import RoleExtractor as CoreRoleExtractor
+from .model import RoleExtractionResult
+
+
+def _apply_stop_time_section_labels(
+ section_candidates: list[Any],
+ audio_features: dict[str, Any] | None,
+) -> None:
+ """Map each detected all-stem cutoff onto the stable section immediately before re-entry.
+
+ Stop-time is detected from separated stems, while structural segmentation owns
+ stable section identities and ranges. The first boundary range containing a
+ stop's start time is therefore the rehearsal card that owns the cut. Exact
+ boundary starts deliberately resolve to the preceding range so the next card
+ remains the re-entry destination. Only the form label changes; stable section
+ identity, timing, role topology, and persisted wire keys remain unchanged.
+ """
+ analysis_features = audio_features or {}
+ audio_stems = analysis_features.get("stems")
+ sample_rate = analysis_features.get("sr")
+ section_boundaries = analysis_features.get("boundaries")
+
+ if not isinstance(audio_stems, dict) or not audio_stems:
+ return
+ if not isinstance(sample_rate, int) or isinstance(sample_rate, bool) or sample_rate <= 0:
+ return
+ if not isinstance(section_boundaries, list) or len(section_boundaries) != len(section_candidates):
+ return
+
+ stop_time_moments = temporal_hits.detect_stop_time(audio_stems, sample_rate)
+ for stop_time_moment in stop_time_moments:
+ if not isinstance(stop_time_moment, dict):
+ continue
+ stop_start_time = stop_time_moment.get("start_time")
+ if isinstance(stop_start_time, bool) or not isinstance(stop_start_time, (int, float)):
+ continue
+
+ for section_index, section_boundary in enumerate(section_boundaries):
+ if not isinstance(section_boundary, (tuple, list)) or len(section_boundary) != 2:
+ continue
+ section_start_time, section_end_time = section_boundary
+ if (
+ isinstance(section_start_time, bool)
+ or isinstance(section_end_time, bool)
+ or not isinstance(section_start_time, (int, float))
+ or not isinstance(section_end_time, (int, float))
+ ):
+ continue
+ if section_start_time <= stop_start_time <= section_end_time:
+ section_candidate = section_candidates[section_index]
+ if isinstance(section_candidate, dict):
+ section_candidate["form_label"] = "stop"
+ break
+
+
+class CoordinatedRoleExtractor(CoreRoleExtractor):
+ """Role extractor that first binds cross-role stop-time evidence to section form."""
+
+ def extract(
+ self,
+ section_candidates: list[Any],
+ audio_features: dict[str, Any] | None = None,
+ ) -> RoleExtractionResult:
+ """Extract role topology after mapping all-stem cutoffs onto stable section cards."""
+ _apply_stop_time_section_labels(section_candidates, audio_features)
+ return super().extract(section_candidates, audio_features)
diff --git a/services/analysis-engine/tests/test_pipeline_integration.py b/services/analysis-engine/tests/test_pipeline_integration.py
index fa39d4eaa..0ebea475d 100644
--- a/services/analysis-engine/tests/test_pipeline_integration.py
+++ b/services/analysis-engine/tests/test_pipeline_integration.py
@@ -143,6 +143,70 @@ def test_pipeline_without_detected_sections_falls_back() -> None:
assert song["id"] == "demo-song"
+def test_pipeline_maps_detected_stop_time_to_section_before_reentry() -> None:
+ """Ensure an analyzed all-stem cutoff reaches the stable section consumed by the workspace."""
+ sample_rate = 22050
+ audio_stems = _make_realistic_stems(sr=sample_rate, duration=20.0)
+ stop_start_sample = int(sample_rate * 9.4)
+ stop_end_sample = int(sample_rate * 10.0)
+ for stem_audio in audio_stems.values():
+ stem_audio[stop_start_sample:stop_end_sample] = 0.0
+
+ detected_sections = [
+ {
+ "id": "verse-1",
+ "form_label": "verse",
+ "sequence_index": 0,
+ "groove": "driving",
+ "confidence_level": "high",
+ "confidence_source": "model",
+ "confidence_notes": "Synthetic verse",
+ "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"},
+ },
+ {
+ "id": "chorus-1",
+ "form_label": "chorus",
+ "sequence_index": 1,
+ "groove": "open",
+ "confidence_level": "high",
+ "confidence_source": "model",
+ "confidence_notes": "Synthetic chorus",
+ "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"},
+ },
+ ]
+ section_boundaries = [(0.0, 10.0), (10.0, 20.0)]
+
+ with (
+ patch(
+ "bandscope_analysis.api.segment_with_boundaries",
+ return_value=(detected_sections, section_boundaries),
+ ),
+ patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", return_value=None),
+ patch(
+ "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize",
+ return_value=[],
+ ),
+ ):
+ rehearsal_song = build_demo_rehearsal_song(
+ {
+ "stems": audio_stems,
+ "sr": sample_rate,
+ "separation": {
+ "duration_seconds": 20.0,
+ "chunk_count": 1,
+ "notes": "Synthetic stop-time",
+ },
+ }
+ )
+
+ assert [section_payload["label"] for section_payload in rehearsal_song["sections"]] == [
+ "stop",
+ "chorus",
+ ]
+ assert rehearsal_song["sections"][0]["id"] == "verse-1"
+ assert rehearsal_song["sections"][1]["id"] == "chorus-1"
+
+
def test_pipeline_missing_boundary_uses_full_duration_range() -> None:
"""Ensure boundary count mismatches fail closed to the full duration."""
stems = _make_realistic_stems(sr=22050, duration=30.0)