diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..77e8dbbb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and the first count-out so the band leaves the section together. - 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..45b3791ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and the first count-out so the band leaves the section together - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..9781f44ef 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 count-out on the ready rehearsal map and tell the player to leave the section together. - 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..ad14b2677 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, the next instrument check, and the first count-out so the band leaves the section together. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..840f83b5b 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,47 @@ describe("Workspace", () => { ); }); + it("names tonight's first count-out and the next leave-together", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-count-out"); + expect(callout).toHaveTextContent("Tonight's first count-out"); + expect(callout).toHaveTextContent("verse ends at 0:30. Count out that last bar before you leave the verse."); + }); + + it("keeps the selected active part on tonight's first count-out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-count-out")).toHaveTextContent( + "verse ends at 0:30. Count out that last bar before you leave the verse." + ); + }); + + it("asks the player to confirm the end when the selected part sits out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0] = { + ...song.sections[0]!, + partGraph: song.sections[0]!.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" })); + + expect(screen.getByTestId("first-count-out")).toHaveTextContent( + "Tonight's first count-out still needs an end time. Confirm where the first section ends before you leave it." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -325,5 +366,6 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByText("오늘 먼저 맞출 끝 박")).toBeTruthy(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..3f732ba56 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 { firstCountOut } from "./firstCountOut"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const namedCountOut = useMemo(() => firstCountOut(song, activeRole), [activeRole, song]); + const firstCountOutCopy = namedCountOut + ? fillRangeCopy(t("workspaceFirstCountOutNamed"), { + sectionLabel: namedCountOut.sectionLabel, + endTime: namedCountOut.endTime + }) + : t("workspaceFirstCountOutMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +317,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstCountOutTitle")}

+

{firstCountOutCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstCountOut.test.ts b/apps/desktop/src/features/workspace/firstCountOut.test.ts new file mode 100644 index 000000000..daf37a098 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountOut.test.ts @@ -0,0 +1,110 @@ +import { + createDemoRehearsalSong, + parseRehearsalSong, + type RehearsalSong +} from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstCountOut, formatCountOutTime } from "./firstCountOut"; + +function withSectionTime( + song: RehearsalSong, + timeRange: RehearsalSong["sections"][number]["timeRange"], + label: string = song.sections[0]!.label +): RehearsalSong { + return { + ...song, + sections: song.sections.map((section, index) => + index === 0 + ? { ...section, label: label as RehearsalSong["sections"][number]["label"], timeRange } + : section + ) + }; +} + +describe("formatCountOutTime", () => { + it("formats bounded whole seconds as m:ss", () => { + expect(formatCountOutTime(0)).toBe("0:00"); + expect(formatCountOutTime(30)).toBe("0:30"); + expect(formatCountOutTime(30.9)).toBe("0:30"); + expect(formatCountOutTime(90)).toBe("1:30"); + }); + + it("fails closed on non-finite, negative, non-number, or oversized ends", () => { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, -1, "30", 4_294_967_296]) { + expect(formatCountOutTime(value)).toBeNull(); + } + }); +}); + +describe("firstCountOut", () => { + it("names the first section end from existing time-range evidence", () => { + expect(firstCountOut(createDemoRehearsalSong())).toEqual({ + sectionLabel: "verse", + endTime: "0:30" + }); + }); + + it("does not turn migrated legacy timing placeholders into count-out evidence", () => { + const seed = createDemoRehearsalSong(); + const legacySong = { + ...seed, + sections: seed.sections.map(({ timeRange: _timeRange, ...section }) => section) + }; + const migrated = parseRehearsalSong(legacySong); + + expect(migrated.sections[0]!.timeRange).toEqual({ start: 0, end: 1 }); + expect(firstCountOut(migrated)).toBeNull(); + }); + + it("skips blank labels until a named section end exists", () => { + const song = withSectionTime(createDemoRehearsalSong(), { start: 10, end: 30 }, "none"); + expect(firstCountOut(song)).toBeNull(); + }); + + it("skips inverted, inherited, or malformed time ranges", () => { + const inherited = Object.create({ start: 10, end: 30 }) as RehearsalSong["sections"][number]["timeRange"]; + expect(firstCountOut(withSectionTime(createDemoRehearsalSong(), { start: 30, end: 10 }))).toBeNull(); + expect(firstCountOut(withSectionTime(createDemoRehearsalSong(), inherited))).toBeNull(); + expect( + firstCountOut(withSectionTime(createDemoRehearsalSong(), { start: Number.NaN, end: 30 })) + ).toBeNull(); + }); + + it("keeps the selected active part on tonight's first count-out", () => { + expect(firstCountOut(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + sectionLabel: "verse", + endTime: "0:30" + }); + }); + + it("returns null when the selected part sits out of the only named end", () => { + const song = createDemoRehearsalSong(); + song.sections[0] = { + ...song.sections[0]!, + partGraph: song.sections[0]!.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }; + + expect(firstCountOut(song, "keys-right")).toBeNull(); + expect(firstCountOut(song, "missing-role")).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect(firstCountOut(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); +}); + +describe("count-out copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy("{sectionLabel} ends at {endTime}.", { + sectionLabel: "verse {endTime}", + endTime: "0:30" + }) + ).toBe("verse {endTime} ends at 0:30."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCountOut.ts b/apps/desktop/src/features/workspace/firstCountOut.ts new file mode 100644 index 000000000..c1095c8c9 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountOut.ts @@ -0,0 +1,137 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named section end the band should count out together. */ +export type FirstCountOut = { + sectionLabel: string; + endTime: string; +}; + +const MAX_SECTION_TIME_SECONDS = 4_294_967_295; + +/** 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); +} + +/** + * Format a bounded section end as `m:ss`, or fail closed. + * + * Rejects non-numbers, non-finite values, negatives, and ends above the + * shared section-time ceiling so a malformed payload cannot become a + * rehearsal count-out. + */ +export function formatCountOutTime(totalSeconds: unknown): string | null { + if (typeof totalSeconds !== "number" || !Number.isFinite(totalSeconds) || totalSeconds < 0) { + return null; + } + if (totalSeconds > MAX_SECTION_TIME_SECONDS) { + return null; + } + const whole = Math.floor(totalSeconds); + const minutes = Math.floor(whole / 60); + const seconds = (whole % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */ +function isOwnActive(value: Record): boolean { + return Object.prototype.hasOwnProperty.call(value, "is_active") && value.is_active === true; +} + +/** + * Return whether the selected part is on this section's count-out. + * + * Prefers own-property `partGraph` activity so a rest is not sold as a + * leave-together cue. Falls back to a named role on the section when no + * graph node exists. + */ +function sectionIncludesActiveRole( + sectionValue: Record, + activeRole: string +): boolean { + if (Array.isArray(sectionValue.partGraph)) { + let sawSelectedNode = false; + for (const nodeValue of sectionValue.partGraph) { + if ( + !isRuntimeObject(nodeValue) || + !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") + ) { + continue; + } + const roleId = meaningfulRangeText(nodeValue.role_id); + if (roleId !== activeRole) { + continue; + } + sawSelectedNode = true; + if (isOwnActive(nodeValue)) { + return true; + } + } + if (sawSelectedNode) { + return false; + } + } + + if (!Array.isArray(sectionValue.roles)) { + return false; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { + continue; + } + if (meaningfulRangeText(roleValue.id) === activeRole) { + return true; + } + } + return false; +} + +/** + * Pick the first named section end a player should count out before leaving. + * + * Uses existing `timeRange.end` authority already produced by analysis. + * Blank labels, inverted spans, inherited time fields, inactive selected + * parts, and malformed roots fail closed. When a role is selected, only a + * section that includes that part as active is named. + */ +export function firstCountOut( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstCountOut | null { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return null; + } + + for (const sectionValue of song.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + if (activeRole && !sectionIncludesActiveRole(sectionValue, activeRole)) { + continue; + } + if ( + !isRuntimeObject(sectionValue.timeRange) || + !Object.prototype.hasOwnProperty.call(sectionValue.timeRange, "start") || + !Object.prototype.hasOwnProperty.call(sectionValue.timeRange, "end") + ) { + continue; + } + const start = sectionValue.timeRange.start; + const end = sectionValue.timeRange.end; + if (typeof start !== "number" || !Number.isFinite(start) || start < 0) { + continue; + } + const endTime = formatCountOutTime(end); + if (!endTime || typeof end !== "number" || end < start) { + continue; + } + return { sectionLabel, endTime }; + } + + return null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..f3c88434c 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -60,6 +60,7 @@ describe("i18n", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("workspaceFirstCountOutTitle")).toBe("오늘 먼저 맞출 끝 박"); }); it("falls back to English when a Korean translation is missing", () => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..3c1cf3c11 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,9 @@ "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.", + "workspaceFirstCountOutTitle": "Tonight's first count-out", + "workspaceFirstCountOutNamed": "{sectionLabel} ends at {endTime}. Count out that last bar before you leave the {sectionLabel}.", + "workspaceFirstCountOutMissing": "Tonight's first count-out still needs an end time. Confirm where the first section ends before you leave it.", "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..a9768de50 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,9 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstCountOutTitle": "오늘 먼저 맞출 끝 박", + "workspaceFirstCountOutNamed": "{sectionLabel}은 {endTime}에 끝납니다. {sectionLabel}을 나가기 전에 마지막 마디를 맞춰 보세요.", + "workspaceFirstCountOutMissing": "오늘 먼저 맞출 끝 박은 아직 없습니다. 첫 구간이 어디서 끝나는지 확인하고 나가기 전에 맞춰 보세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..3d8a6ac98 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. - `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy. - `EmptyState` must remain an actionable state card, not a blank placeholder panel. +- Ready `Workspace` names tonight's first playable range and tonight's first count-out so the map enables the next rehearsal action without opening files or export paths. - If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. ## Pattern Backlog diff --git a/docs/doctoring/first-count-out.md b/docs/doctoring/first-count-out.md new file mode 100644 index 000000000..eaf452c0a --- /dev/null +++ b/docs/doctoring/first-count-out.md @@ -0,0 +1,17 @@ +# Tonight's first count-out + +The ready rehearsal map names the first named section end from existing `timeRange.end` evidence. This is the last bar a player should count out so the band leaves together. It is not a count-in, click, chart bar, or Fine. + +## Next action + +- Named: count out that last bar before leaving the named section. +- Missing: confirm where the first section ends before you leave it. + +## Security Notes + +- Untrusted inputs: `RehearsalSong` JSON, section labels, `timeRange.start` / `timeRange.end`, `partGraph` nodes, `is_active`, and role ids from analysis or a reopened project. +- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property finite non-negative section ends at or after the matching start. +- Allowlist: section labels must be meaningful text. Ends must format as `m:ss` at or below the shared section-time ceiling. When a role is selected, only an own-property active `partGraph` node (or a named role when no graph node exists) can own the count-out. +- Safe failure: inherited time fields, inverted spans, inactive selected parts, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing an end. +- Logging/privacy: rejected or accepted times are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstCountOut.test.ts` and the Workspace callout cover the demo verse end, inverted/inherited times, selected-role scoping, sit-out isolation, and literal copy filling.