From 4172ae164001584d4e4f9e9d3da06e5c9be66017 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 06:23:25 +0000 Subject: [PATCH 01/28] feat(workspace): name tonight's first groove change on the map Name the first consecutive feel change on the ready rehearsal map and tell the player to count the new groove in before that section. When the form holds one feel, keep the same count-in instead of inventing a reset. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/SectionRoadmap.test.tsx | 24 ++++ .../src/features/workspace/SectionRoadmap.tsx | 10 ++ .../src/features/workspace/Workspace.test.tsx | 48 +++++++ .../src/features/workspace/Workspace.tsx | 24 ++++ .../workspace/firstGrooveChange.test.ts | 117 ++++++++++++++++++ .../features/workspace/firstGrooveChange.ts | 89 +++++++++++++ apps/desktop/src/locales/en/common.json | 6 + apps/desktop/src/locales/ko/common.json | 6 + 12 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstGrooveChange.test.ts create mode 100644 apps/desktop/src/features/workspace/firstGrooveChange.ts diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ab8434e28 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 change and the next count-in), stems, playable ranges, 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..6c9e93e2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ Last updated: 2026-03-11 - Core rehearsal artifacts should include: - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - - 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 the next count-in - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..7098f8ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. +- Name tonight's first groove change on the ready rehearsal map and tell the player to count the new feel in before that 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..0e3e8b86e 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 change and the next count-in. `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..06a0fbafe 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -34,6 +34,30 @@ 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("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..41924d3ab 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -3,6 +3,7 @@ import { useId, useMemo } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; +import { fillGrooveCopy, firstGrooveChange, isGrooveChangeTarget } from "./firstGrooveChange"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const grooveChange = useMemo(() => firstGrooveChange(song), [song]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -120,6 +122,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove} + {grooveChange && isGrooveChangeTarget(grooveChange, section.label) ? ( +

+ {fillGrooveCopy( + t(grooveChange.kind === "change" ? "sectionGrooveNextActionChange" : "sectionGrooveNextActionHold"), + { sectionLabel: section.label } + )} +

+ ) : null} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..e2a8fd7d7 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,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 +360,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( + "Tonight's first feel change still needs an ear check. Confirm the groove of the first two sections before you count in." + ); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..85a4613ce 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 { fillGrooveCopy, firstGrooveChange } from "./firstGrooveChange"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,20 @@ 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"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +325,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstGrooveTitle")}

+

{firstGrooveCopy}

+
+

{t("workspaceSongTimelineLabel")}

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..627c3bad2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts @@ -0,0 +1,117 @@ +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", + fromSectionLabel: "verse", + fromGroove: "Straight eighths with a late snare feel", + 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", + fromSectionLabel: "pre-chorus", + fromGroove: "Straight eighths with a late snare feel", + toSectionLabel: "chorus", + toGroove: "Half-time snare with open hats" + }); + }); + + it("skips unlabeled or none-groove sections until a named pair exists", () => { + const song = cloneSong(createDemoRehearsalSong()); + song.sections[0] = { ...song.sections[0]!, label: " ", groove: "none" }; + const withChorus = appendSection(song, "chorus", "Half-time snare with open hats"); + const withBridge = appendSection(withChorus, "bridge", "Double-time ride"); + + expect(firstGrooveChange(withBridge)).toEqual({ + kind: "change", + fromSectionLabel: "chorus", + fromGroove: "Half-time snare with open hats", + toSectionLabel: "bridge", + toGroove: "Double-time ride" + }); + }); + + 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(); + }); +}); + +describe("isGrooveChangeTarget", () => { + it("marks only the arrival section as the next-action card", () => { + const change: ReturnType = { + kind: "change", + fromSectionLabel: "verse", + fromGroove: "Straight eighths with a late snare feel", + toSectionLabel: "chorus", + toGroove: "Half-time snare with open hats" + }; + + expect(isGrooveChangeTarget(change, "chorus")).toBe(true); + expect(isGrooveChangeTarget(change, " verse ")).toBe(false); + expect(isGrooveChangeTarget(change, " ")).toBe(false); + }); +}); + +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..ba38cf242 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstGrooveChange.ts @@ -0,0 +1,89 @@ +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"; + fromSectionLabel: string; + fromGroove: string; + toSectionLabel: string; + toGroove: string; +}; + +type NamedGroove = { + 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 named groove change a player should count in before the next section. + * + * Walks labeled sections in form order and returns the first consecutive pair + * whose trimmed groove text differs. When every named section holds the same + * feel, the result is a same-feel hold so the room does not reset the groove. + * Runtime roots and collection members are treated as untrusted; malformed + * evidence is isolated instead of becoming feel-change authority. + */ +export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const namedGrooves: NamedGroove[] = []; + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + const groove = meaningfulRangeText(sectionValue.groove); + if (!sectionLabel || !groove) { + continue; + } + namedGrooves.push({ sectionLabel, groove }); + } + + if (namedGrooves.length === 0) { + return null; + } + + const first = namedGrooves[0]; + 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", + fromSectionLabel: previous.sectionLabel, + fromGroove: previous.groove, + toSectionLabel: current.sectionLabel, + toGroove: current.groove + }; + } + } + + return { + kind: "same", + fromSectionLabel: first.sectionLabel, + fromGroove: first.groove, + 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 labeled section is the map card that should name the next groove action. */ +export function isGrooveChangeTarget(change: FirstGrooveChange, sectionLabel: string): boolean { + const label = meaningfulRangeText(sectionLabel); + return Boolean(label) && label === change.toSectionLabel; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..48ff799e3 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,12 @@ "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.", + "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": "Tonight's first feel change still needs an ear check. Confirm the groove of the first two sections before you count in.", + "sectionGrooveNextActionChange": "Count this new groove in before {sectionLabel}.", + "sectionGrooveNextActionHold": "Count this groove in before {sectionLabel}.", "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..ac349b37a 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,12 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstGrooveTitle": "오늘 먼저 바뀌는 그루브", + "workspaceFirstGrooveChange": "{fromSection}의 {fromGroove} 다음, {toSection}에서 {toGroove}로 바뀝니다. {toSection} 들어가기 전에 새 그루브를 세어 보세요.", + "workspaceFirstGrooveHold": "오늘 그루브는 {groove}로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 그루브를 세어 보세요.", + "workspaceFirstGrooveMissing": "오늘 먼저 바뀌는 그루브는 아직 귀로 확인이 필요합니다. 처음 두 구간의 그루브를 확인한 뒤 카운트하세요.", + "sectionGrooveNextActionChange": "{sectionLabel} 들어가기 전에 이 새 그루브를 세어 보세요.", + "sectionGrooveNextActionHold": "{sectionLabel} 들어가기 전에 이 그루브를 세어 보세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From 159cecb328a23ecdb4dd188f11dac154b6e3bccc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:20:31 +0900 Subject: [PATCH 02/28] test(groove): reject repeated-label roadmap ambiguity --- .../features/workspace/firstGrooveChange.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts index 627c3bad2..361a1f723 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts @@ -103,6 +103,20 @@ describe("isGrooveChangeTarget", () => { expect(isGrooveChangeTarget(change, " verse ")).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", () => { From 9590836b89017ccb5cb24e7c1b208053f82b6d54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:22:16 +0900 Subject: [PATCH 03/28] test(groove): pin repeated-label destination card --- .../features/workspace/SectionRoadmap.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 06a0fbafe..da039d0c8 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -60,6 +60,23 @@ describe("SectionRoadmap", () => { ); }); + 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", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); From 2b686e3ff9b2f8ecb7dab31ba651144f228c9d63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:23:13 +0900 Subject: [PATCH 04/28] fix(groove): target roadmap actions by section identity --- .../workspace/firstGrooveChange.test.ts | 25 ++++++++++++++-- .../features/workspace/firstGrooveChange.ts | 29 ++++++++++++++----- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts index 361a1f723..24b78232b 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts @@ -33,8 +33,10 @@ 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" }); @@ -49,8 +51,10 @@ describe("firstGrooveChange", () => { 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" }); @@ -64,8 +68,10 @@ describe("firstGrooveChange", () => { expect(firstGrooveChange(withBridge)).toEqual({ kind: "change", + fromSectionId: "chorus-section", fromSectionLabel: "chorus", fromGroove: "Half-time snare with open hats", + toSectionId: "bridge-section", toSectionLabel: "bridge", toGroove: "Double-time ride" }); @@ -87,20 +93,33 @@ describe("firstGrooveChange", () => { } 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(); + }); }); describe("isGrooveChangeTarget", () => { - it("marks only the arrival section as the next-action card", () => { + 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")).toBe(true); - expect(isGrooveChangeTarget(change, " verse ")).toBe(false); + expect(isGrooveChangeTarget(change, "chorus-1")).toBe(true); + expect(isGrooveChangeTarget(change, " verse-1 ")).toBe(false); expect(isGrooveChangeTarget(change, " ")).toBe(false); }); diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.ts b/apps/desktop/src/features/workspace/firstGrooveChange.ts index ba38cf242..7a4930887 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.ts @@ -4,13 +4,16 @@ 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; }; @@ -26,8 +29,8 @@ function isRuntimeObject(value: unknown): value is Record { * Walks labeled sections in form order and returns the first consecutive pair * whose trimmed groove text differs. When every named section holds the same * feel, the result is a same-feel hold so the room does not reset the groove. - * Runtime roots and collection members are treated as untrusted; malformed - * evidence is isolated instead of becoming feel-change authority. + * Stable section ids, rather than display labels, own roadmap targeting. Any + * duplicate section id is ambiguous runtime evidence and fails closed. */ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null { const runtimeSong: unknown = song; @@ -36,17 +39,23 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null } const namedGrooves: NamedGroove[] = []; + const seenSectionIds = new Set(); for (const sectionValue of runtimeSong.sections) { if (!isRuntimeObject(sectionValue)) { continue; } + const sectionId = meaningfulRangeText(sectionValue.id); const sectionLabel = meaningfulRangeText(sectionValue.label); const groove = meaningfulRangeText(sectionValue.groove); - if (!sectionLabel || !groove) { + if (!sectionId || !sectionLabel || !groove) { continue; } - namedGrooves.push({ sectionLabel, groove }); + if (seenSectionIds.has(sectionId)) { + return null; + } + seenSectionIds.add(sectionId); + namedGrooves.push({ sectionId, sectionLabel, groove }); } if (namedGrooves.length === 0) { @@ -60,8 +69,10 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null 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 }; @@ -70,8 +81,10 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null return { kind: "same", + fromSectionId: first.sectionId, fromSectionLabel: first.sectionLabel, fromGroove: first.groove, + toSectionId: first.sectionId, toSectionLabel: first.sectionLabel, toGroove: first.groove }; @@ -82,8 +95,8 @@ export function fillGrooveCopy(template: string, values: Record) return fillRangeCopy(template, values); } -/** True when this labeled section is the map card that should name the next groove action. */ -export function isGrooveChangeTarget(change: FirstGrooveChange, sectionLabel: string): boolean { - const label = meaningfulRangeText(sectionLabel); - return Boolean(label) && label === change.toSectionLabel; +/** 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; } From 4c9252b21c2f709aeb2ab6b59b00a31e1dfe95f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:36:59 +0900 Subject: [PATCH 05/28] fix(workspace): validate groove target identities before derivation --- .../features/workspace/firstGrooveChange.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.ts b/apps/desktop/src/features/workspace/firstGrooveChange.ts index 7a4930887..43b994209 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.ts @@ -29,8 +29,9 @@ function isRuntimeObject(value: unknown): value is Record { * Walks labeled sections in form order and returns the first consecutive pair * whose trimmed groove text differs. When every named section holds the same * feel, the result is a same-feel hold so the room does not reset the groove. - * Stable section ids, rather than display labels, own roadmap targeting. Any - * duplicate section id is ambiguous runtime evidence and fails closed. + * Stable section ids, rather than display labels, own roadmap targeting. All + * meaningful section ids are validated for uniqueness before groove evidence + * is derived so an ineligible or later duplicate cannot create ambiguous cards. */ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null { const runtimeSong: unknown = song; @@ -38,23 +39,32 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null return null; } - const namedGrooves: NamedGroove[] = []; const seenSectionIds = new Set(); - for (const sectionValue of runtimeSong.sections) { if (!isRuntimeObject(sectionValue)) { continue; } const sectionId = meaningfulRangeText(sectionValue.id); - const sectionLabel = meaningfulRangeText(sectionValue.label); - const groove = meaningfulRangeText(sectionValue.groove); - if (!sectionId || !sectionLabel || !groove) { + if (!sectionId) { continue; } if (seenSectionIds.has(sectionId)) { return null; } seenSectionIds.add(sectionId); + } + + const namedGrooves: NamedGroove[] = []; + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + const sectionLabel = meaningfulRangeText(sectionValue.label); + const groove = meaningfulRangeText(sectionValue.groove); + if (!sectionId || !sectionLabel || !groove) { + continue; + } namedGrooves.push({ sectionId, sectionLabel, groove }); } From e5dd8fbea67fd4bacc520f7d925c91898f82b36a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:37:32 +0900 Subject: [PATCH 06/28] fix(workspace): target groove roadmap card by section id --- apps/desktop/src/features/workspace/SectionRoadmap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 41924d3ab..188eabca7 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -122,7 +122,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove}
- {grooveChange && isGrooveChangeTarget(grooveChange, section.label) ? ( + {grooveChange && isGrooveChangeTarget(grooveChange, section.id) ? (

{fillGrooveCopy( t(grooveChange.kind === "change" ? "sectionGrooveNextActionChange" : "sectionGrooveNextActionHold"), From 184d231237454a67630aa1a7be8faafba8531fbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:38:05 +0900 Subject: [PATCH 07/28] test(workspace): cover ambiguous groove section identities --- .../workspace/firstGrooveChange.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts index 24b78232b..d2571df9f 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts @@ -104,6 +104,29 @@ describe("firstGrooveChange", () => { 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", () => { From d40bd4550d728b60368114580dcd5cd73adf7631 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:17:14 +0900 Subject: [PATCH 08/28] refactor(workspace): preserve sibling change analyzers --- .../workspace/firstConfidenceChange.test.ts | 207 ++++++++++++++++++ .../workspace/firstConfidenceChange.ts | 144 ++++++++++++ .../workspace/firstDurationChange.test.ts | 196 +++++++++++++++++ .../features/workspace/firstDurationChange.ts | 149 +++++++++++++ 4 files changed, 696 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstConfidenceChange.test.ts create mode 100644 apps/desktop/src/features/workspace/firstConfidenceChange.ts create mode 100644 apps/desktop/src/features/workspace/firstDurationChange.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDurationChange.ts 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..4fa723a5d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDurationChange.test.ts @@ -0,0 +1,196 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +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 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..56b439797 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDurationChange.ts @@ -0,0 +1,149 @@ +import { MAX_SECTION_TIME_SECONDS, type RehearsalSong } from "@bandscope/shared-types"; +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. 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); + const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); + if (!sectionId || !sectionLabel || durationSeconds === null) { + continue; + } + 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; +} From 2c8039679cb7998f993776f83a49e5f0a0d6d5ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:42:30 +0900 Subject: [PATCH 09/28] fix(workspace): preserve measured duration authority --- .../features/workspace/firstDurationChange.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.ts b/apps/desktop/src/features/workspace/firstDurationChange.ts index 56b439797..f38edb5b1 100644 --- a/apps/desktop/src/features/workspace/firstDurationChange.ts +++ b/apps/desktop/src/features/workspace/firstDurationChange.ts @@ -1,4 +1,5 @@ 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. */ @@ -56,12 +57,15 @@ export function sectionDurationSeconds(timeRangeValue: unknown): number | null { * 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. 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. + * 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; @@ -91,10 +95,16 @@ export function firstDurationChange(song: RehearsalSong): FirstDurationChange | } const sectionId = meaningfulRangeText(sectionValue.id); const sectionLabel = meaningfulRangeText(sectionValue.label); - const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); - if (!sectionId || !sectionLabel || durationSeconds === null) { + if (!sectionId || !sectionLabel) { continue; } + if (hasSyntheticSectionTimeRange(sectionValue)) { + return null; + } + const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); + if (durationSeconds === null) { + return null; + } namedDurations.push({ sectionId, sectionLabel, From 741cbb44d7c2abbd67ca8df21dcae51d66346001 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:42:50 +0900 Subject: [PATCH 10/28] feat(project): retain legacy timing provenance --- .../src/lib/rehearsalTimingEvidence.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/desktop/src/lib/rehearsalTimingEvidence.ts diff --git a/apps/desktop/src/lib/rehearsalTimingEvidence.ts b/apps/desktop/src/lib/rehearsalTimingEvidence.ts new file mode 100644 index 000000000..29648726c --- /dev/null +++ b/apps/desktop/src/lib/rehearsalTimingEvidence.ts @@ -0,0 +1,53 @@ +import { parseRehearsalSong, 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; +} From d716b9fafb9b9e05756548064f5a0a16aa6040f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:43:30 +0900 Subject: [PATCH 11/28] test(workspace): preserve duration provenance regressions --- .../workspace/firstDurationChange.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.test.ts b/apps/desktop/src/features/workspace/firstDurationChange.test.ts index 4fa723a5d..35bd89a8f 100644 --- a/apps/desktop/src/features/workspace/firstDurationChange.test.ts +++ b/apps/desktop/src/features/workspace/firstDurationChange.test.ts @@ -1,5 +1,6 @@ import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; +import { parseRehearsalSongWithTimingEvidence } from "../../lib/rehearsalTimingEvidence"; import { fillDurationCopy, firstDurationChange, @@ -106,6 +107,49 @@ describe("firstDurationChange", () => { }); }); + 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(); From 583734e755bdecaf86169f471f744c02326bbadd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:44:03 +0900 Subject: [PATCH 12/28] fix(project): keep migration timing provenance through load --- apps/desktop/src/lib/analysis.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..3b977d570 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -15,6 +15,7 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { parseRehearsalSongWithTimingEvidence } from "./rehearsalTimingEvidence"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -351,5 +352,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); } From 7f7ecc177ad02b25a5ab3585f5ff429939c554bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:44:34 +0900 Subject: [PATCH 13/28] test(project): preserve timing provenance at load boundary --- apps/desktop/src/lib/analysis.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..7ff9b56f9 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -4,8 +4,10 @@ import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + loadProject, startAnalysisJob } from "./analysis"; +import { hasSyntheticSectionTimeRange } from "./rehearsalTimingEvidence"; type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown; @@ -117,6 +119,20 @@ describe("analysis bridge", () => { expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); }); + it("preserves synthetic timing provenance when a legacy project crosses the native load boundary", 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("reports staged browser fallback progress before returning the demo result", async () => { const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); From 443fb3d4a1b16881c8b4e1b93de0c596387be97a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:47:16 +0900 Subject: [PATCH 14/28] refactor(workspace): consolidate first-change guidance --- .../src/features/workspace/Workspace.tsx | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 85a4613ce..ccee87688 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -6,6 +6,8 @@ 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"; @@ -178,6 +180,34 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp 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) => { @@ -326,12 +356,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

-

{t("workspaceFirstGrooveTitle")}

-

{firstGrooveCopy}

+

{t("workspaceFirstChangesTitle")}

+
+
+

{t("workspaceFirstGrooveTitle")}

+

{firstGrooveCopy}

+
+
+

{t("workspaceFirstDurationTitle")}

+

{firstDurationCopy}

+
+
+

{t("workspaceFirstConfidenceTitle")}

+

{firstConfidenceCopy}

+
+
From b722e8fb066957c36a3da57c62fcc42e23722c66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:47:56 +0900 Subject: [PATCH 15/28] feat(i18n): unify rehearsal change guidance --- apps/desktop/src/locales/en/common.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 48ff799e3..68c04976c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,12 +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": "Tonight's first feel change still needs an ear check. Confirm the groove of the first two sections before you count in.", "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": "Tonight's first length change still needs an ear check. Confirm how long the first two sections last before you 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": "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are before you count in.", + "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}." } From 995f8fd8a2433cae33ad91e1e8a9a4320b7d30c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:48:35 +0900 Subject: [PATCH 16/28] feat(i18n): unify Korean rehearsal change guidance --- apps/desktop/src/locales/ko/common.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index ac349b37a..a78c4eca7 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,12 +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} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From dd48e755fe636352af9075a7b1b0556fbeb22039 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:49:21 +0900 Subject: [PATCH 17/28] refactor(workspace): unify roadmap next actions --- .../src/features/workspace/SectionRoadmap.tsx | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 188eabca7..df9ec95a8 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -4,6 +4,8 @@ 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"; @@ -21,6 +23,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma 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 => { @@ -106,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}
- {grooveChange && isGrooveChangeTarget(grooveChange, section.id) ? ( -

- {fillGrooveCopy( - t(grooveChange.kind === "change" ? "sectionGrooveNextActionChange" : "sectionGrooveNextActionHold"), - { sectionLabel: section.label } - )} -

+ {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} @@ -238,7 +271,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma })}
- ))} + ); + })} ); From b750ad3dd4e556169ed71b63605b126159598b8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:51:28 +0900 Subject: [PATCH 18/28] test(workspace): preserve consolidated first-change behavior --- .../firstChangesIntegration.test.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx 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..97ce91064 --- /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 explicit ear-check copy when named 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( + "Tonight's first length change still needs an ear check. Confirm how long the first two sections last before you count in." + ); + expect(screen.getByTestId("first-confidence-change")).toHaveTextContent( + "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are before you count in." + ); + }); + + 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 들어가기 전에 이 중간 읽기를 귀로 확인해 보세요." + ); + }); +}); From 3ae8807b338b37bba7e0d3f0aacec2e95c3d28c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:54:05 +0900 Subject: [PATCH 19/28] test(workspace): fail closed on unknown groove adjacency --- .../workspace/firstGrooveChange.test.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts index d2571df9f..6d0c1843f 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.test.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.test.ts @@ -60,21 +60,27 @@ describe("firstGrooveChange", () => { }); }); - it("skips unlabeled or none-groove sections until a named pair exists", () => { + 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()); - song.sections[0] = { ...song.sections[0]!, label: " ", groove: "none" }; - const withChorus = appendSection(song, "chorus", "Half-time snare with open hats"); - const withBridge = appendSection(withChorus, "bridge", "Double-time ride"); + 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(withBridge)).toEqual({ - kind: "change", - fromSectionId: "chorus-section", - fromSectionLabel: "chorus", - fromGroove: "Half-time snare with open hats", - toSectionId: "bridge-section", - toSectionLabel: "bridge", - toGroove: "Double-time ride" - }); + expect(firstGrooveChange(song)).toBeNull(); }); it("trims groove text before deciding a change", () => { From 8be1d954241f41d676c8928519d136738aebdcef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:54:26 +0900 Subject: [PATCH 20/28] fix(workspace): preserve groove adjacency authority --- .../features/workspace/firstGrooveChange.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstGrooveChange.ts b/apps/desktop/src/features/workspace/firstGrooveChange.ts index 43b994209..bc53f132a 100644 --- a/apps/desktop/src/features/workspace/firstGrooveChange.ts +++ b/apps/desktop/src/features/workspace/firstGrooveChange.ts @@ -24,14 +24,14 @@ function isRuntimeObject(value: unknown): value is Record { } /** - * Pick the first named groove change a player should count in before the next section. + * Pick the first proven groove change a player should count in before the next section. * - * Walks labeled sections in form order and returns the first consecutive pair - * whose trimmed groove text differs. When every named section holds the same - * feel, the result is a same-feel hold so the room does not reset the groove. - * Stable section ids, rather than display labels, own roadmap targeting. All - * meaningful section ids are validated for uniqueness before groove evidence - * is derived so an ineligible or later duplicate cannot create ambiguous cards. + * 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; @@ -42,11 +42,11 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null const seenSectionIds = new Set(); for (const sectionValue of runtimeSong.sections) { if (!isRuntimeObject(sectionValue)) { - continue; + return null; } const sectionId = meaningfulRangeText(sectionValue.id); if (!sectionId) { - continue; + return null; } if (seenSectionIds.has(sectionId)) { return null; @@ -57,13 +57,13 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | null const namedGrooves: NamedGroove[] = []; for (const sectionValue of runtimeSong.sections) { if (!isRuntimeObject(sectionValue)) { - continue; + return null; } const sectionId = meaningfulRangeText(sectionValue.id); const sectionLabel = meaningfulRangeText(sectionValue.label); const groove = meaningfulRangeText(sectionValue.groove); if (!sectionId || !sectionLabel || !groove) { - continue; + return null; } namedGrooves.push({ sectionId, sectionLabel, groove }); } @@ -73,6 +73,9 @@ export function firstGrooveChange(song: RehearsalSong): FirstGrooveChange | 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]; From 4eabf558e60547769266a9322501fbbca93ca7b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:06:55 +0900 Subject: [PATCH 21/28] docs(workspace): preserve consolidated first-change contract --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 ++-- CHANGELOG.md | 2 ++ CLAUDE.md | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab8434e28..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 (including tonight's first feel change and the next count-in), 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 6c9e93e2c..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, with the ready workspace naming tonight's first feel change and the next count-in + - 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 7098f8ea3..c5a80f0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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 0e3e8b86e..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, plus tonight's first groove change and the next count-in. `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. From fdaaf255559026ae600b2add3b701c5a04089785 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:45:28 +0900 Subject: [PATCH 22/28] test(workspace): reproduce synthetic timing persistence leak --- apps/desktop/src/lib/analysis.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index 7ff9b56f9..4c4303c34 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -5,6 +5,7 @@ import { getAnalysisJobStatus, importYoutubeUrl, loadProject, + saveProject, startAnalysisJob } from "./analysis"; import { hasSyntheticSectionTimeRange } from "./rehearsalTimingEvidence"; @@ -101,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>; }; @@ -117,9 +118,10 @@ 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("preserves synthetic timing provenance when a legacy project crosses the native load boundary", async () => { + it("marks synthetic timing when the JavaScript adapter receives a legacy-shaped load payload", async () => { const legacyProject = createDemoRehearsalSong() as unknown as { sections: Array>; }; @@ -133,6 +135,20 @@ describe("analysis bridge", () => { 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 () => { const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); From 5dad2527fbf1d7c4915898d0398c0d55412f0c73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:46:07 +0900 Subject: [PATCH 23/28] fix(workspace): preserve timing provenance across analysis adapters --- .../src/lib/rehearsalTimingEvidence.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/rehearsalTimingEvidence.ts b/apps/desktop/src/lib/rehearsalTimingEvidence.ts index 29648726c..8674b5c01 100644 --- a/apps/desktop/src/lib/rehearsalTimingEvidence.ts +++ b/apps/desktop/src/lib/rehearsalTimingEvidence.ts @@ -1,4 +1,9 @@ -import { parseRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { + parseAnalysisJobStatus, + parseRehearsalSong, + type AnalysisJobStatus, + type RehearsalSong +} from "@bandscope/shared-types"; const SYNTHETIC_SECTION_TIME_RANGE = Symbol("bandscope.syntheticSectionTimeRange"); @@ -51,3 +56,39 @@ export function parseRehearsalSongWithTimingEvidence(songValue: unknown): Rehear 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."); + } +} From e1b3c4f25845336dd9ddf176a3023e5ec84e35bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:46:46 +0900 Subject: [PATCH 24/28] fix(workspace): fail closed on synthetic timing persistence --- apps/desktop/src/lib/analysis.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 3b977d570..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,7 +14,11 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; -import { parseRehearsalSongWithTimingEvidence } from "./rehearsalTimingEvidence"; +import { + assertMeasuredSectionTimingForPersistence, + parseAnalysisJobStatusWithTimingEvidence, + parseRehearsalSongWithTimingEvidence +} from "./rehearsalTimingEvidence"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -265,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"); } @@ -297,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); } @@ -345,6 +348,7 @@ export async function importYoutubeUrl(url: string): Promise { + assertMeasuredSectionTimingForPersistence(song); const parsedSong = parseRehearsalSong(song); await invokeAnalysis("save_project", { payload: parsedSong }); } From 0c96d3cd70c88b5a2146ad5ad9d5490ce921a979 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:47:42 +0900 Subject: [PATCH 25/28] test(workspace): reproduce sparse-form guidance claims --- .../src/features/workspace/Workspace.test.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index e2a8fd7d7..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", () => { @@ -371,7 +381,7 @@ describe("Workspace", () => { render(); expect(screen.getByTestId("first-groove-change")).toHaveTextContent( - "Tonight's first feel change still needs an ear check. Confirm the groove of the first two sections before you count in." + "Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal." ); }); }); From 39ac80ae61b73ac5b4a9a2cd124a0f03c69811f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:49:31 +0900 Subject: [PATCH 26/28] fix(workspace): remove nonexistent-section rehearsal claims --- apps/desktop/src/locales/en/common.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 68c04976c..99a4c5c74 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -157,19 +157,19 @@ "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": "Tonight's first feel change still needs an ear check. Confirm the groove of the first two sections before you count in.", + "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": "Tonight's first length change still needs an ear check. Confirm how long the first two sections last before you count in.", + "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": "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are 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", From f973cb704a74231ac80467f58b8b31b2d543bdee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:50:15 +0900 Subject: [PATCH 27/28] fix(workspace): keep missing guidance truthful in Korean --- apps/desktop/src/locales/ko/common.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index a78c4eca7..863e46d78 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -157,19 +157,19 @@ "workspaceFirstGrooveTitle": "오늘 먼저 바뀌는 그루브", "workspaceFirstGrooveChange": "{fromSection}의 {fromGroove} 다음, {toSection}에서 {toGroove}로 바뀝니다. {toSection} 들어가기 전에 새 그루브를 세어 보세요.", "workspaceFirstGrooveHold": "오늘 그루브는 {groove}로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 그루브를 세어 보세요.", - "workspaceFirstGrooveMissing": "오늘 먼저 바뀌는 그루브는 아직 귀로 확인이 필요합니다. 처음 두 구간의 그루브를 확인한 뒤 카운트하세요.", + "workspaceFirstGrooveMissing": "그루브 변화는 아직 확인되지 않았습니다. 합주 전에 구간별 그루브를 분석하거나 귀로 확인하세요.", "sectionGrooveNextActionChange": "{sectionLabel} 들어가기 전에 이 새 그루브를 세어 보세요.", "sectionGrooveNextActionHold": "{sectionLabel} 들어가기 전에 이 그루브를 세어 보세요.", "workspaceFirstDurationTitle": "오늘 먼저 바뀌는 구간 길이", "workspaceFirstDurationChange": "{fromSection}의 {fromDuration}초 다음, {toSection}에서 {toDuration}초로 바뀝니다. {toSection} 들어가기 전에 새 길이를 세어 보세요.", "workspaceFirstDurationHold": "오늘 구간 길이는 {duration}초로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 길이를 세어 보세요.", - "workspaceFirstDurationMissing": "오늘 먼저 바뀌는 구간 길이는 아직 귀로 확인이 필요합니다. 처음 두 구간의 길이를 확인한 뒤 카운트하세요.", + "workspaceFirstDurationMissing": "구간 길이는 아직 확인되지 않았습니다. 카운트인에 쓰기 전에 구간 타이밍을 분석하거나 귀로 확인하세요.", "sectionDurationNextActionChange": "{sectionLabel} 들어가기 전에 이 새 길이를 세어 보세요.", "sectionDurationNextActionHold": "{sectionLabel} 들어가기 전에 이 길이를 세어 보세요.", "workspaceFirstConfidenceTitle": "오늘 먼저 바뀌는 확신", "workspaceFirstConfidenceChange": "{fromSection}의 {fromLevel} 다음, {toSection}에서 {toLevel}으로 바뀝니다. {toSection} 들어가기 전에 귀로 한 번 더 확인해 보세요.", "workspaceFirstConfidenceHold": "오늘 확신은 {level}으로 끝까지 갑니다. {sectionLabel} 들어가기 전에 귀로 한 번 더 확인해 보세요.", - "workspaceFirstConfidenceMissing": "오늘 먼저 바뀌는 확신은 아직 귀로 확인이 필요합니다. 처음 두 구간이 얼마나 확실한지 확인한 뒤 카운트하세요.", + "workspaceFirstConfidenceMissing": "구간 신뢰도는 아직 확인되지 않았습니다. 합주 전에 구간별 신뢰 근거를 분석하거나 귀로 확인하세요.", "sectionConfidenceNextActionChange": "{sectionLabel} 들어가기 전에 이 구간을 귀로 확인해 보세요.", "sectionConfidenceNextActionHold": "{sectionLabel} 들어가기 전에 이 {level} 읽기를 귀로 확인해 보세요.", "confidenceWordLow": "낮음", From 21f38bfcaf92c59c56675fb9d4f54197fcf817db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:05:30 +0900 Subject: [PATCH 28/28] test(workspace): align first-change missing evidence contract --- .../src/features/workspace/firstChangesIntegration.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx b/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx index 97ce91064..b5a1029be 100644 --- a/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx +++ b/apps/desktop/src/features/workspace/firstChangesIntegration.test.tsx @@ -64,7 +64,7 @@ describe("canonical first-change rehearsal guidance", () => { ); }); - it("fails closed with explicit ear-check copy when named duration and confidence evidence are unusable", () => { + it("fails closed with truthful analysis guidance when duration and confidence evidence are unusable", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); song.sections[0] = { @@ -77,10 +77,10 @@ describe("canonical first-change rehearsal guidance", () => { render(); expect(screen.getByTestId("first-duration-change")).toHaveTextContent( - "Tonight's first length change still needs an ear check. Confirm how long the first two sections last before you count in." + "Section timing isn't confirmed yet. Analyze section timing before using a count-in." ); expect(screen.getByTestId("first-confidence-change")).toHaveTextContent( - "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are before you count in." + "Section confidence isn't confirmed yet. Analyze section confidence before rehearsal." ); });