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..35bd89a8f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDurationChange.test.ts @@ -0,0 +1,240 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { parseRehearsalSongWithTimingEvidence } from "../../lib/rehearsalTimingEvidence"; +import { + fillDurationCopy, + firstDurationChange, + isDurationChangeTarget, + sectionDurationSeconds +} from "./firstDurationChange"; + +function cloneSong(song: RehearsalSong): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ ...role })) + })) + }; +} + +function appendSection( + song: RehearsalSong, + label: string, + timeRange: { start: number; end: number } +): RehearsalSong { + const first = song.sections[0]; + return { + ...song, + sections: [ + ...song.sections, + { + ...first, + id: `${label}-section`, + label, + timeRange, + roles: first.roles.map((role) => ({ ...role, id: `${role.id}-${label}` })) + } + ] + }; +} + +describe("sectionDurationSeconds", () => { + it("returns the positive integer span for a valid time range", () => { + expect(sectionDurationSeconds({ start: 10, end: 30 })).toBe(20); + expect(sectionDurationSeconds({ start: 0, end: 1 })).toBe(1); + }); + + it("fails closed on missing, inverted, fractional, or unbounded ranges", () => { + expect(sectionDurationSeconds(null)).toBeNull(); + expect(sectionDurationSeconds({ start: 30, end: 10 })).toBeNull(); + expect(sectionDurationSeconds({ start: 10, end: 10 })).toBeNull(); + expect(sectionDurationSeconds({ start: 10.5, end: 20 })).toBeNull(); + expect(sectionDurationSeconds({ start: -1, end: 10 })).toBeNull(); + expect(sectionDurationSeconds({ start: Number.NaN, end: 10 })).toBeNull(); + }); +}); + +describe("firstDurationChange", () => { + it("holds the demo song's one named length so the room does not reset the count", () => { + expect(firstDurationChange(createDemoRehearsalSong())).toEqual({ + kind: "same", + fromSectionId: "verse-1", + fromSectionLabel: "verse", + fromDuration: "20", + toSectionId: "verse-1", + toSectionLabel: "verse", + toDuration: "20" + }); + }); + + it("names the first consecutive length change in form order", () => { + const song = appendSection( + appendSection(createDemoRehearsalSong(), "pre-chorus", { start: 30, end: 50 }), + "chorus", + { start: 50, end: 82 } + ); + + expect(firstDurationChange(song)).toEqual({ + kind: "change", + fromSectionId: "pre-chorus-section", + fromSectionLabel: "pre-chorus", + fromDuration: "20", + toSectionId: "chorus-section", + toSectionLabel: "chorus", + toDuration: "32" + }); + }); + + it("skips unlabeled or invalid-duration sections until a named pair exists", () => { + const song = cloneSong(createDemoRehearsalSong()); + song.sections[0] = { + ...song.sections[0]!, + label: " ", + timeRange: { start: 10, end: 10 } + }; + const withChorus = appendSection(song, "chorus", { start: 30, end: 46 }); + const withBridge = appendSection(withChorus, "bridge", { start: 46, end: 78 }); + + expect(firstDurationChange(withBridge)).toEqual({ + kind: "change", + fromSectionId: "chorus-section", + fromSectionLabel: "chorus", + fromDuration: "16", + toSectionId: "bridge-section", + toSectionLabel: "bridge", + toDuration: "32" + }); + }); + + it("fails closed instead of bridging across a named section with missing timing evidence", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } }, + { + ...verse, + id: "chorus-gap", + label: "chorus", + timeRange: null + } as unknown as typeof verse, + { ...verse, id: "bridge-a", label: "bridge", timeRange: { start: 16, end: 48 } } + ]; + + expect(firstDurationChange(song)).toBeNull(); + }); + + it("does not present legacy migration placeholders as measured section lengths", () => { + const legacySong = structuredClone(createDemoRehearsalSong()) as unknown as { + sections: Array>; + }; + delete legacySong.sections[0]!.timeRange; + + const parsedLegacySong = parseRehearsalSongWithTimingEvidence(legacySong); + expect(parsedLegacySong.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); + expect(firstDurationChange(parsedLegacySong)).toBeNull(); + }); + + it("keeps an explicit measured one-second section eligible for count-in guidance", () => { + const measuredSong = structuredClone(createDemoRehearsalSong()); + measuredSong.sections[0]!.timeRange = { start: 0, end: 1 }; + + expect(firstDurationChange(parseRehearsalSongWithTimingEvidence(measuredSong))).toEqual({ + kind: "same", + fromSectionId: "verse-1", + fromSectionLabel: "verse", + fromDuration: "1", + toSectionId: "verse-1", + toSectionLabel: "verse", + toDuration: "1" + }); + }); + + it("fails closed on malformed runtime roots and members", () => { + expect(firstDurationChange(null as unknown as RehearsalSong)).toBeNull(); + expect(firstDurationChange({ sections: "nope" } as unknown as RehearsalSong)).toBeNull(); + expect( + firstDurationChange({ + ...createDemoRehearsalSong(), + sections: [null, "skip", { label: "verse" }] + } as unknown as RehearsalSong) + ).toBeNull(); + }); + + it("fails closed when repeated section ids cannot identify one destination card", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "duplicate", timeRange: { start: 0, end: 16 } }, + { ...verse, id: "duplicate", timeRange: { start: 16, end: 48 } } + ]; + + expect(firstDurationChange(song)).toBeNull(); + }); + + it("rejects a repeated section id even when one occurrence has no eligible duration evidence", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "duplicate", label: " ", timeRange: { start: 10, end: 10 } }, + { ...verse, id: "duplicate", label: "chorus", timeRange: { start: 30, end: 46 } } + ]; + + expect(firstDurationChange(song)).toBeNull(); + }); + + it("validates later section ids before returning an earlier duration transition", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } }, + { ...verse, id: "chorus-a", label: "chorus", timeRange: { start: 16, end: 48 } }, + { ...verse, id: "verse-a", label: "outro", timeRange: { start: 48, end: 64 } } + ]; + + expect(firstDurationChange(song)).toBeNull(); + }); +}); + +describe("isDurationChangeTarget", () => { + it("marks only the arrival section identity as the next-action card", () => { + const change: ReturnType = { + kind: "change", + fromSectionId: "verse-1", + fromSectionLabel: "verse", + fromDuration: "20", + toSectionId: "chorus-1", + toSectionLabel: "chorus", + toDuration: "32" + }; + + expect(isDurationChangeTarget(change, "chorus-1")).toBe(true); + expect(isDurationChangeTarget(change, " verse-1 ")).toBe(false); + expect(isDurationChangeTarget(change, " ")).toBe(false); + }); + + it("uses stable section identity when repeated labels would otherwise mark multiple cards", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", timeRange: { start: 0, end: 16 } }, + { ...verse, id: "verse-b", label: "verse", timeRange: { start: 16, end: 48 } } + ]; + + const change = firstDurationChange(song); + expect(change?.kind).toBe("change"); + expect(isDurationChangeTarget(change!, "verse-a")).toBe(false); + expect(isDurationChangeTarget(change!, "verse-b")).toBe(true); + }); +}); + +describe("fillDurationCopy", () => { + it("keeps rehearsal duration values literal while filling trusted tokens", () => { + expect( + fillDurationCopy("Count {toDuration} before the {toSection}.", { + toDuration: "32 {toSection}", + toSection: "chorus" + }) + ).toBe("Count 32 {toSection} before the chorus."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDurationChange.ts b/apps/desktop/src/features/workspace/firstDurationChange.ts new file mode 100644 index 000000000..f38edb5b1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDurationChange.ts @@ -0,0 +1,159 @@ +import { MAX_SECTION_TIME_SECONDS, type RehearsalSong } from "@bandscope/shared-types"; +import { hasSyntheticSectionTimeRange } from "../../lib/rehearsalTimingEvidence"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named section-length change, or a same-length hold through the form. */ +export type FirstDurationChange = { + kind: "change" | "same"; + fromSectionId: string; + fromSectionLabel: string; + fromDuration: string; + toSectionId: string; + toSectionLabel: string; + toDuration: string; +}; + +type NamedDuration = { + sectionId: string; + sectionLabel: string; + duration: string; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Return a positive integer duration in seconds from untrusted time-range evidence. + * + * Mirrors the shared rehearsal-song contract: start and end must be finite + * integers, start must be in range, and end must be strictly after start. + */ +export function sectionDurationSeconds(timeRangeValue: unknown): number | null { + if (!isRuntimeObject(timeRangeValue)) { + return null; + } + const start = timeRangeValue.start; + const end = timeRangeValue.end; + if ( + typeof start !== "number" || + typeof end !== "number" || + !Number.isFinite(start) || + !Number.isFinite(end) || + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return end - start; +} + +/** + * Pick the first named section-length change a player should count in before the next section. + * + * Walks labeled sections in form order and returns the first consecutive pair + * whose integer duration differs. Unlabeled compatibility noise is ignored, + * while a named section without valid timing fails closed so unknown timing can + * never bridge two measured sections. Legacy compatibility placeholders are + * also rejected so migration data cannot become measured rehearsal guidance. + * When every named section holds the same length, the result is a same-length + * hold so the room does not reset the count. Stable section ids, rather than + * display labels, own roadmap targeting. All meaningful section ids are + * validated for uniqueness before duration evidence is derived so an + * ineligible or later duplicate cannot create ambiguous cards. + */ +export function firstDurationChange(song: RehearsalSong): FirstDurationChange | null { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const seenSectionIds = new Set(); + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + if (!sectionId) { + continue; + } + if (seenSectionIds.has(sectionId)) { + return null; + } + seenSectionIds.add(sectionId); + } + + const namedDurations: NamedDuration[] = []; + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionId || !sectionLabel) { + continue; + } + if (hasSyntheticSectionTimeRange(sectionValue)) { + return null; + } + const durationSeconds = sectionDurationSeconds(sectionValue.timeRange); + if (durationSeconds === null) { + return null; + } + namedDurations.push({ + sectionId, + sectionLabel, + duration: String(durationSeconds) + }); + } + + if (namedDurations.length === 0) { + return null; + } + + const first = namedDurations[0]; + if (!first) { + return null; + } + for (let index = 1; index < namedDurations.length; index += 1) { + const previous = namedDurations[index - 1]; + const current = namedDurations[index]; + if (previous.duration !== current.duration) { + return { + kind: "change", + fromSectionId: previous.sectionId, + fromSectionLabel: previous.sectionLabel, + fromDuration: previous.duration, + toSectionId: current.sectionId, + toSectionLabel: current.sectionLabel, + toDuration: current.duration + }; + } + } + + return { + kind: "same", + fromSectionId: first.sectionId, + fromSectionLabel: first.sectionLabel, + fromDuration: first.duration, + toSectionId: first.sectionId, + toSectionLabel: first.sectionLabel, + toDuration: first.duration + }; +} + +/** Fill trusted `{token}` placeholders for duration-change rehearsal copy. */ +export function fillDurationCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} + +/** True when this stable section identity is the map card owning the next duration action. */ +export function isDurationChangeTarget(change: FirstDurationChange, sectionId: string): boolean { + const identity = meaningfulRangeText(sectionId); + return Boolean(identity) && identity === change.toSectionId; +} diff --git a/apps/desktop/src/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()); 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); } 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; +} 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} 들어가기 전에 이 음역을 악기로 확인해 보세요." }