diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..14898c5a5 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 download of tonight's first-action handoff; when a valid named playable range exists the handoff JSON leads with that structured action, and otherwise it omits `firstAction` rather than inventing one. - 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..7906e3cfc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,10 +82,10 @@ 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 a download of tonight's first-action handoff - 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 + - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form, with the metadata handoff JSON leading with tonight's first playable-range action ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..d8bcde620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Lead the metadata handoff JSON with tonight's first playable-range action and name that download on the rehearsal map. - 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. - 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..81ce5c424 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 a download of tonight's first-action handoff. `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/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..47b9b85da 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -360,9 +360,7 @@ describe("App", () => { }); it("short-circuits confidence evaluation when encountering a low confidence section", async () => { - const loadedProject = succeededResult().result; // medium is first - // Add low and high sections. High shouldn't matter since low is lowest. - // And low will trigger the early break in the loop. + const loadedProject = succeededResult().result; loadedProject.sections.push( { ...loadedProject.sections[0], @@ -923,7 +921,7 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy()); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); @@ -1264,7 +1262,6 @@ describe("App", () => { expect(tauriInvoke).not.toHaveBeenCalled(); }); - it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); @@ -1294,7 +1291,6 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /open project/i })); - // Should not show error, should remain in empty state await waitFor(() => { expect(mockLoadProject).toHaveBeenCalledTimes(1); }); @@ -1359,7 +1355,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1367,7 +1362,6 @@ describe("App", () => { mockSaveProject.mockResolvedValueOnce(undefined); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1379,7 +1373,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1387,7 +1380,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce(new Error("Permission denied")); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1399,7 +1391,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1407,7 +1398,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce(new Error("User cancelled")); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1422,7 +1412,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1430,7 +1419,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce("Disk full"); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1468,7 +1456,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1476,7 +1463,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce("User cancelled"); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1491,19 +1477,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - // Mock prompt to simulate user entering a new chord const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); - // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit) fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]); - // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated) await waitFor(() => { expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0); }); @@ -1552,7 +1534,6 @@ describe("App", () => { }); }); - it("renders Settings and Help as focusable aria-disabled controls", () => { render(); const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); @@ -1589,8 +1570,6 @@ describe("App", () => { fireEvent.click(scoreButton); expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); - // Projects opened from a .bscope file have no live workspace, so score - // storage is gated behind the active-project notice. expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); @@ -1604,9 +1583,6 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); - // The compact nav is a separate rendered bar (shown on small viewports) with - // its own set of buttons; exercise it directly so the mobile navigation path - // is covered, not just the sidebar one. const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i }); expect(compactScoreButton).toBeEnabled(); diff --git a/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx b/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx new file mode 100644 index 000000000..f3cbef497 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; +const originalCreateObjectUrl = URL.createObjectURL; +const originalRevokeObjectUrl = URL.revokeObjectURL; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace handoff export contract", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.restoreAllMocks(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: originalCreateObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectUrl + }); + }); + + it("keeps the full-band handoff lead stable when the UI role changes", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const createObjectUrl = vi.fn(() => "blob:full-band-handoff"); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn() + }); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-range handoff" })); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const payload = JSON.parse(await blob.text()) as { + firstAction?: { roleId?: string; roleName?: string }; + sections?: Array<{ roleBuckets?: Array<{ name?: string }> }>; + }; + + expect(payload.firstAction?.roleId).toBe("bass-guitar"); + expect(payload.firstAction?.roleName).toBe("Bass Guitar"); + expect(payload.sections?.[0]?.roleBuckets?.map((role) => role.name)).toEqual( + expect.arrayContaining(["Bass Guitar", "Lead Vocal"]) + ); + }); + + it("names the Korean download as the first playable-range share file", () => { + setNavigatorLanguage("ko-KR"); + render(); + + expect(screen.getByRole("button", { name: "오늘 첫 연주 구간 공유 파일 다운로드" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..6ec60049a 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,87 @@ describe("Workspace", () => { ); }); + it("names tonight's first-range handoff download and leads the file with that action", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const createObjectUrl = vi.fn(() => "blob:handoff"); + const revokeObjectUrl = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectUrl + }); + + render(); + + const download = screen.getByRole("button", { name: "Download tonight's first-range handoff" }); + fireEvent.click(download); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const payload = JSON.parse(await blob.text()); + expect(Object.keys(payload)).toEqual([ + "artifactKind", + "artifactVersion", + "createdAt", + "workspace", + "song", + "firstAction", + "sections", + "sourceAssets" + ]); + expect(payload.firstAction).toEqual({ + sectionId: "verse-1", + sectionLabel: "verse", + roleId: "bass-guitar", + roleName: "Bass Guitar", + lowestNote: "C#2", + highestNote: "E3", + clash: true + }); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff"); + }); + + it("does not invent a first-action handoff lead when the first range still needs an ear check", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + const createObjectUrl = vi.fn(() => "blob:handoff-missing"); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn() + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-range handoff" })); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const payload = JSON.parse(await blob.text()); + expect(payload.firstAction).toBeUndefined(); + expect(Object.keys(payload)).toEqual([ + "artifactKind", + "artifactVersion", + "createdAt", + "workspace", + "song", + "sections", + "sourceAssets" + ]); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -249,7 +330,7 @@ describe("Workspace", () => { }); render(); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); @@ -278,7 +359,7 @@ describe("Workspace", () => { }); render(); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); @@ -325,5 +406,6 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "오늘 첫 연주 구간 공유 파일 다운로드" })).toBeTruthy(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..44ea89f1a 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 { firstHandoffAction } from "./firstHandoffAction"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -244,7 +245,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const json = generateMetadataHandoffJson(song, { sourceBootstrap: parsedSourceBootstrap, workspaceId: song.id, - workspaceTitle: song.title + workspaceTitle: song.title, + firstAction: firstHandoffAction(song) }); downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_handoff.json`); }; @@ -294,7 +296,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp className="min-h-10 border-teal-300/25 bg-teal-300/10 font-semibold text-teal-50 shadow-sm hover:bg-teal-300/20 hover:text-white" >