diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..76872c82a 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 offers a next action that opens that part on the map. - 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..da96a1407 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, offering a control that opens that part on the map, and naming the next instrument check - 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..262c446a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,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 playable range on the ready rehearsal map, offer Open {role} on the map when the full-band view is showing that part, and tell the player to check that span on their instrument before the 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..12d91ee9c 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, offers a control that opens that part on the map, and tells the player to check that span on their instrument. `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..7c609d9bc 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -151,6 +151,7 @@ describe("Workspace", () => { expect(callout).toHaveTextContent( "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." ); + expect(screen.getByRole("button", { name: "Open Bass Guitar on the map" })).toBeTruthy(); }); it("asks for an ear check when the selected part has no named span", () => { @@ -167,6 +168,7 @@ describe("Workspace", () => { expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." ); + expect(screen.queryByRole("button", { name: /Open .+ on the map/ })).toBeNull(); }); it("limits the range callout to the selected role", () => { @@ -179,6 +181,7 @@ describe("Workspace", () => { expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( "Lead Vocal sits G#3–C#5 in verse. Hear that clash on your instrument before the verse." ); + expect(screen.queryByRole("button", { name: "Open Lead Vocal on the map" })).toBeNull(); }); it("asks the player to check a named span when no clash is present", () => { @@ -196,6 +199,28 @@ describe("Workspace", () => { ); }); + it("opens the named first-range part from the range card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar on the map" })); + + expect(screen.getByText("Stem Player")).toBeTruthy(); + expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Open Bass Guitar on the map" })).toBeNull(); + }); + + it("localizes the first-range open-part action", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "지도에서 Bass Guitar 열기" })).toBeTruthy(); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..6c29d0082 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,7 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillRangeCopy, firstRangeOpenPart, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +163,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstRangePart = firstRangeOpenPart(firstRange, activeRole, allRoles); + const firstRangeOpenCopy = firstRangePart + ? fillRangeCopy(t("workspaceFirstRangeOpenPart"), { roleName: firstRangePart.roleName }) + : null; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -308,6 +312,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp >

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+ {firstRangePart && firstRangeOpenCopy ? ( + + ) : null}
diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts index 643935954..7cef970bc 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -1,6 +1,12 @@ import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; -import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; +import { + fillRangeCopy, + firstRangeOpenPart, + firstRangeSqueeze, + meaningfulRangeText, + playableRange +} from "./firstRangeSqueeze"; function blankRoleRange(song: RehearsalSong): RehearsalSong { return { @@ -51,6 +57,7 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong()); expect(squeeze).toEqual({ + roleId: "bass-guitar", sectionLabel: "verse", roleName: "Bass Guitar", lowestNote: "C#2", @@ -67,6 +74,7 @@ describe("firstRangeSqueeze", () => { })); expect(firstRangeSqueeze(song)).toEqual({ + roleId: "bass-guitar", sectionLabel: "verse", roleName: "Bass Guitar", lowestNote: "C#2", @@ -114,6 +122,7 @@ describe("firstRangeSqueeze", () => { expect( firstRangeSqueeze({ ...song, sections: [malformedSection] } as unknown as RehearsalSong) ).toEqual({ + roleId: "bass-guitar", sectionLabel: "verse", roleName: "Bass Guitar", lowestNote: "C#2", @@ -126,6 +135,7 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong(), "lead-vocal"); expect(squeeze).toEqual({ + roleId: "lead-vocal", sectionLabel: "verse", roleName: "Lead Vocal", lowestNote: "G#3", @@ -140,6 +150,28 @@ describe("firstRangeSqueeze", () => { }); }); +describe("firstRangeOpenPart", () => { + const squeeze = firstRangeSqueeze(createDemoRehearsalSong()); + const roles = [ + { id: "bass-guitar" }, + { id: "lead-vocal" } + ]; + + it("offers the named part from the full-band map", () => { + expect(firstRangeOpenPart(squeeze, null, roles)?.roleId).toBe("bass-guitar"); + }); + + it("hides the control once that part is already selected", () => { + expect(firstRangeOpenPart(squeeze, "bass-guitar", roles)).toBeNull(); + }); + + it("fails closed when the named part is missing from the switcher", () => { + expect(firstRangeOpenPart(squeeze, null, [{ id: "lead-vocal" }])).toBeNull(); + expect(firstRangeOpenPart(squeeze, null, null as unknown as { id: string }[])).toBeNull(); + expect(firstRangeOpenPart(null, null, roles)).toBeNull(); + }); +}); + describe("fillRangeCopy", () => { it("replaces every token occurrence", () => { expect( diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts index 47270d2a9..ce01cac60 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -2,6 +2,7 @@ import type { RehearsalSong } from "@bandscope/shared-types"; /** Tonight's first named playable span on the rehearsal map. */ export type FirstRangeSqueeze = { + roleId: string; sectionLabel: string; roleName: string; lowestNote: string; @@ -143,6 +144,7 @@ export function firstRangeSqueeze( } const candidate: FirstRangeSqueeze = { + roleId, sectionLabel, roleName, ...range, @@ -162,6 +164,30 @@ export function firstRangeSqueeze( return fallback; } +/** + * Offer the named first-range part only when the map is not already on it. + * + * Fail closed when the squeeze is missing, the role id is unknown to the + * current switcher, or that part is already selected. The instrument-check + * sentence remains the next action once the part is open. + */ +export function firstRangeOpenPart( + squeeze: FirstRangeSqueeze | null, + activeRole: string | null, + roles: ReadonlyArray<{ id: string }> +): FirstRangeSqueeze | null { + if (!squeeze || !Array.isArray(roles)) { + return null; + } + if (activeRole === squeeze.roleId) { + return null; + } + if (!roles.some((role) => role.id === squeeze.roleId)) { + return null; + } + return squeeze; +} + /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ export function fillRangeCopy(template: string, values: Record): string { return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (placeholder, token: string) => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..d174e494c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,7 @@ "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.", + "workspaceFirstRangeOpenPart": "Open {roleName} on the map", "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..4aded3dd5 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,7 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstRangeOpenPart": "지도에서 {roleName} 열기", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." }