diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..fb69d1288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- After a song is selected, customer-facing copy must enable the next rehearsal action (pick tonight's part and analyze). Do not leave a selected source on the empty-file prompt. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..c79b14659 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- After local or YouTube source admission, the workspace first-run card names analyze as the next action and records an explicit role focus before the job starts. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..86ed67a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After a local song or admitted YouTube source is chosen, the workspace names Analyze this song as the next action and lets the player pick tonight's part before analysis starts. - 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 82c2c704a..52a011df6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +After a local or YouTube source is selected, the workspace must show a first-run card that starts analysis for the chosen part rather than repeating the empty-file prompt. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..0f7f83b9c 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1616,4 +1616,98 @@ describe("App", () => { expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); + + it("turns a selected local song into a first-run analyze card", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Analyze this song" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "Whole band" })).toHaveAttribute("aria-checked", "true"); + expect(screen.queryByText("/Users/test/Music/late-night-set.wav")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Ready to Analyze" })).toBeNull(); + }); + + it("starts analysis from the first-run card with the chosen part", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-first-run", + state: "queued", + progressLabel: "Queued for analysis" + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("radio", { name: "Lead vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Analyze this song" })); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["lead-vocal"] + } + }); + }); + }); + + it("lets the first-run card choose a different local file", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(bootstrapResponse({ + projectId: "project-2", + source: { + sourcePath: "/Users/test/Music/next-song.wav", + fileName: "next-song.wav", + extension: "wav", + fileSizeBytes: 2048000 + } + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a different file" })); + + await waitFor(() => { + expect(screen.getByText(/next-song\.wav/i)).toBeTruthy(); + }); + expect(screen.getByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + expect(screen.queryByText("/Users/test/Music/next-song.wav")).toBeNull(); + }); + + it("keeps header start-analysis on the whole-band role until a part is chosen", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-header-role", + state: "queued", + progressLabel: "Queued for analysis" + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /^Start analysis$/i })); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + } + }); + }); + }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..5f2ec53c0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -46,7 +46,8 @@ import { import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; -import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { EmptyState, ErrorState, FirstRunState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { roleFocusForFirstRun, type FirstRunRoleId } from "./features/workspace/firstRunRoles"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; @@ -258,6 +259,7 @@ export function App() { const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); + const [firstRunRoleId, setFirstRunRoleId] = useState("whole-band"); const [activeAnalysisBootstrap, setActiveAnalysisBootstrap] = useState(null); const [selectionError, setSelectionError] = useState(null); const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); @@ -273,7 +275,7 @@ export function App() { sourceKind: "local_audio", projectId: selectedBootstrap.projectId, sourceLabel: selectedBootstrap.source.fileName, - roleFocus: defaultRequest.roleFocus + roleFocus: roleFocusForFirstRun(firstRunRoleId) } : defaultRequest; @@ -420,6 +422,7 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setFirstRunRoleId("whole-band"); return; } @@ -451,6 +454,7 @@ export function App() { const selection = await importYoutubeUrl(normalizedUrl); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setFirstRunRoleId("whole-band"); setYoutubeUrl(""); } else { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); @@ -514,6 +518,22 @@ export function App() { if (jobResult) { return ; } + if (selectedBootstrap) { + return ( + { + void handleStartAnalysis(); + }} + onChooseDifferentFile={() => { + void handleChooseLocalAudio(); + }} + analysisDisabled={analysisInFlight || isStarting || isImporting} + /> + ); + } return ; }; diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..469a903a3 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, FirstRunState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +describe("WorkspaceStates first-run card", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps the empty card text-only until a song is selected", () => { + render(); + expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Analyze this song" })).toBeNull(); + }); + + it("names analyze as the next action after a song is selected", () => { + const onSelectRole = vi.fn(); + const onStartAnalysis = vi.fn(); + const onChooseDifferentFile = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("radio", { name: "Lead vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Analyze this song" })); + fireEvent.click(screen.getByRole("button", { name: "Choose a different file" })); + + expect(onSelectRole).toHaveBeenCalledWith("lead-vocal"); + expect(onStartAnalysis).toHaveBeenCalledTimes(1); + expect(onChooseDifferentFile).toHaveBeenCalledTimes(1); + expect(screen.queryByText("/Users/test/Music/rehearsal-take.wav")).toBeNull(); + }); + + it("shows the selected song basename without exposing local path segments", () => { + render( + + ); + + expect(screen.queryByText(/\/Users\/test/)).toBeNull(); + expect(screen.getByText("Selected song")).toBeTruthy(); + expect(screen.getByText("late-night-set.wav")).toBeTruthy(); + expect(document.querySelector('[data-selected-audio="late-night-set.wav"]')).toBeTruthy(); + }); + + it("uses roving focus and arrow keys for the role radiogroup", () => { + const onSelectRole = vi.fn(); + render( + + ); + + const wholeBand = screen.getByRole("radio", { name: "Whole band" }); + const leadVocal = screen.getByRole("radio", { name: "Lead vocal" }); + expect(wholeBand).toHaveAttribute("tabindex", "0"); + expect(leadVocal).toHaveAttribute("tabindex", "-1"); + + wholeBand.focus(); + fireEvent.keyDown(wholeBand, { key: "ArrowRight" }); + + expect(onSelectRole).toHaveBeenCalledWith("lead-vocal"); + expect(document.activeElement).toBe(leadVocal); + }); + + it("localizes the first-run next-action copy", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오늘 합주할 곡이 준비됐어요" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "이 곡 분석하기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "리드 보컬" })).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..75eacf076 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,54 @@ -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import type { KeyboardEvent } from "react"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Play, Upload } from "lucide-react"; +import { + FIRST_RUN_ROLE_OPTIONS, + displaySelectedAudioName, + type FirstRunRoleId +} from "./firstRunRoles"; + +const FIRST_RUN_ROLE_LABELS = { + "whole-band": "firstRunRoleWholeBand", + "lead-vocal": "firstRunRoleLeadVocal", + "bass-guitar": "firstRunRoleBass", + "keys-right": "firstRunRoleKeys" +} as const satisfies Record; + +/** Move a first-run role radio using the ARIA radiogroup keyboard pattern. */ +function handleRoleNavigation( + event: KeyboardEvent, + currentIndex: number, + onSelectRole: (roleId: FirstRunRoleId) => void +): void { + const lastIndex = FIRST_RUN_ROLE_OPTIONS.length - 1; + let nextIndex: number | null = null; + + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = lastIndex; + } + + if (nextIndex === null) { + return; + } + + event.preventDefault(); + const nextOption = FIRST_RUN_ROLE_OPTIONS[nextIndex]; + if (!nextOption) { + return; + } + onSelectRole(nextOption.id); + const group = event.currentTarget.closest('[role="radiogroup"]'); + const radios = group?.querySelectorAll('[role="radio"]'); + radios?.[nextIndex]?.focus(); +} /** Documented. */ export function EmptyState() { @@ -18,6 +66,103 @@ export function EmptyState() { ); } +/** Next-action handlers after a local or YouTube source is admitted. */ +export interface FirstRunStateProps { + fileName: string; + selectedRoleId: FirstRunRoleId; + onSelectRole: (roleId: FirstRunRoleId) => void; + onStartAnalysis: () => void; + onChooseDifferentFile: () => void; + analysisDisabled?: boolean; +} + +/** Render the first-run card that names analyze as the next rehearsal action. */ +export function FirstRunState({ + fileName, + selectedRoleId, + onSelectRole, + onStartAnalysis, + onChooseDifferentFile, + analysisDisabled = false +}: FirstRunStateProps) { + const t = createTranslator(detectPreferredLocale()); + const safeFileName = displaySelectedAudioName(fileName); + + return ( + + +
+
+

{t("firstRunTitle")}

+

{t("firstRunGuidance")}

+

{t("firstRunLocalFirst")}

+ {safeFileName && ( +

+ {t("firstRunFileLabel")}{" "} + {safeFileName} +

+ )} + +
+ {FIRST_RUN_ROLE_OPTIONS.map((option, optionIndex) => { + const checked = selectedRoleId === option.id; + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} + /** Documented. */ export function LoadingState() { const t = createTranslator(detectPreferredLocale()); diff --git a/apps/desktop/src/features/workspace/firstRunRoles.test.ts b/apps/desktop/src/features/workspace/firstRunRoles.test.ts new file mode 100644 index 000000000..81ab4b8f7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRunRoles.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + FIRST_RUN_WHOLE_BAND_ROLE_FOCUS, + displaySelectedAudioName, + isFirstRunRoleId, + roleFocusForFirstRun +} from "./firstRunRoles"; + +describe("firstRunRoles", () => { + it("admits only the closed first-run role choices", () => { + expect(isFirstRunRoleId("whole-band")).toBe(true); + expect(isFirstRunRoleId("lead-vocal")).toBe(true); + expect(isFirstRunRoleId("bass-guitar")).toBe(true); + expect(isFirstRunRoleId("keys-right")).toBe(true); + expect(isFirstRunRoleId("drums")).toBe(false); + expect(isFirstRunRoleId("")).toBe(false); + }); + + it("maps each admitted role onto existing analysis role IDs", () => { + expect(roleFocusForFirstRun("whole-band")).toEqual([...FIRST_RUN_WHOLE_BAND_ROLE_FOCUS]); + expect(roleFocusForFirstRun("lead-vocal")).toEqual(["lead-vocal"]); + expect(roleFocusForFirstRun("bass-guitar")).toEqual(["bass-guitar"]); + expect(roleFocusForFirstRun("keys-right")).toEqual(["keys-right"]); + }); + + it("never renders local path segments in the selected-song label", () => { + expect(displaySelectedAudioName("rehearsal-take.wav")).toBe("rehearsal-take.wav"); + expect(displaySelectedAudioName("/Users/test/Music/late-night-set.wav")).toBe("late-night-set.wav"); + expect(displaySelectedAudioName("C:\\Users\\test\\Music\\late-night-set.wav")).toBe("late-night-set.wav"); + expect(displaySelectedAudioName("..")).toBe(""); + expect(displaySelectedAudioName("")).toBe(""); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRunRoles.ts b/apps/desktop/src/features/workspace/firstRunRoles.ts new file mode 100644 index 000000000..a1c68dc04 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRunRoles.ts @@ -0,0 +1,51 @@ +/** Role-focus IDs used when first-run analysis covers the whole band. */ +const FIRST_RUN_WHOLE_BAND_ROLE_FOCUS = [ + "bass-guitar", + "keys-right", + "lead-vocal" +] as const; + +/** Closed first-run role choices and their admitted analysis role-focus IDs. */ +const FIRST_RUN_ROLE_OPTIONS = [ + { id: "whole-band", roleFocus: FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }, + { id: "lead-vocal", roleFocus: ["lead-vocal"] }, + { id: "bass-guitar", roleFocus: ["bass-guitar"] }, + { id: "keys-right", roleFocus: ["keys-right"] } +] as const; + +/** Export the immutable first-run role contracts without duplicating declaration authority. */ +export { FIRST_RUN_ROLE_OPTIONS, FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }; + +/** Identifier for an admitted first-run role choice. */ +export type FirstRunRoleId = (typeof FIRST_RUN_ROLE_OPTIONS)[number]["id"]; + +const FIRST_RUN_ROLE_IDS: ReadonlySet = new Set( + FIRST_RUN_ROLE_OPTIONS.map((option) => option.id) +); + +/** Return whether the value is an admitted first-run role choice. */ +export function isFirstRunRoleId(value: string): value is FirstRunRoleId { + return FIRST_RUN_ROLE_IDS.has(value); +} + +/** Resolve the analysis roleFocus for an admitted first-run choice. */ +export function roleFocusForFirstRun(roleId: FirstRunRoleId): string[] { + const option = FIRST_RUN_ROLE_OPTIONS.find((entry) => entry.id === roleId); + return option ? [...option.roleFocus] : [...FIRST_RUN_WHOLE_BAND_ROLE_FOCUS]; +} + +/** Keep only a basename so first-run copy never renders a local path. */ +export function displaySelectedAudioName(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + return ""; + } + + const segments = trimmed.split(/[\\/]+/u).filter((segment) => segment.length > 0); + const base = segments.at(-1) ?? ""; + if (!base || base === "." || base === "..") { + return ""; + } + + return base.replaceAll("\0", "").slice(0, 255); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..1a6cef8b7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -30,6 +30,17 @@ "workspaceReadyToAnalyzeTitle": "Ready to Analyze", "workspaceAnalyzingAudioTitle": "Analyzing Audio", "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "firstRunTitle": "Tonight's song is ready", + "firstRunGuidance": "Pick the part you are playing, then analyze. BandScope will not invent a rehearsal map until that analysis finishes.", + "firstRunLocalFirst": "Your song stays on this device.", + "firstRunFileLabel": "Selected song", + "firstRunRoleLabel": "Tonight's part", + "firstRunRoleWholeBand": "Whole band", + "firstRunRoleLeadVocal": "Lead vocal", + "firstRunRoleBass": "Bass", + "firstRunRoleKeys": "Keys", + "firstRunAnalyze": "Analyze this song", + "firstRunChooseDifferent": "Choose a different file", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..8c62d9384 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -30,6 +30,17 @@ "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "firstRunTitle": "오늘 합주할 곡이 준비됐어요", + "firstRunGuidance": "맡는 파트를 고른 다음 분석을 시작하세요. 분석이 끝나기 전에 BandScope는 합주 지도를 만들어내지 않습니다.", + "firstRunLocalFirst": "선택한 곡은 이 기기에만 남습니다.", + "firstRunFileLabel": "선택한 곡", + "firstRunRoleLabel": "오늘 맡을 파트", + "firstRunRoleWholeBand": "밴드 전체", + "firstRunRoleLeadVocal": "리드 보컬", + "firstRunRoleBass": "베이스", + "firstRunRoleKeys": "키보드", + "firstRunAnalyze": "이 곡 분석하기", + "firstRunChooseDifferent": "다른 파일 선택", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도",