From 3c9eb80f38170ee508347f43052c71f9a8048855 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 16:15:01 +0000 Subject: [PATCH 1/8] feat(workspace): name tonight's first length change on the map Tell the player to count the new section length in before that arrival, or keep tonight's held length when every named section lasts the same. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/SectionRoadmap.test.tsx | 41 ++++ .../src/features/workspace/SectionRoadmap.tsx | 10 + .../src/features/workspace/Workspace.test.tsx | 48 +++++ .../src/features/workspace/Workspace.tsx | 24 +++ .../workspace/firstDurationChange.test.ts | 196 ++++++++++++++++++ .../features/workspace/firstDurationChange.ts | 149 +++++++++++++ apps/desktop/src/locales/en/common.json | 6 + apps/desktop/src/locales/ko/common.json | 6 + 12 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstDurationChange.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDurationChange.ts diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ebfb9e04e 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 section-length 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..5645f7a88 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 section-length 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..9afb72fa9 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 section-length change on the ready rehearsal map and tell the player to count the new length 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..2a0b079d5 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 section-length 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..c1e4adb0f 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -34,6 +34,47 @@ describe("SectionRoadmap", () => { expect(screen.getAllByText("음역").length).toBeGreaterThan(0); expect(screen.getByText("C#2 — E3")).toBeTruthy(); expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); + expect(screen.getByText("verse 들어가기 전에 이 길이를 세어 보세요.")).toBeTruthy(); + }); + + it("names the next count-in on the section where the length changes", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + verse, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 62 }, + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + render(); + + expect(screen.queryByTestId("duration-next-action-verse-1")).toBeNull(); + expect(screen.getByTestId("duration-next-action-chorus-1")).toHaveTextContent( + "Count this new length in before chorus." + ); + }); + + it("marks only the destination card when consecutive sections share a label", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } }, + { ...verse, id: "verse-b", label: "verse", timeRange: { start: 16, end: 48 } } + ]; + + 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." + ); }); 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..99a2750dc 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 { fillDurationCopy, firstDurationChange, isDurationChangeTarget } from "./firstDurationChange"; 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 durationChange = useMemo(() => firstDurationChange(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} + {durationChange && isDurationChangeTarget(durationChange, section.id) ? ( +

+ {fillDurationCopy( + t(durationChange.kind === "change" ? "sectionDurationNextActionChange" : "sectionDurationNextActionHold"), + { 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..c0f850643 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 length and the next count-in when the form does not change duration", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-duration-change"); + expect(callout).toHaveTextContent("Tonight's first length change"); + expect(callout).toHaveTextContent( + "Tonight's section length stays 20 seconds through the form. Count that length in before the verse." + ); + }); + + it("names tonight's first length 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", + timeRange: { start: 30, end: 62 }, + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + render(); + + 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." + ); + }); + 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 section length exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0] = { ...song.sections[0]!, label: " ", timeRange: { start: 10, end: 10 } }; + + 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." + ); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..9bcba9140 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 { fillDurationCopy, firstDurationChange } from "./firstDurationChange"; 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 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"); /** 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("workspaceFirstDurationTitle")}

+

{firstDurationCopy}

+
+

{t("workspaceSongTimelineLabel")}

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; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..65029d056 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.", + "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}.", "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..ee573a81c 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": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstDurationTitle": "오늘 먼저 바뀌는 구간 길이", + "workspaceFirstDurationChange": "{fromSection}의 {fromDuration}초 다음, {toSection}에서 {toDuration}초로 바뀝니다. {toSection} 들어가기 전에 새 길이를 세어 보세요.", + "workspaceFirstDurationHold": "오늘 구간 길이는 {duration}초로 끝까지 갑니다. {sectionLabel} 들어가기 전에 그 길이를 세어 보세요.", + "workspaceFirstDurationMissing": "오늘 먼저 바뀌는 구간 길이는 아직 귀로 확인이 필요합니다. 처음 두 구간의 길이를 확인한 뒤 카운트하세요.", + "sectionDurationNextActionChange": "{sectionLabel} 들어가기 전에 이 새 길이를 세어 보세요.", + "sectionDurationNextActionHold": "{sectionLabel} 들어가기 전에 이 길이를 세어 보세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From 0a8dca3f700e9188404c301ee98e859db955a574 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:23:07 +0900 Subject: [PATCH 2/8] test(workspace): fail closed across timing gaps --- .../workspace/firstDurationChange.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.test.ts b/apps/desktop/src/features/workspace/firstDurationChange.test.ts index 4fa723a5d..b7ac39201 100644 --- a/apps/desktop/src/features/workspace/firstDurationChange.test.ts +++ b/apps/desktop/src/features/workspace/firstDurationChange.test.ts @@ -106,6 +106,23 @@ 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("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 9cd1b7e432df91aab90d7b66fbdd370c14ebfe71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:23:35 +0900 Subject: [PATCH 3/8] fix(workspace): fail closed across named timing gaps --- .../src/features/workspace/firstDurationChange.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.ts b/apps/desktop/src/features/workspace/firstDurationChange.ts index 56b439797..3cb0b73a9 100644 --- a/apps/desktop/src/features/workspace/firstDurationChange.ts +++ b/apps/desktop/src/features/workspace/firstDurationChange.ts @@ -56,7 +56,9 @@ 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 + * 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. 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 @@ -91,10 +93,13 @@ 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; } + const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); + if (durationSeconds === null) { + return null; + } namedDurations.push({ sectionId, sectionLabel, From 9529e09b0bb0e951043524f64085399f8e31d307 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:24:05 +0900 Subject: [PATCH 4/8] refactor(workspace): preserve legacy timing provenance in memory --- .../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 71c6cefc721a1e490647a65e800e362e32f3a928 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:24:38 +0900 Subject: [PATCH 5/8] test(workspace): reject legacy synthetic timing guidance --- .../workspace/firstDurationChange.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.test.ts b/apps/desktop/src/features/workspace/firstDurationChange.test.ts index b7ac39201..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, @@ -123,6 +124,32 @@ describe("firstDurationChange", () => { 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 388d37fbea6da7ea2edac3e12428c7443f227c11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:37:34 +0900 Subject: [PATCH 6/8] fix(workspace): reject synthetic timing guidance --- .../features/workspace/firstDurationChange.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDurationChange.ts b/apps/desktop/src/features/workspace/firstDurationChange.ts index 3cb0b73a9..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. */ @@ -58,12 +59,13 @@ export function sectionDurationSeconds(timeRangeValue: unknown): number | null { * Walks labeled sections in form order and returns the first consecutive pair * whose integer duration differs. Unlabeled compatibility noise is ignored, * while a named section without valid timing fails closed so unknown timing can - * never bridge two measured sections. 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. + * 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; @@ -96,6 +98,9 @@ export function firstDurationChange(song: RehearsalSong): FirstDurationChange | if (!sectionId || !sectionLabel) { continue; } + if (hasSyntheticSectionTimeRange(sectionValue)) { + return null; + } const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); if (durationSeconds === null) { return null; From 0c84bae6353c5dabf4dbba6aba6cc700671e2bdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:38:18 +0900 Subject: [PATCH 7/8] fix(project): preserve legacy timing provenance on 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 8f468438825838ad43b6bc57040e0c91e55769ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:38:53 +0900 Subject: [PATCH 8/8] test(project): preserve migrated timing evidence on load --- 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());