diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..fd1b9ca3a 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, leftover last-dropout remaining last-return tutti cues after leftover last-dropout remaining last-return, 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..5e1545d44 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 and tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return
- 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..de0fc0e3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Name tonight's first leftover last-dropout remaining last-return tutti on the ready rehearsal map and tell the player the band is all in after leftover last-dropout remaining last-return.
- 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..54b64952b 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 tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return. `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..7a22387f2 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -153,6 +153,226 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ {
+ ...verse,
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "bass-guitar" ||
+ node.role_id === "keys-right" ||
+ node.role_id === "lead-vocal"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "keys-right" || node.role_id === "lead-vocal"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "bridge-1",
+ label: "bridge",
+ timeRange: {
+ start: verse.timeRange.end + 20,
+ end: verse.timeRange.end + 40
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "outro-1",
+ label: "outro",
+ timeRange: {
+ start: verse.timeRange.end + 40,
+ end: verse.timeRange.end + 60
+ }
+ },
+ {
+ ...verse,
+ id: "tag-1",
+ label: "tag",
+ timeRange: {
+ start: verse.timeRange.end + 60,
+ end: verse.timeRange.end + 80
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "keys-right" || node.role_id === "bass-guitar"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "coda-1",
+ label: "stop",
+ timeRange: {
+ start: verse.timeRange.end + 80,
+ end: verse.timeRange.end + 100
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "bass-guitar" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "ending-1",
+ label: "ending",
+ timeRange: {
+ start: verse.timeRange.end + 100,
+ end: verse.timeRange.end + 120
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "fine-1",
+ label: "fine",
+ timeRange: {
+ start: verse.timeRange.end + 120,
+ end: verse.timeRange.end + 140
+ }
+ }
+ ];
+
+ render();
+
+ const callout = screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti");
+ expect(callout).toHaveTextContent("Tonight's first leftover last-dropout remaining last-return tutti");
+ expect(callout).toHaveTextContent(
+ "Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Count the leftover last-dropout remaining last-return tutti in from the top of fine."
+ );
+ });
+
+ it("tells leftover last-dropout remaining last-return tutti to come in together after leftover last-dropout remaining last-return", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ {
+ ...verse,
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "bass-guitar" ||
+ node.role_id === "keys-right" ||
+ node.role_id === "lead-vocal"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "keys-right" || node.role_id === "lead-vocal"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "bridge-1",
+ label: "bridge",
+ timeRange: {
+ start: verse.timeRange.end + 20,
+ end: verse.timeRange.end + 40
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "outro-1",
+ label: "outro",
+ timeRange: {
+ start: verse.timeRange.end + 40,
+ end: verse.timeRange.end + 60
+ }
+ },
+ {
+ ...verse,
+ id: "tag-1",
+ label: "tag",
+ timeRange: {
+ start: verse.timeRange.end + 60,
+ end: verse.timeRange.end + 80
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "keys-right" || node.role_id === "bass-guitar"
+ ? { ...node, is_active: false }
+ : node
+ )
+ },
+ {
+ ...verse,
+ id: "coda-1",
+ label: "stop",
+ timeRange: {
+ start: verse.timeRange.end + 80,
+ end: verse.timeRange.end + 100
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "bass-guitar" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "ending-1",
+ label: "ending",
+ timeRange: {
+ start: verse.timeRange.end + 100,
+ end: verse.timeRange.end + 120
+ },
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "fine-1",
+ label: "fine",
+ timeRange: {
+ start: verse.timeRange.end + 120,
+ end: verse.timeRange.end + 140
+ }
+ }
+ ];
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent(
+ "Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Come in together from the top of fine."
+ );
+ });
+
+ it("asks the player to verify the required not-yet-all-in then all-in order", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent(
+ "This cue needs a last-return section where someone is still out, followed by a later section where everyone is in. Before rehearsing, confirm that order in the map."
+ );
+ });
+
it("asks for an ear check when the selected part has no named span", () => {
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..e2d74ea2c 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 { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +164,26 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const namedLeftoverLastDropoutRemainingLastReturnTutti = useMemo(
+ () => firstLeftoverLastDropoutRemainingLastReturnTutti(song, activeRole),
+ [activeRole, song]
+ );
+ const firstLeftoverLastDropoutRemainingLastReturnTuttiCopy = namedLeftoverLastDropoutRemainingLastReturnTutti
+ ? fillRangeCopy(
+ t(
+ activeRole && activeRole === namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleId
+ ? "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn"
+ : "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed"
+ ),
+ {
+ remainingRoleName: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleName,
+ sectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.sectionLabel,
+ remainingSectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingSectionLabel,
+ remainingLastReturnSectionLabel:
+ namedLeftoverLastDropoutRemainingLastReturnTutti.remainingLastReturnSectionLabel
+ }
+ )
+ : t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -309,6 +330,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{t("workspaceFirstRangeTitle")}
{firstRangeCopy}
+
+
+ {t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle")}
+
+
+ {firstLeftoverLastDropoutRemainingLastReturnTuttiCopy}
+
+
diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts
new file mode 100644
index 000000000..f6f14c7b9
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts
@@ -0,0 +1,194 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti";
+
+function sectionWithInactiveRoles(
+ template: RehearsalSong["sections"][number],
+ id: string,
+ label: RehearsalSong["sections"][number]["label"],
+ start: number,
+ inactiveRoleIds: readonly string[]
+): RehearsalSong["sections"][number] {
+ const inactive = new Set(inactiveRoleIds);
+ return {
+ ...template,
+ id,
+ label,
+ timeRange: { start, end: start + 20 },
+ partGraph: template.partGraph.map((node) => ({
+ ...node,
+ is_active: !inactive.has(node.role_id)
+ }))
+ };
+}
+
+function sectionTemplateWithObserver(
+ template: RehearsalSong["sections"][number]
+): RehearsalSong["sections"][number] {
+ const referenceRole = template.roles[0]!;
+ const referenceNode = template.partGraph[0]!;
+ return {
+ ...template,
+ roles: [
+ ...template.roles,
+ {
+ ...referenceRole,
+ id: "observer",
+ name: "Observer"
+ }
+ ],
+ partGraph: [
+ ...template.partGraph,
+ {
+ ...referenceNode,
+ role_id: "observer",
+ handoff_to: [],
+ handoff_from: []
+ }
+ ]
+ };
+}
+
+describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search", () => {
+ it("keeps searching after the selected part newly drops out during leftover last-dropout remaining last-return", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "fine-1", "fine", 140, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ });
+
+ it("invalidates the candidate when a new part drops out during remaining-leftover return", () => {
+ const seed = createDemoRehearsalSong();
+ const template = sectionTemplateWithObserver(seed.sections[0]!);
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["observer"]),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]),
+ sectionWithInactiveRoles(template, "stop-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "fine-1", "fine", 140, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toBeNull();
+ });
+
+ it("keeps searching after an earlier completed sequence excludes the selected part", () => {
+ const seed = createDemoRehearsalSong();
+ const template = sectionTemplateWithObserver(seed.sections[0]!);
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]),
+ sectionWithInactiveRoles(template, "stop-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "outro", 120, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "fine-1", "outro", 140, []),
+ sectionWithInactiveRoles(template, "verse-2", "verse", 160, [
+ "observer",
+ "bass-guitar",
+ "keys-right"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-2", "chorus", 180, [
+ "bass-guitar",
+ "keys-right"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-2", "bridge", 200, ["keys-right"]),
+ sectionWithInactiveRoles(template, "outro-2", "outro", 220, []),
+ sectionWithInactiveRoles(template, "tag-2", "tag", 240, ["observer", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "stop-2", "stop", 260, ["observer"]),
+ sectionWithInactiveRoles(template, "ending-2", "outro", 280, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "fine-2", "outro", 300, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "observer")).toEqual({
+ sectionLabel: "outro",
+ remainingLastReturnSectionLabel: "outro",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "observer",
+ remainingRoleName: "Observer"
+ });
+ });
+
+ it("does not tell leftover last-dropout remaining last-return without leftover last-dropout remaining last-return tutti to come in together", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "outro", 120, ["lead-vocal"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "bass-guitar")).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts
new file mode 100644
index 000000000..cb51fbed5
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts
@@ -0,0 +1,587 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { fillRangeCopy } from "./firstRangeSqueeze";
+import { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti";
+
+function sectionWithInactiveRoles(
+ template: RehearsalSong["sections"][number],
+ id: string,
+ label: string,
+ start: number,
+ inactiveRoleIds: readonly string[],
+ activeOnlyRoles = false
+): RehearsalSong["sections"][number] {
+ const inactive = new Set(inactiveRoleIds);
+ const partGraph = template.partGraph.map((node) => ({
+ ...node,
+ is_active: !inactive.has(node.role_id)
+ }));
+ return {
+ ...template,
+ id,
+ label: label as RehearsalSong["sections"][number]["label"],
+ timeRange: { start, end: start + 20 },
+ partGraph,
+ roles: activeOnlyRoles
+ ? template.roles.filter((role) => !inactive.has(role.id))
+ : template.roles
+ };
+}
+
+function leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(
+ remainingRoleId = "bass-guitar",
+ returningDropoutId = "keys-right",
+ lastRoleId = "lead-vocal",
+ returningLeftoverId = "keys-right",
+ originalSitOutRoleId = "bass-guitar",
+ leftoverAtLastReturnId = "lead-vocal"
+): RehearsalSong {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ return {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ originalSitOutRoleId,
+ returningLeftoverId,
+ lastRoleId
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ returningLeftoverId,
+ lastRoleId
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [lastRoleId]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, [
+ remainingRoleId,
+ returningDropoutId
+ ]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, [remainingRoleId]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, [leftoverAtLastReturnId]),
+ sectionWithInactiveRoles(template, "fine-1", "fine", 140, [])
+ ]
+ };
+}
+
+describe("firstLeftoverLastDropoutRemainingLastReturnTutti", () => {
+ it("returns null on the demo song where every graph node is active", () => {
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(createDemoRehearsalSong())).toBeNull();
+ });
+
+ it("names leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return", () => {
+ expect(
+ firstLeftoverLastDropoutRemainingLastReturnTutti(
+ leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti()
+ )
+ ).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ });
+
+ it("uses song-wide role names when inactive analysis roles are omitted from section roles", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "opening-1", "opening", 0, []),
+ sectionWithInactiveRoles(
+ template,
+ "bridge-1",
+ "bridge",
+ 20,
+ ["bass-guitar", "keys-right", "lead-vocal"],
+ true
+ ),
+ sectionWithInactiveRoles(
+ template,
+ "chorus-1",
+ "chorus",
+ 40,
+ ["keys-right", "lead-vocal"],
+ true
+ ),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 60, ["lead-vocal"], true),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 80, [], true),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["keys-right", "bass-guitar"], true),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["bass-guitar"], true),
+ sectionWithInactiveRoles(template, "wait-1", "wait", 140, ["lead-vocal"], true),
+ sectionWithInactiveRoles(template, "fine-1", "fine", 160, [], true)
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "wait",
+ remainingSectionLabel: "ending",
+ dropoutSectionLabel: "stop",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "bridge",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ });
+
+ it("does not treat leftover last-dropout remaining last-return that is already all-in as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("fails closed when leftover last-dropout remaining last-return never tuttis", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("keeps searching after continued leftover at leftover last-dropout remaining last-return until tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]),
+ sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "wait-1", "wait", 140, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "fine-1", "fine", 160, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ });
+
+ it("keeps the selected leftover last-dropout remaining last-return tutti on tonight's first leftover last-dropout remaining last-return tutti", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "bass-guitar")).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "keys-right")).toEqual({
+ sectionLabel: "fine",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ dropoutSectionLabel: "tag",
+ lastReturnSectionLabel: "outro",
+ leftoverSectionLabel: "chorus",
+ fromSectionLabel: "verse",
+ remainingRoleId: "bass-guitar",
+ remainingRoleName: "Bass Guitar"
+ });
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "missing-role")).toBeNull();
+ });
+
+ it("does not treat leftover last-dropout remaining as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat leftover last-dropout return with nobody still out as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat leftover last-return as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat remaining leftover at leftover return as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a leftover sit-out without leftover return as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ expect(
+ firstLeftoverLastDropoutRemainingLastReturnTutti({
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ])
+ ]
+ })
+ ).toBeNull();
+ });
+
+ it("does not treat a leftover return with nobody still out as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a come-in without a leftover sit-out as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["bass-guitar"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a tutti after a full original return as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a new dropout after remaining leftover as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar", "lead-vocal"])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a singleton leftover last-dropout return as leftover last-dropout remaining last-return tutti", () => {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const song: RehearsalSong = {
+ ...seed,
+ sections: [
+ sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
+ "bass-guitar",
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
+ "keys-right",
+ "lead-vocal"
+ ]),
+ sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
+ sectionWithInactiveRoles(template, "outro-1", "outro", 60, []),
+ sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right"]),
+ sectionWithInactiveRoles(template, "coda-1", "stop", 100, [])
+ ]
+ };
+
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("ignores inherited is_active evidence", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ const inherited = Object.create({
+ is_active: false,
+ role_id: "keys-right"
+ }) as RehearsalSong["sections"][number]["partGraph"][number];
+ song.sections[0] = {
+ ...song.sections[0]!,
+ partGraph: [inherited, ...song.sections[0]!.partGraph]
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("does not treat a missing is_active flag as leftover-last-dropout-remaining-last-return-tutti evidence", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ song.sections[1] = {
+ ...song.sections[1]!,
+ partGraph: song.sections[1]!.partGraph.map((node) => {
+ if (node.role_id !== "keys-right") {
+ return node;
+ }
+ const rest: Record = {
+ role_id: node.role_id,
+ handoff_to: node.handoff_to,
+ handoff_from: node.handoff_from
+ };
+ return rest as RehearsalSong["sections"][number]["partGraph"][number];
+ })
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("fails closed on contradictory duplicate graph identities", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ const section = song.sections[7]!;
+ const bassNode = section.partGraph.find((node) => node.role_id === "bass-guitar")!;
+ const withoutBass = section.partGraph.filter((node) => node.role_id !== "bass-guitar");
+ song.sections[7] = {
+ ...section,
+ partGraph: [...withoutBass, { ...bassNode, is_active: true }, { ...bassNode, is_active: false }]
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("skips blank leftover-last-dropout-remaining-last-return-tutti labels until a named leftover last-dropout remaining last-return tutti exists", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ song.sections[7] = {
+ ...song.sections[7]!,
+ label: "none" as RehearsalSong["sections"][number]["label"]
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("fails closed when leftover last-dropout remaining last-return tutti has no named leftover role", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ song.sections[0] = {
+ ...song.sections[0]!,
+ roles: song.sections[0]!.roles.map((role) => ({ ...role, name: " " }))
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("fails closed when a later section has no named graph", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ song.sections[7] = {
+ ...song.sections[7]!,
+ partGraph: []
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+
+ it("fails closed on malformed runtime roots", () => {
+ for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) {
+ expect(
+ firstLeftoverLastDropoutRemainingLastReturnTutti(malformed as unknown as RehearsalSong)
+ ).toBeNull();
+ }
+ });
+
+ it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => {
+ const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti();
+ song.sections[0] = {
+ ...song.sections[0]!,
+ partGraph: [
+ { role_id: " ", is_active: false, handoff_to: [], handoff_from: [] },
+ { role_id: "ghost", is_active: false, handoff_to: [], handoff_from: [] },
+ {
+ role_id: "keys-right",
+ is_active: "no" as unknown as boolean,
+ handoff_to: [],
+ handoff_from: []
+ },
+ ...song.sections[0]!.partGraph
+ ]
+ };
+ expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull();
+ });
+});
+
+describe("leftover-last-dropout-remaining-last-return-tutti copy filling", () => {
+ it("keeps rehearsal values literal", () => {
+ expect(
+ fillRangeCopy(
+ "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}.",
+ {
+ remainingRoleName: "Bass Guitar {sectionLabel}",
+ remainingLastReturnSectionLabel: "ending",
+ remainingSectionLabel: "stop",
+ sectionLabel: "fine"
+ }
+ )
+ ).toBe(
+ "Bass Guitar {sectionLabel} comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine."
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts
new file mode 100644
index 000000000..36599c193
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts
@@ -0,0 +1,615 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named leftover last-dropout remaining last-return tutti. */
+export type FirstLeftoverLastDropoutRemainingLastReturnTutti = {
+ sectionLabel: string;
+ remainingLastReturnSectionLabel: string;
+ remainingSectionLabel: string;
+ dropoutSectionLabel: string;
+ lastReturnSectionLabel: string;
+ leftoverSectionLabel: string;
+ fromSectionLabel: string;
+ remainingRoleId: string;
+ remainingRoleName: 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 an own-property boolean `is_active` flag. Inherited evidence is isolated. */
+function ownActiveFlag(value: Record): boolean | null {
+ if (!Object.prototype.hasOwnProperty.call(value, "is_active")) {
+ return null;
+ }
+ if (value.is_active === true) {
+ return true;
+ }
+ if (value.is_active === false) {
+ return false;
+ }
+ return null;
+}
+
+type NamedRoleCatalog = Map;
+
+/**
+ * Build trustworthy role identity evidence across the whole song.
+ *
+ * Production analysis emits active-only section `roles` while keeping inactive
+ * identities in `partGraph`. The song-wide catalog therefore lets leftover
+ * last-dropout remaining last-return tutti keep its trustworthy display name
+ * across leftover sit-out, remaining leftover, leftover last-return, leftover
+ * last-dropout, leftover last-dropout remaining, leftover last-dropout
+ * remaining last-return, and leftover last-dropout remaining last-return tutti.
+ */
+function namedSongRoles(songValue: Record): NamedRoleCatalog | null {
+ if (!Array.isArray(songValue.sections)) {
+ return null;
+ }
+
+ const namedRoles: NamedRoleCatalog = new Map();
+ for (const sectionValue of songValue.sections) {
+ if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) {
+ return null;
+ }
+
+ const sectionRoleIds = new Set();
+ for (const roleValue of sectionValue.roles) {
+ if (
+ !isRuntimeObject(roleValue) ||
+ !Object.prototype.hasOwnProperty.call(roleValue, "id") ||
+ !Object.prototype.hasOwnProperty.call(roleValue, "name")
+ ) {
+ return null;
+ }
+
+ const roleId = meaningfulRangeText(roleValue.id);
+ const roleName = meaningfulRangeText(roleValue.name);
+ if (!roleId || !roleName || sectionRoleIds.has(roleId)) {
+ return null;
+ }
+ sectionRoleIds.add(roleId);
+
+ const knownName = namedRoles.get(roleId);
+ if (knownName && knownName !== roleName) {
+ return null;
+ }
+ namedRoles.set(roleId, roleName);
+ }
+ }
+
+ return namedRoles.size > 0 ? namedRoles : null;
+}
+
+type NamedGraphNode = {
+ roleId: string;
+ active: boolean;
+};
+
+/**
+ * Collect one complete, unique activity record for every song-wide named role.
+ *
+ * Missing, unknown, duplicate, inherited, or non-boolean graph evidence fails
+ * closed so leftover last-dropout remaining cannot be both remaining and
+ * returning in the same leftover last-dropout remaining last-return tutti
+ * decision.
+ */
+function namedGraphNodes(
+ sectionValue: Record,
+ namedRoles: NamedRoleCatalog
+): NamedGraphNode[] | null {
+ if (!Array.isArray(sectionValue.partGraph)) {
+ return null;
+ }
+
+ const nodes: NamedGraphNode[] = [];
+ const seenRoleIds = new Set();
+ for (const nodeValue of sectionValue.partGraph) {
+ if (
+ !isRuntimeObject(nodeValue) ||
+ !Object.prototype.hasOwnProperty.call(nodeValue, "role_id")
+ ) {
+ return null;
+ }
+
+ const roleId = meaningfulRangeText(nodeValue.role_id);
+ if (!roleId || !namedRoles.has(roleId) || seenRoleIds.has(roleId)) {
+ return null;
+ }
+
+ const active = ownActiveFlag(nodeValue);
+ if (active === null) {
+ return null;
+ }
+
+ seenRoleIds.add(roleId);
+ nodes.push({ roleId, active });
+ }
+
+ return seenRoleIds.size === namedRoles.size ? nodes : null;
+}
+
+type PendingLeftoverSitOut = {
+ leftoverSectionLabel: string;
+ fromSectionLabel: string;
+ leftoverIds: string[];
+ originalSitOutIds: string[];
+};
+
+type PendingRemainingLeftover = {
+ leftoverSectionLabel: string;
+ remainingSectionLabel: string;
+ fromSectionLabel: string;
+ leftoverIds: string[];
+ remainingIds: string[];
+ originalSitOutIds: string[];
+};
+
+type PendingLastReturn = {
+ leftoverSectionLabel: string;
+ remainingSectionLabel: string;
+ lastReturnSectionLabel: string;
+ fromSectionLabel: string;
+ leftoverIds: string[];
+ remainingIds: string[];
+ originalSitOutIds: string[];
+ lastRoleId: string;
+};
+
+type PendingDropout = {
+ leftoverSectionLabel: string;
+ remainingSectionLabel: string;
+ lastReturnSectionLabel: string;
+ dropoutSectionLabel: string;
+ fromSectionLabel: string;
+ leftoverIds: string[];
+ remainingIds: string[];
+ originalSitOutIds: string[];
+ lastRoleId: string;
+ dropoutIds: string[];
+};
+
+type PendingRemainingDropout = {
+ leftoverSectionLabel: string;
+ remainingSectionLabel: string;
+ lastReturnSectionLabel: string;
+ dropoutSectionLabel: string;
+ remainingDropoutSectionLabel: string;
+ fromSectionLabel: string;
+ leftoverIds: string[];
+ remainingIds: string[];
+ originalSitOutIds: string[];
+ lastRoleId: string;
+ dropoutIds: string[];
+ remainingDropoutIds: string[];
+ returningDropoutIds: string[];
+};
+
+type PendingTutti = PendingRemainingDropout & {
+ remainingLastReturnSectionLabel: string;
+ leftoverAtLastReturnIds: string[];
+};
+
+/**
+ * Return whether the selected part belongs to this leftover last-dropout
+ * remaining last-return tutti.
+ *
+ * A leftover last-dropout remaining last-return tutti is shown only after
+ * leftover last-dropout remaining last-return whose original sit-out, leftover,
+ * remaining leftover, last leftover, leftover last-dropout, leftover last-dropout
+ * remaining, leftover last-dropout remaining last-return, leftover at leftover
+ * last-dropout remaining last-return, or leftover last-dropout remaining
+ * last-return tutti includes that selected part, so a silent always-active part
+ * is never told to count someone in.
+ */
+function selectedPartBelongs(
+ pending: PendingRemainingDropout | PendingTutti,
+ activeRole: string | null
+): boolean {
+ if (!activeRole) {
+ return true;
+ }
+ const leftoverAtLastReturnIds =
+ "leftoverAtLastReturnIds" in pending ? pending.leftoverAtLastReturnIds : [];
+ return (
+ pending.originalSitOutIds.includes(activeRole) ||
+ pending.leftoverIds.includes(activeRole) ||
+ pending.remainingIds.includes(activeRole) ||
+ pending.lastRoleId === activeRole ||
+ pending.dropoutIds.includes(activeRole) ||
+ pending.remainingDropoutIds.includes(activeRole) ||
+ pending.returningDropoutIds.includes(activeRole) ||
+ leftoverAtLastReturnIds.includes(activeRole)
+ );
+}
+
+/**
+ * Pick the first leftover last-dropout remaining last-return tutti a player
+ * should honor.
+ *
+ * A leftover sit-out is the first later named section where at least one member
+ * of the current reduced cohort has returned and at least one remains out. A
+ * leftover return with remaining leftover is the first named section after that
+ * leftover sit-out where at least one leftover part is own-property active and
+ * at least one leftover remains own-property tacet. A leftover last-return is
+ * the first later named section where every remaining leftover is own-property
+ * active. A leftover last-dropout is the first later named section after that
+ * leftover last-return where at least two named parts are own-property tacet.
+ * A leftover last-dropout remaining is the first later named section after that
+ * leftover last-dropout where at least one leftover last-dropout named part is
+ * own-property active and at least one leftover last-dropout remains
+ * own-property tacet. A leftover last-dropout remaining last-return is the first
+ * later named section after leftover last-dropout remaining where every leftover
+ * last-dropout remaining named part is own-property active and at least one
+ * named part remains own-property tacet. A leftover last-dropout remaining
+ * last-return tutti is the first later named section after leftover last-dropout
+ * remaining last-return where every named graph role is own-property active.
+ * Leftover last-dropout remaining last-return that is already all-in, leftover
+ * last-dropout remaining, leftover last-dropout, leftover last-dropout return
+ * with nobody still out, leftover last-return, remaining leftover, leftover
+ * sit-out, leftover return, come-in, tacet, tutti after leftover last-return
+ * without leftover last-dropout remaining last-return, new dropout after
+ * remaining leftover, leftover last-dropout without leftover last-dropout
+ * remaining, leftover last-dropout remaining last-return without a later tutti,
+ * or a singleton leftover last-dropout is not leftover last-dropout remaining
+ * last-return tutti.
+ *
+ * Inherited/missing activity, incomplete or contradictory graphs, unnamed
+ * roles, and malformed runtime data fail closed. When a role is selected, a
+ * leftover last-dropout remaining last-return tutti is shown only after leftover
+ * last-dropout remaining last-return that includes that named part, leftover at
+ * leftover last-dropout remaining last-return of that named part, or leftover
+ * last-dropout remaining last-return tutti of that named part.
+ */
+export function firstLeftoverLastDropoutRemainingLastReturnTutti(
+ song: RehearsalSong | unknown,
+ activeRole: string | null = null
+): FirstLeftoverLastDropoutRemainingLastReturnTutti | null {
+ if (!isRuntimeObject(song) || !Array.isArray(song.sections)) {
+ return null;
+ }
+
+ const namedRoles = namedSongRoles(song);
+ if (!namedRoles || (activeRole && !namedRoles.has(activeRole))) {
+ return null;
+ }
+
+ let reducedFrom: string | null = null;
+ let sittingOutIds: Set | null = null;
+ let pendingSitOut: PendingLeftoverSitOut | null = null;
+ let pendingRemaining: PendingRemainingLeftover | null = null;
+ let pendingLastReturn: PendingLastReturn | null = null;
+ let pendingDropout: PendingDropout | null = null;
+ let pendingRemainingDropout: PendingRemainingDropout | null = null;
+ let pendingTutti: PendingTutti | null = null;
+
+ for (const sectionValue of song.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ return null;
+ }
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ if (!sectionLabel) {
+ continue;
+ }
+
+ const nodes = namedGraphNodes(sectionValue, namedRoles);
+ if (!nodes) {
+ return null;
+ }
+
+ const sittingOut = nodes.filter((node) => node.active === false);
+
+ if (pendingTutti) {
+ const pending = pendingTutti;
+ if (activeRole && !selectedPartBelongs(pending, activeRole)) {
+ pendingTutti = null;
+ if (sittingOut.length === 0) {
+ reducedFrom = null;
+ sittingOutIds = null;
+ } else {
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ }
+ } else {
+ if (sittingOut.length === 0) {
+ return {
+ sectionLabel,
+ remainingLastReturnSectionLabel: pending.remainingLastReturnSectionLabel,
+ remainingSectionLabel: pending.remainingDropoutSectionLabel,
+ dropoutSectionLabel: pending.dropoutSectionLabel,
+ lastReturnSectionLabel: pending.lastReturnSectionLabel,
+ leftoverSectionLabel: pending.leftoverSectionLabel,
+ fromSectionLabel: pending.fromSectionLabel,
+ remainingRoleId: pending.lastRoleId,
+ remainingRoleName: namedRoles.get(pending.lastRoleId)!
+ };
+ }
+ continue;
+ }
+ }
+
+ if (pendingRemainingDropout) {
+ const remaining: PendingRemainingDropout = pendingRemainingDropout;
+ const remainingNodes = remaining.remainingDropoutIds.map((remainingId) =>
+ nodes.find((node) => node.roleId === remainingId)
+ );
+ if (remainingNodes.some((node) => !node)) {
+ return null;
+ }
+
+ const returningLast = remainingNodes.filter((node) => node!.active);
+ const stillRemaining = remainingNodes.filter((node) => node!.active === false);
+
+ if (returningLast.length > 0 && stillRemaining.length === 0) {
+ let last = returningLast[0]!;
+ if (activeRole) {
+ if (!selectedPartBelongs(remaining, activeRole) && sittingOut.every((node) => node.roleId !== activeRole)) {
+ continue;
+ }
+ const selectedLast = returningLast.find((node) => node!.roleId === activeRole);
+ if (selectedLast) {
+ last = selectedLast;
+ }
+ }
+ if (sittingOut.length === 0) {
+ pendingRemainingDropout = null;
+ reducedFrom = null;
+ sittingOutIds = null;
+ continue;
+ }
+ pendingTutti = {
+ ...remaining,
+ lastRoleId: last.roleId,
+ remainingLastReturnSectionLabel: sectionLabel,
+ leftoverAtLastReturnIds: sittingOut.map((node) => node.roleId)
+ };
+ pendingRemainingDropout = null;
+ continue;
+ }
+
+ if (returningLast.length > 0 && stillRemaining.length > 0) {
+ pendingRemainingDropout = {
+ ...remaining,
+ remainingDropoutIds: stillRemaining.map((node) => node!.roleId)
+ };
+ }
+ continue;
+ }
+
+ if (pendingDropout) {
+ const dropout = pendingDropout;
+ const dropoutNodes = dropout.dropoutIds.map((dropoutId) =>
+ nodes.find((node) => node.roleId === dropoutId)
+ );
+ if (dropoutNodes.some((node) => !node)) {
+ return null;
+ }
+
+ const returningDropouts = dropoutNodes.filter((node) => node!.active);
+ const remainingDropouts = dropoutNodes.filter((node) => node!.active === false);
+
+ if (returningDropouts.length > 0 && remainingDropouts.length > 0) {
+ if (activeRole) {
+ const selectedDropout = remainingDropouts.find((node) => node!.roleId === activeRole);
+ if (
+ !selectedDropout &&
+ !dropout.originalSitOutIds.includes(activeRole) &&
+ !dropout.leftoverIds.includes(activeRole) &&
+ !dropout.remainingIds.includes(activeRole) &&
+ dropout.lastRoleId !== activeRole &&
+ !dropout.dropoutIds.includes(activeRole)
+ ) {
+ continue;
+ }
+ }
+ pendingRemainingDropout = {
+ leftoverSectionLabel: dropout.leftoverSectionLabel,
+ remainingSectionLabel: dropout.remainingSectionLabel,
+ lastReturnSectionLabel: dropout.lastReturnSectionLabel,
+ dropoutSectionLabel: dropout.dropoutSectionLabel,
+ remainingDropoutSectionLabel: sectionLabel,
+ fromSectionLabel: dropout.fromSectionLabel,
+ leftoverIds: dropout.leftoverIds,
+ remainingIds: dropout.remainingIds,
+ originalSitOutIds: dropout.originalSitOutIds,
+ lastRoleId: dropout.lastRoleId,
+ dropoutIds: dropout.dropoutIds,
+ remainingDropoutIds: remainingDropouts.map((node) => node!.roleId),
+ returningDropoutIds: returningDropouts.map((node) => node!.roleId)
+ };
+ pendingDropout = null;
+ continue;
+ }
+
+ if (returningDropouts.length > 0 && remainingDropouts.length === 0) {
+ pendingDropout = null;
+ if (sittingOut.length === 0) {
+ reducedFrom = null;
+ sittingOutIds = null;
+ } else {
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ }
+ }
+ continue;
+ }
+
+ if (pendingLastReturn) {
+ if (sittingOut.length === 0) {
+ continue;
+ }
+
+ if (sittingOut.length < 2) {
+ pendingLastReturn = null;
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ continue;
+ }
+
+ if (activeRole) {
+ const selectedDropout = sittingOut.find((node) => node.roleId === activeRole);
+ if (
+ !selectedDropout &&
+ !pendingLastReturn.originalSitOutIds.includes(activeRole) &&
+ !pendingLastReturn.leftoverIds.includes(activeRole) &&
+ !pendingLastReturn.remainingIds.includes(activeRole) &&
+ pendingLastReturn.lastRoleId !== activeRole
+ ) {
+ continue;
+ }
+ }
+
+ pendingDropout = {
+ leftoverSectionLabel: pendingLastReturn.leftoverSectionLabel,
+ remainingSectionLabel: pendingLastReturn.remainingSectionLabel,
+ lastReturnSectionLabel: pendingLastReturn.lastReturnSectionLabel,
+ dropoutSectionLabel: sectionLabel,
+ fromSectionLabel: pendingLastReturn.fromSectionLabel,
+ leftoverIds: pendingLastReturn.leftoverIds,
+ remainingIds: pendingLastReturn.remainingIds,
+ originalSitOutIds: pendingLastReturn.originalSitOutIds,
+ lastRoleId: pendingLastReturn.lastRoleId,
+ dropoutIds: sittingOut.map((node) => node.roleId)
+ };
+ pendingLastReturn = null;
+ continue;
+ }
+
+ if (pendingRemaining) {
+ const originalSitOutIds = new Set(pendingRemaining.originalSitOutIds);
+ if (sittingOut.some((node) => !originalSitOutIds.has(node.roleId))) {
+ pendingRemaining = null;
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ continue;
+ }
+
+ const remainingNodes: NamedGraphNode[] = [];
+ for (const remainingId of pendingRemaining.remainingIds) {
+ const remainingNode = nodes.find((node) => node.roleId === remainingId);
+ if (!remainingNode) {
+ return null;
+ }
+ remainingNodes.push(remainingNode);
+ }
+
+ const returningLast = remainingNodes.filter((node) => node.active);
+ const stillRemaining = remainingNodes.filter((node) => node.active === false);
+
+ if (returningLast.length > 0 && stillRemaining.length === 0) {
+ let last = returningLast[0]!;
+ if (activeRole) {
+ const activeRoleNode = nodes.find((node) => node.roleId === activeRole);
+ if (!activeRoleNode) {
+ return null;
+ }
+ const selectedLast = returningLast.find((node) => node.roleId === activeRole);
+ if (selectedLast) {
+ last = selectedLast;
+ }
+ }
+ pendingLastReturn = {
+ leftoverSectionLabel: pendingRemaining.leftoverSectionLabel,
+ remainingSectionLabel: pendingRemaining.remainingSectionLabel,
+ lastReturnSectionLabel: sectionLabel,
+ fromSectionLabel: pendingRemaining.fromSectionLabel,
+ leftoverIds: pendingRemaining.leftoverIds,
+ remainingIds: pendingRemaining.remainingIds,
+ originalSitOutIds: pendingRemaining.originalSitOutIds,
+ lastRoleId: last.roleId
+ };
+ pendingRemaining = null;
+ continue;
+ }
+
+ if (returningLast.length > 0 && stillRemaining.length > 0) {
+ pendingRemaining = {
+ leftoverSectionLabel: pendingRemaining.leftoverSectionLabel,
+ remainingSectionLabel: sectionLabel,
+ fromSectionLabel: pendingRemaining.fromSectionLabel,
+ leftoverIds: pendingRemaining.leftoverIds,
+ remainingIds: stillRemaining.map((node) => node.roleId),
+ originalSitOutIds: pendingRemaining.originalSitOutIds
+ };
+ }
+ continue;
+ }
+
+ if (pendingSitOut) {
+ const leftoverNodes = pendingSitOut.leftoverIds.map((leftoverId) =>
+ nodes.find((node) => node.roleId === leftoverId)
+ );
+ if (leftoverNodes.some((node) => !node)) {
+ return null;
+ }
+
+ const returningLeftovers = leftoverNodes.filter((node) => node!.active);
+ const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false);
+
+ if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) {
+ pendingRemaining = {
+ leftoverSectionLabel: pendingSitOut.leftoverSectionLabel,
+ remainingSectionLabel: sectionLabel,
+ fromSectionLabel: pendingSitOut.fromSectionLabel,
+ leftoverIds: pendingSitOut.leftoverIds,
+ remainingIds: remainingLeftovers.map((node) => node!.roleId),
+ originalSitOutIds: pendingSitOut.originalSitOutIds
+ };
+ pendingSitOut = null;
+ continue;
+ }
+
+ if (returningLeftovers.length > 0 && remainingLeftovers.length === 0) {
+ pendingSitOut = null;
+ if (sittingOut.length === 0) {
+ reducedFrom = null;
+ sittingOutIds = null;
+ } else {
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ }
+ }
+ continue;
+ }
+
+ if (!sittingOutIds || !reducedFrom) {
+ if (sittingOut.length === 0) {
+ continue;
+ }
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ continue;
+ }
+
+ const baselineIds = sittingOutIds;
+ const returning = nodes.filter(
+ (node) => node.active === true && baselineIds.has(node.roleId)
+ );
+ const leftovers = sittingOut.filter((node) => baselineIds.has(node.roleId));
+
+ if (returning.length > 0 && leftovers.length > 0) {
+ pendingSitOut = {
+ leftoverSectionLabel: sectionLabel,
+ fromSectionLabel: reducedFrom,
+ leftoverIds: leftovers.map((node) => node.roleId),
+ originalSitOutIds: [...baselineIds]
+ };
+ continue;
+ }
+
+ if (returning.length === baselineIds.size && leftovers.length === 0) {
+ if (sittingOut.length === 0) {
+ reducedFrom = null;
+ sittingOutIds = null;
+ } else {
+ reducedFrom = sectionLabel;
+ sittingOutIds = new Set(sittingOut.map((node) => node.roleId));
+ }
+ }
+ }
+
+ return null;
+}
\ 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..632c37f9c 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,10 @@
"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.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "Tonight's first leftover last-dropout remaining last-return tutti",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Count the leftover last-dropout remaining last-return tutti in from the top of {sectionLabel}.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Come in together from the top of {sectionLabel}.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "This cue needs a last-return section where someone is still out, followed by a later section where everyone is in. Before rehearsing, confirm that order in the map.",
"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..fdafd37d9 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,10 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "오늘 먼저 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 후 투티",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 투티를 넣으세요.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 같이 들어오세요.",
+ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "이 큐를 잡으려면 마지막 복귀 시점에도 한 파트가 쉬고 있어야 하고, 그 뒤 구간에서 전원이 들어와야 합니다. 합주 전에 지도에서 이 순서를 확인하세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..d8fc6a194 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 leftover last-dropout remaining last-return tutti 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-leftover-last-dropout-remaining-last-return-tutti.md b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md
new file mode 100644
index 000000000..4d37f889b
--- /dev/null
+++ b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md
@@ -0,0 +1,18 @@
+# Tonight's first leftover last-dropout remaining last-return tutti
+
+The ready rehearsal map names the first leftover last-dropout remaining last-return tutti from existing `partGraph` evidence: a named leftover sit-out, then a later named leftover return where at least one leftover named part is own-property active and at least one leftover remains own-property tacet, then a later named leftover last-return where every remaining leftover is own-property active, then a later named leftover last-dropout where at least two named parts are own-property tacet, then a later named leftover last-dropout remaining where at least one leftover last-dropout named part is own-property active and at least one leftover last-dropout remains own-property tacet, then a later named leftover last-dropout remaining last-return where every leftover last-dropout remaining named part is own-property active and at least one named part remains own-property tacet, then a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. This is when the band is all in after leftover last-dropout remaining last-return. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover at leftover return, leftover last-return, leftover last-dropout, leftover last-dropout remaining, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout return with nobody still out, tutti, handoff, Fine, last-line breath, a singleton leftover last-dropout, leftover last-dropout remaining last-return without a later tutti, a newly inactive role outside the original reduction cohort while a remaining return is being tracked, or a new MIR product.
+
+## Next action
+
+- Named leftover last-dropout remaining last-return tutti: come in together from the top of the named leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return.
+- Named returning or other included part: count the leftover last-dropout remaining last-return tutti in from the top of that tutti.
+- Missing leftover last-dropout remaining last-return tutti: confirm a last-return section where someone is still out, then confirm a later section where everyone is in before rehearsal.
+
+## Security Notes
+
+- Untrusted input: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names 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 an own-property leftover sit-out, leftover return with remaining leftover, leftover last-return, leftover last-dropout cohort of at least two named parts, leftover last-dropout remaining, leftover last-dropout remaining last-return that is not already all-in, and a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active.
+- Allowlist: section labels and role names must be meaningful text. A missing graph node is not leftover last-dropout remaining last-return tutti. Inherited `is_active` is isolated. Leftover last-dropout remaining last-return that is already all-in is leftover last-dropout remaining last-return, not leftover last-dropout remaining last-return tutti. Leftover last-dropout remaining is leftover last-dropout remaining, not leftover last-dropout remaining last-return tutti. A leftover last-dropout return with nobody still out is leftover last-dropout return, not leftover last-dropout remaining last-return tutti. A leftover last-return is leftover last-return, not leftover last-dropout remaining last-return tutti. Remaining leftover at leftover return is remaining leftover, not leftover last-dropout remaining last-return tutti. A singleton leftover last-dropout cannot leave leftover last-dropout remaining last-return tutti. While a remaining return is being tracked, newly inactive roles outside the original reduction cohort invalidate and reseed the candidate. A role from the original reduction cohort may become inactive again and later return; that transition alone does not invalidate the candidate. All-active later sections after leftover last-return are tuttis, not leftover last-dropout remaining last-return tutti, until leftover last-dropout remaining last-return exists and later tuttis. When a role is selected, only leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return that includes that named part, leftover at leftover last-dropout remaining last-return of that named part, or leftover last-dropout remaining last-return tutti of that named part, is shown.
+- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without leftover return, leftover last-dropouts without leftover last-dropout remaining, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns with nobody still out, singleton leftover last-dropouts, come-ins without a leftover, full-band returns, remaining leftovers without last-return, newly inactive roles outside the original reduction cohort while a remaining return is being tracked, unnamed roles, empty graphs, and malformed roots return `null` or cause the current candidate to be discarded and search to continue, so the workspace does not invent a leftover last-dropout remaining last-return tutti.
+- Logging/privacy: rejected or accepted leftover last-dropout remaining last-return tutti cues are not logged. Copy interpolation keeps rehearsal values literal.
+- Tests: `firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts`, selected-role search, and the Workspace callout cover the demo all-active case, an explicit bass leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return, selected-role scoping, inherited flags, missing `is_active`, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns, singleton leftover last-dropouts, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, an original-cohort selected role becoming inactive again and later returning, a new outside-cohort dropout invalidating the candidate, unnamed roles, empty graphs, and literal copy filling.