diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..776f3ae54 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 (including tonight's first feel and section-length changes with their next count-ins), stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence (including tonight's first confidence change and the next ear check), 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..e63947ed3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -81,10 +81,10 @@ 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
- - groove and timing cues relevant to locking the band together
+ - groove and timing cues relevant to locking the band together, with the ready workspace naming tonight's first feel change and first section-length change plus their next count-ins
- 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
- - role-specific rehearsal priorities and confidence flags
+ - role-specific rehearsal priorities and confidence flags, with the ready workspace naming tonight's first confidence change and the next ear check
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
## Confidence, edits, and provenance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..c5a80f0a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@
### 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 groove change on the ready rehearsal map and tell the player to count the new feel in before that section.
+- Name tonight's first section-length change on the ready rehearsal map and tell the player to count the new length in before that section.
+- Name tonight's first confidence change on the ready rehearsal map and tell the player to confirm that section by ear before counting in.
- 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..73e00e973 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, plus tonight's first groove and section-length changes with their next count-ins and tonight's first confidence change with the next ear check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
index 5b32019d2..da039d0c8 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
@@ -34,6 +34,47 @@ describe("SectionRoadmap", () => {
expect(screen.getAllByText("음역").length).toBeGreaterThan(0);
expect(screen.getByText("C#2 — E3")).toBeTruthy();
expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0);
+ expect(screen.getByText("verse 들어가기 전에 이 그루브를 세어 보세요.")).toBeTruthy();
+ });
+
+ it("names the next count-in on the section where the feel changes", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ verse,
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ groove: "Half-time snare with open hats",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
+ }
+ ];
+
+ render();
+
+ expect(screen.queryByTestId("groove-next-action-verse-1")).toBeNull();
+ expect(screen.getByTestId("groove-next-action-chorus-1")).toHaveTextContent(
+ "Count this new groove in before chorus."
+ );
+ });
+
+ it("marks only the destination card when consecutive sections share a label", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
+ { ...verse, id: "verse-b", label: "verse", groove: "Half-time" }
+ ];
+
+ render();
+
+ expect(screen.queryByTestId("groove-next-action-verse-a")).toBeNull();
+ expect(screen.getByTestId("groove-next-action-verse-b")).toHaveTextContent(
+ "Count this new groove in before verse."
+ );
});
it("omits the range row when both notes are unnamed", () => {
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 834d1e8f0..df9ec95a8 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -3,6 +3,9 @@ import { useId, useMemo } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
+import { fillGrooveCopy, firstGrooveChange, isGrooveChangeTarget } from "./firstGrooveChange";
+import { fillDurationCopy, firstDurationChange, isDurationChangeTarget } from "./firstDurationChange";
+import { confidenceWordKey, fillConfidenceCopy, firstConfidenceChange, isConfidenceChangeTarget } from "./firstConfidenceChange";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -19,6 +22,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
+ const grooveChange = useMemo(() => firstGrooveChange(song), [song]);
+ const durationChange = useMemo(() => firstDurationChange(song), [song]);
+ const confidenceChange = useMemo(() => firstConfidenceChange(song), [song]);
/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
@@ -104,7 +110,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
- {song.sections.map((section) => (
+ {song.sections.map((section) => {
+ const hasGrooveAction = Boolean(grooveChange && isGrooveChangeTarget(grooveChange, section.id));
+ const hasDurationAction = Boolean(durationChange && isDurationChangeTarget(durationChange, section.id));
+ const hasConfidenceAction = Boolean(confidenceChange && isConfidenceChangeTarget(confidenceChange, section.id));
+ const hasNextAction = hasGrooveAction || hasDurationAction || hasConfidenceAction;
+
+ return (
{t("sectionGrooveLabel")}
{section.groove}
+ {hasNextAction ? (
+
+ {grooveChange && hasGrooveAction ? (
+
+ {fillGrooveCopy(
+ t(grooveChange.kind === "change" ? "sectionGrooveNextActionChange" : "sectionGrooveNextActionHold"),
+ { sectionLabel: section.label }
+ )}
+
+ ) : null}
+ {durationChange && hasDurationAction ? (
+
+ {fillDurationCopy(
+ t(durationChange.kind === "change" ? "sectionDurationNextActionChange" : "sectionDurationNextActionHold"),
+ { sectionLabel: section.label }
+ )}
+
+ ) : null}
+ {confidenceChange && hasConfidenceAction ? (
+
+ {fillConfidenceCopy(
+ t(confidenceChange.kind === "change" ? "sectionConfidenceNextActionChange" : "sectionConfidenceNextActionHold"),
+ {
+ sectionLabel: section.label,
+ level: t(confidenceWordKey(confidenceChange.toLevel))
+ }
+ )}
+
+ ) : null}
+
+ ) : null}
@@ -228,7 +271,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
})}
- ))}
+ );
+ })}
);
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..ce9451d1e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -60,7 +60,8 @@ describe("Workspace", () => {
expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60);
});
- it("keeps the song-structure grid valid when a project has no sections", () => {
+ it("keeps the song-structure grid and missing guidance truthful when a project has no sections", () => {
+ setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
@@ -70,6 +71,15 @@ describe("Workspace", () => {
expect(grid.style.gridTemplateColumns).not.toContain("repeat(0");
expect(grid.style.gridTemplateColumns).toContain("repeat(1");
+ expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
+ "Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal."
+ );
+ expect(screen.getByTestId("first-duration-change")).toHaveTextContent(
+ "Section timing isn't confirmed yet. Analyze section timing before using a count-in."
+ );
+ expect(screen.getByTestId("first-confidence-change")).toHaveTextContent(
+ "Section confidence isn't confirmed yet. Analyze section confidence before rehearsal."
+ );
});
it("falls back to safe timeline text for malformed section times", () => {
@@ -153,6 +163,41 @@ describe("Workspace", () => {
);
});
+ it("names tonight's held feel and the next count-in when the form does not change groove", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ const callout = screen.getByTestId("first-groove-change");
+ expect(callout).toHaveTextContent("Tonight's first feel change");
+ expect(callout).toHaveTextContent(
+ "Tonight's feel stays Straight eighths with a late snare feel through the form. Count that groove in before the verse."
+ );
+ });
+
+ it("names tonight's first feel change and the next count-in", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ verse,
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ groove: "Half-time snare with open hats",
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
+ }
+ ];
+
+ render();
+
+ expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
+ "The feel changes at chorus: Half-time snare with open hats, after verse's Straight eighths with a late snare feel. Count the new groove in before the chorus."
+ );
+ });
+
it("asks for an ear check when the selected part has no named span", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -325,5 +370,18 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
+ expect(screen.getByText("오늘 먼저 바뀌는 그루브")).toBeTruthy();
+ });
+
+ it("asks for an ear check when no named groove exists", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0] = { ...song.sections[0]!, label: " ", groove: "none" };
+
+ render();
+
+ expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
+ "Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal."
+ );
});
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..ccee87688 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,9 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { fillGrooveCopy, firstGrooveChange } from "./firstGrooveChange";
+import { fillDurationCopy, firstDurationChange } from "./firstDurationChange";
+import { confidenceWordKey, fillConfidenceCopy, firstConfidenceChange } from "./firstConfidenceChange";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +166,48 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const firstGroove = useMemo(() => firstGrooveChange(song), [song]);
+ const firstGrooveCopy = firstGroove
+ ? firstGroove.kind === "change"
+ ? fillGrooveCopy(t("workspaceFirstGrooveChange"), {
+ fromSection: firstGroove.fromSectionLabel,
+ fromGroove: firstGroove.fromGroove,
+ toSection: firstGroove.toSectionLabel,
+ toGroove: firstGroove.toGroove
+ })
+ : fillGrooveCopy(t("workspaceFirstGrooveHold"), {
+ sectionLabel: firstGroove.toSectionLabel,
+ groove: firstGroove.toGroove
+ })
+ : t("workspaceFirstGrooveMissing");
+ const firstDuration = useMemo(() => firstDurationChange(song), [song]);
+ const firstDurationCopy = firstDuration
+ ? firstDuration.kind === "change"
+ ? fillDurationCopy(t("workspaceFirstDurationChange"), {
+ fromSection: firstDuration.fromSectionLabel,
+ fromDuration: firstDuration.fromDuration,
+ toSection: firstDuration.toSectionLabel,
+ toDuration: firstDuration.toDuration
+ })
+ : fillDurationCopy(t("workspaceFirstDurationHold"), {
+ sectionLabel: firstDuration.toSectionLabel,
+ duration: firstDuration.toDuration
+ })
+ : t("workspaceFirstDurationMissing");
+ const firstConfidence = useMemo(() => firstConfidenceChange(song), [song]);
+ const firstConfidenceCopy = firstConfidence
+ ? firstConfidence.kind === "change"
+ ? fillConfidenceCopy(t("workspaceFirstConfidenceChange"), {
+ fromSection: firstConfidence.fromSectionLabel,
+ fromLevel: t(confidenceWordKey(firstConfidence.fromLevel)),
+ toSection: firstConfidence.toSectionLabel,
+ toLevel: t(confidenceWordKey(firstConfidence.toLevel))
+ })
+ : fillConfidenceCopy(t("workspaceFirstConfidenceHold"), {
+ sectionLabel: firstConfidence.toSectionLabel,
+ level: t(confidenceWordKey(firstConfidence.toLevel))
+ })
+ : t("workspaceFirstConfidenceMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -310,6 +355,27 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{firstRangeCopy}
+
+ {t("workspaceFirstChangesTitle")}
+
+
+
{t("workspaceFirstGrooveTitle")}
+
{firstGrooveCopy}
+
+
+
{t("workspaceFirstDurationTitle")}
+
{firstDurationCopy}
+
+
+
{t("workspaceFirstConfidenceTitle")}
+
{firstConfidenceCopy}
+
+
+
+
{t("workspaceSongTimelineLabel")}
diff --git a/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx b/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx
new file mode 100644
index 000000000..b5a1029be
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx
@@ -0,0 +1,141 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it } from "vitest";
+import { SectionRoadmap } from "./SectionRoadmap";
+import { Workspace } from "./Workspace";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string): void {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language
+ });
+}
+
+describe("canonical first-change rehearsal guidance", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ });
+
+ it("keeps groove, duration, and confidence in one buyer-visible change surface", () => {
+ setNavigatorLanguage("en-US");
+ render();
+
+ const changeSurface = screen.getByRole("region", { name: "Tonight's first changes" });
+ expect(changeSurface).toContainElement(screen.getByTestId("first-groove-change"));
+ expect(changeSurface).toContainElement(screen.getByTestId("first-duration-change"));
+ expect(changeSurface).toContainElement(screen.getByTestId("first-confidence-change"));
+ expect(screen.getByTestId("first-duration-change")).toHaveTextContent(
+ "Tonight's section length stays 20 seconds through the form. Count that length in before the verse."
+ );
+ expect(screen.getByTestId("first-confidence-change")).toHaveTextContent(
+ "Tonight's confidence stays medium through the form. Confirm the verse by ear before you count in."
+ );
+ });
+
+ it("names the first duration and confidence changes without replacing the groove decision", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ verse,
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ groove: "Half-time snare with open hats",
+ timeRange: { start: 30, end: 62 },
+ confidence: { ...verse.confidence, level: "low" },
+ roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
+ }
+ ];
+
+ render();
+
+ expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
+ "The feel changes at chorus: Half-time snare with open hats"
+ );
+ expect(screen.getByTestId("first-duration-change")).toHaveTextContent(
+ "The section length changes at chorus: 32 seconds, after verse's 20 seconds. Count the new length in before the chorus."
+ );
+ expect(screen.getByTestId("first-confidence-change")).toHaveTextContent(
+ "Confidence changes at chorus: low, after verse's medium. Confirm the chorus by ear before you count in."
+ );
+ });
+
+ it("fails closed with truthful analysis guidance when duration and confidence evidence are unusable", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0] = {
+ ...song.sections[0]!,
+ label: " ",
+ timeRange: { start: 10, end: 10 },
+ confidence: { ...song.sections[0]!.confidence, level: "ready" as "low" }
+ };
+
+ render();
+
+ expect(screen.getByTestId("first-duration-change")).toHaveTextContent(
+ "Section timing isn't confirmed yet. Analyze section timing before using a count-in."
+ );
+ expect(screen.getByTestId("first-confidence-change")).toHaveTextContent(
+ "Section confidence isn't confirmed yet. Analyze section confidence before rehearsal."
+ );
+ });
+
+ it("keeps the consolidated change surface and child labels equivalent in Korean", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+
+ expect(screen.getByRole("region", { name: "오늘 먼저 확인할 변화" })).toBeTruthy();
+ expect(screen.getByText("오늘 먼저 바뀌는 그루브")).toBeTruthy();
+ expect(screen.getByText("오늘 먼저 바뀌는 구간 길이")).toBeTruthy();
+ expect(screen.getByText("오늘 먼저 바뀌는 확신")).toBeTruthy();
+ });
+
+ it("puts duration and confidence next actions only on the stable destination section", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ {
+ ...verse,
+ id: "verse-a",
+ label: "verse",
+ timeRange: { start: 0, end: 16 },
+ confidence: { ...verse.confidence, level: "medium" }
+ },
+ {
+ ...verse,
+ id: "verse-b",
+ label: "verse",
+ timeRange: { start: 16, end: 48 },
+ confidence: { ...verse.confidence, level: "low" }
+ }
+ ];
+
+ render();
+
+ expect(screen.queryByTestId("duration-next-action-verse-a")).toBeNull();
+ expect(screen.getByTestId("duration-next-action-verse-b")).toHaveTextContent(
+ "Count this new length in before verse."
+ );
+ expect(screen.queryByTestId("confidence-next-action-verse-a")).toBeNull();
+ expect(screen.getByTestId("confidence-next-action-verse-b")).toHaveTextContent(
+ "Confirm this section by ear before verse."
+ );
+ });
+
+ it("keeps hold actions on the first stable section for duration and confidence", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+
+ expect(screen.getByTestId("duration-next-action-verse-1")).toHaveTextContent(
+ "verse 들어가기 전에 이 길이를 세어 보세요."
+ );
+ expect(screen.getByTestId("confidence-next-action-verse-1")).toHaveTextContent(
+ "verse 들어가기 전에 이 중간 읽기를 귀로 확인해 보세요."
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts b/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts
new file mode 100644
index 000000000..a61c44cea
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts
@@ -0,0 +1,207 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import {
+ confidenceWordKey,
+ fillConfidenceCopy,
+ firstConfidenceChange,
+ isConfidenceChangeTarget,
+ sectionConfidenceLevel
+} from "./firstConfidenceChange";
+
+function cloneSong(song: RehearsalSong): RehearsalSong {
+ return {
+ ...song,
+ sections: song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => ({ ...role }))
+ }))
+ };
+}
+
+function appendSection(
+ song: RehearsalSong,
+ label: string,
+ level: "low" | "medium" | "high"
+): RehearsalSong {
+ const first = song.sections[0]!;
+ return {
+ ...song,
+ sections: [
+ ...song.sections,
+ {
+ ...first,
+ id: `${label}-section`,
+ label,
+ confidence: {
+ ...first.confidence,
+ level
+ },
+ roles: first.roles.map((role) => ({ ...role, id: `${role.id}-${label}` }))
+ }
+ ]
+ };
+}
+
+describe("sectionConfidenceLevel", () => {
+ it("returns a named rehearsal confidence level", () => {
+ expect(sectionConfidenceLevel({ level: "low" })).toBe("low");
+ expect(sectionConfidenceLevel({ level: "medium" })).toBe("medium");
+ expect(sectionConfidenceLevel({ level: "high" })).toBe("high");
+ });
+
+ it("fails closed on missing, blank, or invented levels", () => {
+ expect(sectionConfidenceLevel(null)).toBeNull();
+ expect(sectionConfidenceLevel({ level: " " })).toBeNull();
+ expect(sectionConfidenceLevel({ level: "ready" })).toBeNull();
+ expect(sectionConfidenceLevel({ notes: "low" })).toBeNull();
+ });
+});
+
+describe("firstConfidenceChange", () => {
+ it("holds the demo song's one named confidence so the room does not invent a reset", () => {
+ expect(firstConfidenceChange(createDemoRehearsalSong())).toEqual({
+ kind: "same",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromLevel: "medium",
+ toSectionId: "verse-1",
+ toSectionLabel: "verse",
+ toLevel: "medium"
+ });
+ });
+
+ it("names the first consecutive confidence change in form order", () => {
+ const song = appendSection(
+ appendSection(createDemoRehearsalSong(), "pre-chorus", "medium"),
+ "chorus",
+ "low"
+ );
+
+ expect(firstConfidenceChange(song)).toEqual({
+ kind: "change",
+ fromSectionId: "pre-chorus-section",
+ fromSectionLabel: "pre-chorus",
+ fromLevel: "medium",
+ toSectionId: "chorus-section",
+ toSectionLabel: "chorus",
+ toLevel: "low"
+ });
+ });
+
+ it("skips unlabeled or invalid-confidence sections until a named pair exists", () => {
+ const song = cloneSong(createDemoRehearsalSong());
+ song.sections[0] = {
+ ...song.sections[0]!,
+ label: " ",
+ confidence: { ...song.sections[0]!.confidence, level: "ready" as "low" }
+ };
+ const withChorus = appendSection(song, "chorus", "high");
+ const withBridge = appendSection(withChorus, "bridge", "low");
+
+ expect(firstConfidenceChange(withBridge)).toEqual({
+ kind: "change",
+ fromSectionId: "chorus-section",
+ fromSectionLabel: "chorus",
+ fromLevel: "high",
+ toSectionId: "bridge-section",
+ toSectionLabel: "bridge",
+ toLevel: "low"
+ });
+ });
+
+ it("fails closed on malformed runtime roots and members", () => {
+ expect(firstConfidenceChange(null as unknown as RehearsalSong)).toBeNull();
+ expect(firstConfidenceChange({ sections: "nope" } as unknown as RehearsalSong)).toBeNull();
+ expect(
+ firstConfidenceChange({
+ ...createDemoRehearsalSong(),
+ sections: [null, "skip", { label: "verse" }]
+ } as unknown as RehearsalSong)
+ ).toBeNull();
+ });
+
+ it("fails closed when repeated section ids cannot identify one destination card", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", confidence: { ...verse.confidence, level: "medium" } },
+ { ...verse, id: "duplicate", confidence: { ...verse.confidence, level: "low" } }
+ ];
+
+ expect(firstConfidenceChange(song)).toBeNull();
+ });
+
+ it("rejects a repeated section id even when one occurrence has no eligible confidence evidence", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", label: " ", confidence: { ...verse.confidence, level: "ready" as "low" } },
+ { ...verse, id: "duplicate", label: "chorus", confidence: { ...verse.confidence, level: "low" } }
+ ];
+
+ expect(firstConfidenceChange(song)).toBeNull();
+ });
+
+ it("validates later section ids before returning an earlier confidence transition", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", confidence: { ...verse.confidence, level: "medium" } },
+ { ...verse, id: "chorus-a", label: "chorus", confidence: { ...verse.confidence, level: "low" } },
+ { ...verse, id: "verse-a", label: "outro", confidence: { ...verse.confidence, level: "high" } }
+ ];
+
+ expect(firstConfidenceChange(song)).toBeNull();
+ });
+});
+
+describe("isConfidenceChangeTarget", () => {
+ it("marks only the arrival section identity as the next-action card", () => {
+ const change: ReturnType = {
+ kind: "change",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromLevel: "medium",
+ toSectionId: "chorus-1",
+ toSectionLabel: "chorus",
+ toLevel: "low"
+ };
+
+ expect(isConfidenceChangeTarget(change, "chorus-1")).toBe(true);
+ expect(isConfidenceChangeTarget(change, " verse-1 ")).toBe(false);
+ expect(isConfidenceChangeTarget(change, " ")).toBe(false);
+ });
+
+ it("uses stable section identity when repeated labels would otherwise mark multiple cards", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", confidence: { ...verse.confidence, level: "medium" } },
+ { ...verse, id: "verse-b", label: "verse", confidence: { ...verse.confidence, level: "low" } }
+ ];
+
+ const change = firstConfidenceChange(song);
+ expect(change?.kind).toBe("change");
+ expect(isConfidenceChangeTarget(change!, "verse-a")).toBe(false);
+ expect(isConfidenceChangeTarget(change!, "verse-b")).toBe(true);
+ });
+});
+
+describe("fillConfidenceCopy", () => {
+ it("keeps rehearsal confidence values literal while filling trusted tokens", () => {
+ expect(
+ fillConfidenceCopy("Confirm {toLevel} before the {toSection}.", {
+ toLevel: "low {toSection}",
+ toSection: "chorus"
+ })
+ ).toBe("Confirm low {toSection} before the chorus.");
+ });
+});
+
+describe("confidenceWordKey", () => {
+ it("maps each named level onto its i18n word key", () => {
+ expect(confidenceWordKey("low")).toBe("confidenceWordLow");
+ expect(confidenceWordKey("medium")).toBe("confidenceWordMedium");
+ expect(confidenceWordKey("high")).toBe("confidenceWordHigh");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstConfidenceChange.ts b/apps/desktop/src/features/workspace/firstConfidenceChange.ts
new file mode 100644
index 000000000..4b1b7a8a0
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstConfidenceChange.ts
@@ -0,0 +1,144 @@
+import type { ConfidenceLevel, RehearsalSong } from "@bandscope/shared-types";
+import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named section-confidence change, or a same-level hold through the form. */
+export type FirstConfidenceChange = {
+ kind: "change" | "same";
+ fromSectionId: string;
+ fromSectionLabel: string;
+ fromLevel: ConfidenceLevel;
+ toSectionId: string;
+ toSectionLabel: string;
+ toLevel: ConfidenceLevel;
+};
+
+type NamedConfidence = {
+ sectionId: string;
+ sectionLabel: string;
+ level: ConfidenceLevel;
+};
+
+/** 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);
+}
+
+/** Return a named rehearsal confidence level or fail closed. */
+export function sectionConfidenceLevel(confidenceValue: unknown): ConfidenceLevel | null {
+ if (!isRuntimeObject(confidenceValue)) {
+ return null;
+ }
+ const level = confidenceValue.level;
+ if (level === "low" || level === "medium" || level === "high") {
+ return level;
+ }
+ return null;
+}
+
+/**
+ * Pick the first named section-confidence change a player should confirm by ear.
+ *
+ * Walks labeled sections in form order and returns the first consecutive pair
+ * whose confidence level differs. When every named section holds the same
+ * level, the result is a same-level hold so the room does not invent a reset.
+ * Stable section ids, rather than display labels, own roadmap targeting.
+ * All meaningful section ids are validated for uniqueness before confidence
+ * evidence is derived so an ineligible or later duplicate cannot create
+ * ambiguous cards.
+ */
+export function firstConfidenceChange(song: RehearsalSong): FirstConfidenceChange | null {
+ const runtimeSong: unknown = song;
+ if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
+ return null;
+ }
+
+ const seenSectionIds = new Set();
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ continue;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ if (!sectionId) {
+ continue;
+ }
+ if (seenSectionIds.has(sectionId)) {
+ return null;
+ }
+ seenSectionIds.add(sectionId);
+ }
+
+ const namedLevels: NamedConfidence[] = [];
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ continue;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ const level = sectionConfidenceLevel(sectionValue.confidence);
+ if (!sectionId || !sectionLabel || level === null) {
+ continue;
+ }
+ namedLevels.push({
+ sectionId,
+ sectionLabel,
+ level
+ });
+ }
+
+ if (namedLevels.length === 0) {
+ return null;
+ }
+
+ const first = namedLevels[0];
+ if (!first) {
+ return null;
+ }
+ for (let index = 1; index < namedLevels.length; index += 1) {
+ const previous = namedLevels[index - 1];
+ const current = namedLevels[index];
+ if (previous.level !== current.level) {
+ return {
+ kind: "change",
+ fromSectionId: previous.sectionId,
+ fromSectionLabel: previous.sectionLabel,
+ fromLevel: previous.level,
+ toSectionId: current.sectionId,
+ toSectionLabel: current.sectionLabel,
+ toLevel: current.level
+ };
+ }
+ }
+
+ return {
+ kind: "same",
+ fromSectionId: first.sectionId,
+ fromSectionLabel: first.sectionLabel,
+ fromLevel: first.level,
+ toSectionId: first.sectionId,
+ toSectionLabel: first.sectionLabel,
+ toLevel: first.level
+ };
+}
+
+/** Fill trusted `{token}` placeholders for confidence-change rehearsal copy. */
+export function fillConfidenceCopy(template: string, values: Record): string {
+ return fillRangeCopy(template, values);
+}
+
+/** True when this stable section identity is the map card owning the next confidence action. */
+export function isConfidenceChangeTarget(change: FirstConfidenceChange, sectionId: string): boolean {
+ const identity = meaningfulRangeText(sectionId);
+ return Boolean(identity) && identity === change.toSectionId;
+}
+
+/** Map a validated confidence level onto the matching i18n word key. */
+export function confidenceWordKey(level: ConfidenceLevel): "confidenceWordLow" | "confidenceWordMedium" | "confidenceWordHigh" {
+ if (level === "low") {
+ return "confidenceWordLow";
+ }
+ if (level === "high") {
+ return "confidenceWordHigh";
+ }
+ return "confidenceWordMedium";
+}
+
diff --git a/apps/desktop/src/features/workspace/firstDurationChange.test.ts b/apps/desktop/src/features/workspace/firstDurationChange.test.ts
new file mode 100644
index 000000000..35bd89a8f
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDurationChange.test.ts
@@ -0,0 +1,240 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { parseRehearsalSongWithTimingEvidence } from "../../lib/rehearsalTimingEvidence";
+import {
+ fillDurationCopy,
+ firstDurationChange,
+ isDurationChangeTarget,
+ sectionDurationSeconds
+} from "./firstDurationChange";
+
+function cloneSong(song: RehearsalSong): RehearsalSong {
+ return {
+ ...song,
+ sections: song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => ({ ...role }))
+ }))
+ };
+}
+
+function appendSection(
+ song: RehearsalSong,
+ label: string,
+ timeRange: { start: number; end: number }
+): RehearsalSong {
+ const first = song.sections[0];
+ return {
+ ...song,
+ sections: [
+ ...song.sections,
+ {
+ ...first,
+ id: `${label}-section`,
+ label,
+ timeRange,
+ roles: first.roles.map((role) => ({ ...role, id: `${role.id}-${label}` }))
+ }
+ ]
+ };
+}
+
+describe("sectionDurationSeconds", () => {
+ it("returns the positive integer span for a valid time range", () => {
+ expect(sectionDurationSeconds({ start: 10, end: 30 })).toBe(20);
+ expect(sectionDurationSeconds({ start: 0, end: 1 })).toBe(1);
+ });
+
+ it("fails closed on missing, inverted, fractional, or unbounded ranges", () => {
+ expect(sectionDurationSeconds(null)).toBeNull();
+ expect(sectionDurationSeconds({ start: 30, end: 10 })).toBeNull();
+ expect(sectionDurationSeconds({ start: 10, end: 10 })).toBeNull();
+ expect(sectionDurationSeconds({ start: 10.5, end: 20 })).toBeNull();
+ expect(sectionDurationSeconds({ start: -1, end: 10 })).toBeNull();
+ expect(sectionDurationSeconds({ start: Number.NaN, end: 10 })).toBeNull();
+ });
+});
+
+describe("firstDurationChange", () => {
+ it("holds the demo song's one named length so the room does not reset the count", () => {
+ expect(firstDurationChange(createDemoRehearsalSong())).toEqual({
+ kind: "same",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromDuration: "20",
+ toSectionId: "verse-1",
+ toSectionLabel: "verse",
+ toDuration: "20"
+ });
+ });
+
+ it("names the first consecutive length change in form order", () => {
+ const song = appendSection(
+ appendSection(createDemoRehearsalSong(), "pre-chorus", { start: 30, end: 50 }),
+ "chorus",
+ { start: 50, end: 82 }
+ );
+
+ expect(firstDurationChange(song)).toEqual({
+ kind: "change",
+ fromSectionId: "pre-chorus-section",
+ fromSectionLabel: "pre-chorus",
+ fromDuration: "20",
+ toSectionId: "chorus-section",
+ toSectionLabel: "chorus",
+ toDuration: "32"
+ });
+ });
+
+ it("skips unlabeled or invalid-duration sections until a named pair exists", () => {
+ const song = cloneSong(createDemoRehearsalSong());
+ song.sections[0] = {
+ ...song.sections[0]!,
+ label: " ",
+ timeRange: { start: 10, end: 10 }
+ };
+ const withChorus = appendSection(song, "chorus", { start: 30, end: 46 });
+ const withBridge = appendSection(withChorus, "bridge", { start: 46, end: 78 });
+
+ expect(firstDurationChange(withBridge)).toEqual({
+ kind: "change",
+ fromSectionId: "chorus-section",
+ fromSectionLabel: "chorus",
+ fromDuration: "16",
+ toSectionId: "bridge-section",
+ toSectionLabel: "bridge",
+ toDuration: "32"
+ });
+ });
+
+ it("fails closed instead of bridging across a named section with missing timing evidence", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } },
+ {
+ ...verse,
+ id: "chorus-gap",
+ label: "chorus",
+ timeRange: null
+ } as unknown as typeof verse,
+ { ...verse, id: "bridge-a", label: "bridge", timeRange: { start: 16, end: 48 } }
+ ];
+
+ expect(firstDurationChange(song)).toBeNull();
+ });
+
+ it("does not present legacy migration placeholders as measured section lengths", () => {
+ const legacySong = structuredClone(createDemoRehearsalSong()) as unknown as {
+ sections: Array>;
+ };
+ delete legacySong.sections[0]!.timeRange;
+
+ const parsedLegacySong = parseRehearsalSongWithTimingEvidence(legacySong);
+ expect(parsedLegacySong.sections[0]?.timeRange).toEqual({ start: 0, end: 1 });
+ expect(firstDurationChange(parsedLegacySong)).toBeNull();
+ });
+
+ it("keeps an explicit measured one-second section eligible for count-in guidance", () => {
+ const measuredSong = structuredClone(createDemoRehearsalSong());
+ measuredSong.sections[0]!.timeRange = { start: 0, end: 1 };
+
+ expect(firstDurationChange(parseRehearsalSongWithTimingEvidence(measuredSong))).toEqual({
+ kind: "same",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromDuration: "1",
+ toSectionId: "verse-1",
+ toSectionLabel: "verse",
+ toDuration: "1"
+ });
+ });
+
+ it("fails closed on malformed runtime roots and members", () => {
+ expect(firstDurationChange(null as unknown as RehearsalSong)).toBeNull();
+ expect(firstDurationChange({ sections: "nope" } as unknown as RehearsalSong)).toBeNull();
+ expect(
+ firstDurationChange({
+ ...createDemoRehearsalSong(),
+ sections: [null, "skip", { label: "verse" }]
+ } as unknown as RehearsalSong)
+ ).toBeNull();
+ });
+
+ it("fails closed when repeated section ids cannot identify one destination card", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", timeRange: { start: 0, end: 16 } },
+ { ...verse, id: "duplicate", timeRange: { start: 16, end: 48 } }
+ ];
+
+ expect(firstDurationChange(song)).toBeNull();
+ });
+
+ it("rejects a repeated section id even when one occurrence has no eligible duration evidence", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", label: " ", timeRange: { start: 10, end: 10 } },
+ { ...verse, id: "duplicate", label: "chorus", timeRange: { start: 30, end: 46 } }
+ ];
+
+ expect(firstDurationChange(song)).toBeNull();
+ });
+
+ it("validates later section ids before returning an earlier duration transition", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } },
+ { ...verse, id: "chorus-a", label: "chorus", timeRange: { start: 16, end: 48 } },
+ { ...verse, id: "verse-a", label: "outro", timeRange: { start: 48, end: 64 } }
+ ];
+
+ expect(firstDurationChange(song)).toBeNull();
+ });
+});
+
+describe("isDurationChangeTarget", () => {
+ it("marks only the arrival section identity as the next-action card", () => {
+ const change: ReturnType = {
+ kind: "change",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromDuration: "20",
+ toSectionId: "chorus-1",
+ toSectionLabel: "chorus",
+ toDuration: "32"
+ };
+
+ expect(isDurationChangeTarget(change, "chorus-1")).toBe(true);
+ expect(isDurationChangeTarget(change, " verse-1 ")).toBe(false);
+ expect(isDurationChangeTarget(change, " ")).toBe(false);
+ });
+
+ it("uses stable section identity when repeated labels would otherwise mark multiple cards", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } },
+ { ...verse, id: "verse-b", label: "verse", timeRange: { start: 16, end: 48 } }
+ ];
+
+ const change = firstDurationChange(song);
+ expect(change?.kind).toBe("change");
+ expect(isDurationChangeTarget(change!, "verse-a")).toBe(false);
+ expect(isDurationChangeTarget(change!, "verse-b")).toBe(true);
+ });
+});
+
+describe("fillDurationCopy", () => {
+ it("keeps rehearsal duration values literal while filling trusted tokens", () => {
+ expect(
+ fillDurationCopy("Count {toDuration} before the {toSection}.", {
+ toDuration: "32 {toSection}",
+ toSection: "chorus"
+ })
+ ).toBe("Count 32 {toSection} before the chorus.");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstDurationChange.ts b/apps/desktop/src/features/workspace/firstDurationChange.ts
new file mode 100644
index 000000000..f38edb5b1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDurationChange.ts
@@ -0,0 +1,159 @@
+import { MAX_SECTION_TIME_SECONDS, type RehearsalSong } from "@bandscope/shared-types";
+import { hasSyntheticSectionTimeRange } from "../../lib/rehearsalTimingEvidence";
+import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named section-length change, or a same-length hold through the form. */
+export type FirstDurationChange = {
+ kind: "change" | "same";
+ fromSectionId: string;
+ fromSectionLabel: string;
+ fromDuration: string;
+ toSectionId: string;
+ toSectionLabel: string;
+ toDuration: string;
+};
+
+type NamedDuration = {
+ sectionId: string;
+ sectionLabel: string;
+ duration: 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);
+}
+
+/**
+ * Return a positive integer duration in seconds from untrusted time-range evidence.
+ *
+ * Mirrors the shared rehearsal-song contract: start and end must be finite
+ * integers, start must be in range, and end must be strictly after start.
+ */
+export function sectionDurationSeconds(timeRangeValue: unknown): number | null {
+ if (!isRuntimeObject(timeRangeValue)) {
+ return null;
+ }
+ const start = timeRangeValue.start;
+ const end = timeRangeValue.end;
+ if (
+ typeof start !== "number" ||
+ typeof end !== "number" ||
+ !Number.isFinite(start) ||
+ !Number.isFinite(end) ||
+ !Number.isInteger(start) ||
+ !Number.isInteger(end) ||
+ start < 0 ||
+ start > MAX_SECTION_TIME_SECONDS ||
+ end <= start ||
+ end > MAX_SECTION_TIME_SECONDS
+ ) {
+ return null;
+ }
+ return end - start;
+}
+
+/**
+ * Pick the first named section-length change a player should count in before the next section.
+ *
+ * Walks labeled sections in form order and returns the first consecutive pair
+ * whose integer duration differs. Unlabeled compatibility noise is ignored,
+ * while a named section without valid timing fails closed so unknown timing can
+ * never bridge two measured sections. Legacy compatibility placeholders are
+ * also rejected so migration data cannot become measured rehearsal guidance.
+ * When every named section holds the same length, the result is a same-length
+ * hold so the room does not reset the count. Stable section ids, rather than
+ * display labels, own roadmap targeting. All meaningful section ids are
+ * validated for uniqueness before duration evidence is derived so an
+ * ineligible or later duplicate cannot create ambiguous cards.
+ */
+export function firstDurationChange(song: RehearsalSong): FirstDurationChange | null {
+ const runtimeSong: unknown = song;
+ if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
+ return null;
+ }
+
+ const seenSectionIds = new Set();
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ continue;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ if (!sectionId) {
+ continue;
+ }
+ if (seenSectionIds.has(sectionId)) {
+ return null;
+ }
+ seenSectionIds.add(sectionId);
+ }
+
+ const namedDurations: NamedDuration[] = [];
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ continue;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ if (!sectionId || !sectionLabel) {
+ continue;
+ }
+ if (hasSyntheticSectionTimeRange(sectionValue)) {
+ return null;
+ }
+ const durationSeconds = sectionDurationSeconds(sectionValue.timeRange);
+ if (durationSeconds === null) {
+ return null;
+ }
+ namedDurations.push({
+ sectionId,
+ sectionLabel,
+ duration: String(durationSeconds)
+ });
+ }
+
+ if (namedDurations.length === 0) {
+ return null;
+ }
+
+ const first = namedDurations[0];
+ if (!first) {
+ return null;
+ }
+ for (let index = 1; index < namedDurations.length; index += 1) {
+ const previous = namedDurations[index - 1];
+ const current = namedDurations[index];
+ if (previous.duration !== current.duration) {
+ return {
+ kind: "change",
+ fromSectionId: previous.sectionId,
+ fromSectionLabel: previous.sectionLabel,
+ fromDuration: previous.duration,
+ toSectionId: current.sectionId,
+ toSectionLabel: current.sectionLabel,
+ toDuration: current.duration
+ };
+ }
+ }
+
+ return {
+ kind: "same",
+ fromSectionId: first.sectionId,
+ fromSectionLabel: first.sectionLabel,
+ fromDuration: first.duration,
+ toSectionId: first.sectionId,
+ toSectionLabel: first.sectionLabel,
+ toDuration: first.duration
+ };
+}
+
+/** Fill trusted `{token}` placeholders for duration-change rehearsal copy. */
+export function fillDurationCopy(template: string, values: Record): string {
+ return fillRangeCopy(template, values);
+}
+
+/** True when this stable section identity is the map card owning the next duration action. */
+export function isDurationChangeTarget(change: FirstDurationChange, sectionId: string): boolean {
+ const identity = meaningfulRangeText(sectionId);
+ return Boolean(identity) && identity === change.toSectionId;
+}
diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts
new file mode 100644
index 000000000..6d0c1843f
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts
@@ -0,0 +1,179 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { fillGrooveCopy, firstGrooveChange, isGrooveChangeTarget } from "./firstGrooveChange";
+
+function cloneSong(song: RehearsalSong): RehearsalSong {
+ return {
+ ...song,
+ sections: song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => ({ ...role }))
+ }))
+ };
+}
+
+function appendSection(song: RehearsalSong, label: string, groove: string): RehearsalSong {
+ const first = song.sections[0];
+ return {
+ ...song,
+ sections: [
+ ...song.sections,
+ {
+ ...first,
+ id: `${label}-section`,
+ label,
+ groove,
+ roles: first.roles.map((role) => ({ ...role, id: `${role.id}-${label}` }))
+ }
+ ]
+ };
+}
+
+describe("firstGrooveChange", () => {
+ it("holds the demo song's one named feel so the room does not reset the groove", () => {
+ expect(firstGrooveChange(createDemoRehearsalSong())).toEqual({
+ kind: "same",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromGroove: "Straight eighths with a late snare feel",
+ toSectionId: "verse-1",
+ toSectionLabel: "verse",
+ toGroove: "Straight eighths with a late snare feel"
+ });
+ });
+
+ it("names the first consecutive feel change in form order", () => {
+ const song = appendSection(
+ appendSection(createDemoRehearsalSong(), "pre-chorus", "Straight eighths with a late snare feel"),
+ "chorus",
+ "Half-time snare with open hats"
+ );
+
+ expect(firstGrooveChange(song)).toEqual({
+ kind: "change",
+ fromSectionId: "pre-chorus-section",
+ fromSectionLabel: "pre-chorus",
+ fromGroove: "Straight eighths with a late snare feel",
+ toSectionId: "chorus-section",
+ toSectionLabel: "chorus",
+ toGroove: "Half-time snare with open hats"
+ });
+ });
+
+ it("fails closed instead of bridging across an unknown section groove", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
+ { ...verse, id: "pre-a", label: "pre-chorus", groove: "none" },
+ { ...verse, id: "chorus-a", label: "chorus", groove: "Half-time" }
+ ];
+
+ expect(firstGrooveChange(song)).toBeNull();
+ });
+
+ it("fails closed instead of claiming a full-form hold after a trailing unknown groove", () => {
+ const song = cloneSong(createDemoRehearsalSong());
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
+ { ...verse, id: "chorus-a", label: "chorus", groove: " " }
+ ];
+
+ expect(firstGrooveChange(song)).toBeNull();
+ });
+
+ it("trims groove text before deciding a change", () => {
+ const song = appendSection(createDemoRehearsalSong(), "chorus", " Straight eighths with a late snare feel ");
+
+ expect(firstGrooveChange(song)?.kind).toBe("same");
+ });
+
+ it("fails closed on malformed runtime roots and members", () => {
+ expect(firstGrooveChange(null as unknown as RehearsalSong)).toBeNull();
+ expect(firstGrooveChange({ sections: "nope" } as unknown as RehearsalSong)).toBeNull();
+ expect(
+ firstGrooveChange({
+ ...createDemoRehearsalSong(),
+ sections: [null, "skip", { label: "verse" }]
+ } as unknown as RehearsalSong)
+ ).toBeNull();
+ });
+
+ it("fails closed when repeated section ids cannot identify one destination card", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", groove: "Straight eighths" },
+ { ...verse, id: "duplicate", groove: "Half-time" }
+ ];
+
+ expect(firstGrooveChange(song)).toBeNull();
+ });
+
+ it("rejects a repeated section id even when one occurrence has no eligible groove evidence", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "duplicate", label: " ", groove: "none" },
+ { ...verse, id: "duplicate", label: "chorus", groove: "Half-time" }
+ ];
+
+ expect(firstGrooveChange(song)).toBeNull();
+ });
+
+ it("validates later section ids before returning an earlier groove transition", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
+ { ...verse, id: "chorus-a", label: "chorus", groove: "Half-time" },
+ { ...verse, id: "verse-a", label: "outro", groove: "Straight eighths" }
+ ];
+
+ expect(firstGrooveChange(song)).toBeNull();
+ });
+});
+
+describe("isGrooveChangeTarget", () => {
+ it("marks only the arrival section identity as the next-action card", () => {
+ const change: ReturnType = {
+ kind: "change",
+ fromSectionId: "verse-1",
+ fromSectionLabel: "verse",
+ fromGroove: "Straight eighths with a late snare feel",
+ toSectionId: "chorus-1",
+ toSectionLabel: "chorus",
+ toGroove: "Half-time snare with open hats"
+ };
+
+ expect(isGrooveChangeTarget(change, "chorus-1")).toBe(true);
+ expect(isGrooveChangeTarget(change, " verse-1 ")).toBe(false);
+ expect(isGrooveChangeTarget(change, " ")).toBe(false);
+ });
+
+ it("uses stable section identity when repeated labels would otherwise mark multiple cards", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ { ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
+ { ...verse, id: "verse-b", label: "verse", groove: "Half-time" }
+ ];
+
+ const change = firstGrooveChange(song);
+ expect(change?.kind).toBe("change");
+ expect(isGrooveChangeTarget(change!, "verse-a")).toBe(false);
+ expect(isGrooveChangeTarget(change!, "verse-b")).toBe(true);
+ });
+});
+
+describe("fillGrooveCopy", () => {
+ it("keeps rehearsal groove values literal while filling trusted tokens", () => {
+ expect(
+ fillGrooveCopy("Count {toGroove} before the {toSection}.", {
+ toGroove: "Half-time {toSection}",
+ toSection: "chorus"
+ })
+ ).toBe("Count Half-time {toSection} before the chorus.");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.ts b/apps/desktop/src/features/workspace/firstGrooveChange.ts
new file mode 100644
index 000000000..bc53f132a
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstGrooveChange.ts
@@ -0,0 +1,115 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named feel change, or a same-feel hold through the form. */
+export type FirstGrooveChange = {
+ kind: "change" | "same";
+ fromSectionId: string;
+ fromSectionLabel: string;
+ fromGroove: string;
+ toSectionId: string;
+ toSectionLabel: string;
+ toGroove: string;
+};
+
+type NamedGroove = {
+ sectionId: string;
+ sectionLabel: string;
+ groove: 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);
+}
+
+/**
+ * Pick the first proven groove change a player should count in before the next section.
+ *
+ * Every section in form order must carry a stable identity, label, and named groove.
+ * Missing evidence fails closed rather than bridging nonadjacent sections or claiming
+ * that a single known feel holds through unknown form. Stable section ids own roadmap
+ * targeting, and all meaningful ids are validated for uniqueness before groove
+ * evidence is derived so a later duplicate cannot bypass validation through an early
+ * transition return.
+ */
+export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null {
+ const runtimeSong: unknown = song;
+ 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);
+ }
+
+ const namedGrooves: NamedGroove[] = [];
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue)) {
+ return null;
+ }
+ const sectionId = meaningfulRangeText(sectionValue.id);
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ const groove = meaningfulRangeText(sectionValue.groove);
+ if (!sectionId || !sectionLabel || !groove) {
+ return null;
+ }
+ namedGrooves.push({ sectionId, sectionLabel, groove });
+ }
+
+ if (namedGrooves.length === 0) {
+ return null;
+ }
+
+ const first = namedGrooves[0];
+ if (!first) {
+ return null;
+ }
+ for (let index = 1; index < namedGrooves.length; index += 1) {
+ const previous = namedGrooves[index - 1];
+ const current = namedGrooves[index];
+ if (previous.groove !== current.groove) {
+ return {
+ kind: "change",
+ fromSectionId: previous.sectionId,
+ fromSectionLabel: previous.sectionLabel,
+ fromGroove: previous.groove,
+ toSectionId: current.sectionId,
+ toSectionLabel: current.sectionLabel,
+ toGroove: current.groove
+ };
+ }
+ }
+
+ return {
+ kind: "same",
+ fromSectionId: first.sectionId,
+ fromSectionLabel: first.sectionLabel,
+ fromGroove: first.groove,
+ toSectionId: first.sectionId,
+ toSectionLabel: first.sectionLabel,
+ toGroove: first.groove
+ };
+}
+
+/** Fill trusted `{token}` placeholders for groove-change rehearsal copy. */
+export function fillGrooveCopy(template: string, values: Record): string {
+ return fillRangeCopy(template, values);
+}
+
+/** True when this stable section identity is the map card owning the next groove action. */
+export function isGrooveChangeTarget(change: FirstGrooveChange, sectionId: string): boolean {
+ const identity = meaningfulRangeText(sectionId);
+ return Boolean(identity) && identity === change.toSectionId;
+}
diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts
index e3347d1f5..4c4303c34 100644
--- a/apps/desktop/src/lib/analysis.test.ts
+++ b/apps/desktop/src/lib/analysis.test.ts
@@ -4,8 +4,11 @@ import {
MAX_YOUTUBE_URL_LENGTH,
getAnalysisJobStatus,
importYoutubeUrl,
+ loadProject,
+ saveProject,
startAnalysisJob
} from "./analysis";
+import { hasSyntheticSectionTimeRange } from "./rehearsalTimingEvidence";
type TauriWindow = Window & {
__TAURI_INTERNALS__?: unknown;
@@ -99,7 +102,7 @@ describe("analysis bridge", () => {
expect(selection.ok).toBe(true);
});
- it("normalizes legacy analysis job status responses before returning them", async () => {
+ it("retains synthetic timing provenance when normalizing a legacy analysis result", async () => {
const legacyResult = createDemoRehearsalSong() as unknown as {
sections: Array>;
};
@@ -115,6 +118,35 @@ describe("analysis bridge", () => {
const status = await getAnalysisJobStatus("job-legacy");
expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 });
+ expect(hasSyntheticSectionTimeRange(status.result?.sections[0])).toBe(true);
+ });
+
+ it("marks synthetic timing when the JavaScript adapter receives a legacy-shaped load payload", async () => {
+ const legacyProject = createDemoRehearsalSong() as unknown as {
+ sections: Array>;
+ };
+ delete legacyProject.sections[0]!.timeRange;
+ tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(legacyProject);
+
+ const loaded = await loadProject();
+
+ expect(tauriWindow.__TAURI_INVOKE__).toHaveBeenCalledWith("load_project", undefined);
+ expect(loaded.sections[0]?.timeRange).toEqual({ start: 0, end: 1 });
+ expect(hasSyntheticSectionTimeRange(loaded.sections[0])).toBe(true);
+ });
+
+ it("refuses to persist compatibility-only timing as measured project data", async () => {
+ const legacyProject = createDemoRehearsalSong() as unknown as {
+ sections: Array>;
+ };
+ delete legacyProject.sections[0]!.timeRange;
+ const invokeMock = vi.fn().mockResolvedValueOnce(legacyProject);
+ tauriWindow.__TAURI_INVOKE__ = invokeMock;
+
+ const loaded = await loadProject();
+
+ await expect(saveProject(loaded)).rejects.toThrow(/reanalyze/i);
+ expect(invokeMock).toHaveBeenCalledTimes(1);
});
it("reports staged browser fallback progress before returning the demo result", async () => {
diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts
index bb750b34b..badbbef9d 100644
--- a/apps/desktop/src/lib/analysis.ts
+++ b/apps/desktop/src/lib/analysis.ts
@@ -4,7 +4,6 @@ import {
createDemoAnalysisJobRequest,
createDemoRehearsalSong,
createProjectBootstrapSummary,
- parseAnalysisJobStatus,
parseAnalysisJobRequest,
parseProjectBootstrapSummary,
parseRehearsalSong,
@@ -15,6 +14,11 @@ import {
type RehearsalSong
} from "@bandscope/shared-types";
import { listen } from "@tauri-apps/api/event";
+import {
+ assertMeasuredSectionTimingForPersistence,
+ parseAnalysisJobStatusWithTimingEvidence,
+ parseRehearsalSongWithTimingEvidence
+} from "./rehearsalTimingEvidence";
type TauriInvoke = (command: string, args?: Record) => Promise;
@@ -264,7 +268,7 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise {
const response = await invokeAnalysis("get_analysis_job_status", { jobId });
try {
- return parseAnalysisJobStatus(response);
+ return parseAnalysisJobStatusWithTimingEvidence(response);
} catch {
throw new Error("Invalid analysis job status response");
}
@@ -296,7 +300,7 @@ export async function subscribeToAnalysisJobUpdates(
try {
const unlisten = await listen("analysis-job-updated", (event) => {
try {
- const status = parseAnalysisJobStatus(event.payload);
+ const status = parseAnalysisJobStatusWithTimingEvidence(event.payload);
if (status.jobId === jobId) {
onUpdate(status);
}
@@ -344,6 +348,7 @@ export async function importYoutubeUrl(url: string): Promise {
+ assertMeasuredSectionTimingForPersistence(song);
const parsedSong = parseRehearsalSong(song);
await invokeAnalysis("save_project", { payload: parsedSong });
}
@@ -351,5 +356,5 @@ export async function saveProject(song: RehearsalSong): Promise {
/** Documented. */
export async function loadProject(): Promise {
const response = await invokeAnalysis("load_project");
- return parseRehearsalSong(response);
+ return parseRehearsalSongWithTimingEvidence(response);
}
diff --git a/apps/desktop/src/lib/rehearsalTimingEvidence.ts b/apps/desktop/src/lib/rehearsalTimingEvidence.ts
new file mode 100644
index 000000000..8674b5c01
--- /dev/null
+++ b/apps/desktop/src/lib/rehearsalTimingEvidence.ts
@@ -0,0 +1,94 @@
+import {
+ parseAnalysisJobStatus,
+ parseRehearsalSong,
+ type AnalysisJobStatus,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+
+const SYNTHETIC_SECTION_TIME_RANGE = Symbol("bandscope.syntheticSectionTimeRange");
+
+type RuntimeSectionRecord = Record & {
+ [SYNTHETIC_SECTION_TIME_RANGE]?: true;
+};
+
+/** Return whether an untrusted value can carry section timing evidence. */
+function isRuntimeSectionRecord(sectionValue: unknown): sectionValue is RuntimeSectionRecord {
+ return typeof sectionValue === "object" && sectionValue !== null && !Array.isArray(sectionValue);
+}
+
+/** Return whether a parsed section carries a compatibility-only synthetic time range. */
+export function hasSyntheticSectionTimeRange(sectionValue: unknown): boolean {
+ return isRuntimeSectionRecord(sectionValue) && sectionValue[SYNTHETIC_SECTION_TIME_RANGE] === true;
+}
+
+/**
+ * Parse a rehearsal song while retaining whether legacy migration synthesized a section time range.
+ *
+ * The marker is symbol-keyed, so it remains an application-internal adapter concern and is omitted
+ * from JSON/vendor contracts. It is enumerable so ordinary object-spread copies preserve the
+ * evidence while the song remains in memory.
+ */
+export function parseRehearsalSongWithTimingEvidence(songValue: unknown): RehearsalSong {
+ const sourceSections =
+ isRuntimeSectionRecord(songValue) && Array.isArray(songValue.sections) ? songValue.sections : null;
+ const parsedSong = parseRehearsalSong(songValue);
+
+ if (!sourceSections) {
+ return parsedSong;
+ }
+
+ parsedSong.sections.forEach((parsedSection, sectionIndex) => {
+ const sourceSection = sourceSections[sectionIndex];
+ if (!isRuntimeSectionRecord(sourceSection)) {
+ return;
+ }
+ const missingSourceTimeRange = !Object.prototype.hasOwnProperty.call(sourceSection, "timeRange");
+ if (!missingSourceTimeRange && !hasSyntheticSectionTimeRange(sourceSection)) {
+ return;
+ }
+ Object.defineProperty(parsedSection, SYNTHETIC_SECTION_TIME_RANGE, {
+ value: true,
+ enumerable: true,
+ configurable: false,
+ writable: false
+ });
+ });
+
+ return parsedSong;
+}
+
+/**
+ * Parse an analysis status without losing legacy section-timing provenance in its result.
+ *
+ * Shared-type legacy normalization is intentionally contract-compatible and can synthesize a
+ * missing `timeRange`. Rehearsal decisions must still know that the range was compatibility data,
+ * so the desktop adapter compares the normalized result with the untrusted source envelope and
+ * reapplies the internal marker before returning it to workspace consumers.
+ */
+export function parseAnalysisJobStatusWithTimingEvidence(statusValue: unknown): AnalysisJobStatus {
+ const parsedStatus = parseAnalysisJobStatus(statusValue);
+ if (
+ !parsedStatus.result ||
+ !isRuntimeSectionRecord(statusValue) ||
+ !Object.prototype.hasOwnProperty.call(statusValue, "result")
+ ) {
+ return parsedStatus;
+ }
+
+ parsedStatus.result = parseRehearsalSongWithTimingEvidence(statusValue.result);
+ return parsedStatus;
+}
+
+/**
+ * Fail closed before project persistence can promote compatibility timing to measured evidence.
+ *
+ * The current project schema has no serializable provenance field for a synthesized section range;
+ * serializing the marker would therefore erase the distinction on reload. Until the canonical
+ * Project Persistence context owns a versioned migration for that provenance, reanalysis is the
+ * only truthful path to a persistable range.
+ */
+export function assertMeasuredSectionTimingForPersistence(song: RehearsalSong): void {
+ if (song.sections.some((section) => hasSyntheticSectionTimeRange(section))) {
+ throw new Error("Reanalyze the project to restore measured section timing before saving.");
+ }
+}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..99a4c5c74 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,28 @@
"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.",
+ "workspaceFirstChangesTitle": "Tonight's first changes",
+ "workspaceFirstGrooveTitle": "Tonight's first feel change",
+ "workspaceFirstGrooveChange": "The feel changes at {toSection}: {toGroove}, after {fromSection}'s {fromGroove}. Count the new groove in before the {toSection}.",
+ "workspaceFirstGrooveHold": "Tonight's feel stays {groove} through the form. Count that groove in before the {sectionLabel}.",
+ "workspaceFirstGrooveMissing": "Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal.",
+ "sectionGrooveNextActionChange": "Count this new groove in before {sectionLabel}.",
+ "sectionGrooveNextActionHold": "Count this groove in before {sectionLabel}.",
+ "workspaceFirstDurationTitle": "Tonight's first length change",
+ "workspaceFirstDurationChange": "The section length changes at {toSection}: {toDuration} seconds, after {fromSection}'s {fromDuration} seconds. Count the new length in before the {toSection}.",
+ "workspaceFirstDurationHold": "Tonight's section length stays {duration} seconds through the form. Count that length in before the {sectionLabel}.",
+ "workspaceFirstDurationMissing": "Section timing isn't confirmed yet. Analyze section timing before using a count-in.",
+ "sectionDurationNextActionChange": "Count this new length in before {sectionLabel}.",
+ "sectionDurationNextActionHold": "Count this length in before {sectionLabel}.",
+ "workspaceFirstConfidenceTitle": "Tonight's first confidence change",
+ "workspaceFirstConfidenceChange": "Confidence changes at {toSection}: {toLevel}, after {fromSection}'s {fromLevel}. Confirm the {toSection} by ear before you count in.",
+ "workspaceFirstConfidenceHold": "Tonight's confidence stays {level} through the form. Confirm the {sectionLabel} by ear before you count in.",
+ "workspaceFirstConfidenceMissing": "Section confidence isn't confirmed yet. Analyze section confidence before rehearsal.",
+ "sectionConfidenceNextActionChange": "Confirm this section by ear before {sectionLabel}.",
+ "sectionConfidenceNextActionHold": "Give this {level} read an ear check before {sectionLabel}.",
+ "confidenceWordLow": "low",
+ "confidenceWordMedium": "medium",
+ "confidenceWordHigh": "high",
"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..863e46d78 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,28 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstChangesTitle": "오늘 먼저 확인할 변화",
+ "workspaceFirstGrooveTitle": "오늘 먼저 바뀌는 그루브",
+ "workspaceFirstGrooveChange": "{fromSection}의 {fromGroove} 다음, {toSection}에서 {toGroove}로 바뀝니다. {toSection} 들어가기 전에 새 그루브를 세어 보세요.",
+ "workspaceFirstGrooveHold": "오늘 그루브는 {groove}로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 그루브를 세어 보세요.",
+ "workspaceFirstGrooveMissing": "그루브 변화는 아직 확인되지 않았습니다. 합주 전에 구간별 그루브를 분석하거나 귀로 확인하세요.",
+ "sectionGrooveNextActionChange": "{sectionLabel} 들어가기 전에 이 새 그루브를 세어 보세요.",
+ "sectionGrooveNextActionHold": "{sectionLabel} 들어가기 전에 이 그루브를 세어 보세요.",
+ "workspaceFirstDurationTitle": "오늘 먼저 바뀌는 구간 길이",
+ "workspaceFirstDurationChange": "{fromSection}의 {fromDuration}초 다음, {toSection}에서 {toDuration}초로 바뀝니다. {toSection} 들어가기 전에 새 길이를 세어 보세요.",
+ "workspaceFirstDurationHold": "오늘 구간 길이는 {duration}초로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 길이를 세어 보세요.",
+ "workspaceFirstDurationMissing": "구간 길이는 아직 확인되지 않았습니다. 카운트인에 쓰기 전에 구간 타이밍을 분석하거나 귀로 확인하세요.",
+ "sectionDurationNextActionChange": "{sectionLabel} 들어가기 전에 이 새 길이를 세어 보세요.",
+ "sectionDurationNextActionHold": "{sectionLabel} 들어가기 전에 이 길이를 세어 보세요.",
+ "workspaceFirstConfidenceTitle": "오늘 먼저 바뀌는 확신",
+ "workspaceFirstConfidenceChange": "{fromSection}의 {fromLevel} 다음, {toSection}에서 {toLevel}으로 바뀝니다. {toSection} 들어가기 전에 귀로 한 번 더 확인해 보세요.",
+ "workspaceFirstConfidenceHold": "오늘 확신은 {level}으로 끝까지 갑니다. {sectionLabel} 들어가기 전에 귀로 한 번 더 확인해 보세요.",
+ "workspaceFirstConfidenceMissing": "구간 신뢰도는 아직 확인되지 않았습니다. 합주 전에 구간별 신뢰 근거를 분석하거나 귀로 확인하세요.",
+ "sectionConfidenceNextActionChange": "{sectionLabel} 들어가기 전에 이 구간을 귀로 확인해 보세요.",
+ "sectionConfidenceNextActionHold": "{sectionLabel} 들어가기 전에 이 {level} 읽기를 귀로 확인해 보세요.",
+ "confidenceWordLow": "낮음",
+ "confidenceWordMedium": "중간",
+ "confidenceWordHigh": "높음",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}