From 050ae81b01950cb2cf4251e3d04544156b366b06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:22:47 +0000 Subject: [PATCH 01/12] feat(workspace): name tonight's tempo, starting chord, and transpose setup After analysis, the cockpit Tempo, Key, and Transpose metrics name a countable BPM, first-entrance chord shape, and transpose/setup plan when those owned fields exist, then Open/Lock scrolls the matching workspace surface. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/src/App.test.tsx | 81 ++++ apps/desktop/src/App.tsx | 138 ++++++- .../src/features/workspace/Workspace.test.tsx | 12 + .../src/features/workspace/Workspace.tsx | 12 +- ...ehearsalMetrics.inherited-metadata.test.ts | 65 +++ .../workspace/rehearsalMetrics.scroll.test.ts | 98 +++++ .../workspace/rehearsalMetrics.test.ts | 380 ++++++++++++++++++ .../features/workspace/rehearsalMetrics.ts | 358 +++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 7 + apps/desktop/src/locales/en/common.json | 12 +- apps/desktop/src/locales/ko/common.json | 12 +- apps/desktop/vite.config.ts | 3 +- docs/design-system/component-contract.md | 2 +- .../rehearsal-cockpit-tempo-key-transpose.md | 16 + 18 files changed, 1189 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/features/workspace/rehearsalMetrics.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/rehearsalMetrics.scroll.test.ts create mode 100644 apps/desktop/src/features/workspace/rehearsalMetrics.test.ts create mode 100644 apps/desktop/src/features/workspace/rehearsalMetrics.ts create mode 100644 docs/doctoring/rehearsal-cockpit-tempo-key-transpose.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..db88136e8 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. +- Name tonight's countable tempo, first-entrance chord shape, and transpose setup in the cockpit so the next rehearsal action is obvious. Do not present a pending metric as a detected song-wide key. - 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..444a47fe8 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 analysis, the mounted cockpit Tempo, Key, and Transpose metrics must name a countable BPM, the first-entrance chord shape, and the first-entrance transpose setup when those owned fields exist. Keep missing values pending. Do not invent a song-wide key detector; the Key metric is the starting shape the room can check. Cockpit Open/Lock actions scroll renderer-owned workspace surfaces only. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..30bfe069a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's countable tempo, first-entrance chord shape, and transpose setup in the cockpit so the next rehearsal action is Open/Lock on the matching workspace surface. Missing values stay pending; the Key metric is a starting shape, not a claimed song-wide key. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..42789f9c2 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). `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). After analysis, the cockpit names tonight's countable tempo, first-entrance chord shape, and transpose setup and opens the matching workspace surface. Do not treat the Key metric as a song-wide key detector. `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..8abbc8239 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import { MAX_YOUTUBE_URL_LENGTH } from "./lib/analysis"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; // The Score view pulls in ScoreViewer -> pdfjs-dist, which needs DOMMatrix // (absent in jsdom). Stub the pdf.js bridge so App can mount the real @@ -340,6 +341,86 @@ describe("App", () => { expect(screen.getAllByText(/Pending/i).length).toBeGreaterThanOrEqual(2); }); + it("names tonight's tempo, first-entrance chord, and transpose setup after analysis", async () => { + mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong()); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy(); + }); + expect(screen.getAllByText("120 BPM").length).toBeGreaterThan(0); + expect(screen.getByText("Count 120 in before the first entrance.")).toBeTruthy(); + expect(screen.getAllByText("C#m7").length).toBeGreaterThan(0); + expect(screen.getByText("Bass Guitar starts in this shape. Check the first entrance.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open tonight's first chord" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Lock Bass Guitar's setup" })).toBeTruthy(); + expect(screen.queryByText(/^Pending$/)).toBeNull(); + }); + + it("opens the matching workspace surface from the cockpit next action", async () => { + mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong()); + render(); + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy(); + }); + + const tempoTarget = document.getElementById("workspace-surface-tempo"); + const harmonyTarget = document.getElementById("workspace-surface-harmony"); + expect(tempoTarget).toBeTruthy(); + expect(harmonyTarget).toBeTruthy(); + const tempoScroll = vi.fn(); + const harmonyScroll = vi.fn(); + Object.defineProperty(tempoTarget!, "scrollIntoView", { configurable: true, value: tempoScroll }); + Object.defineProperty(harmonyTarget!, "scrollIntoView", { configurable: true, value: harmonyScroll }); + + fireEvent.click(screen.getByRole("button", { name: "Open tonight's count-in" })); + expect(tempoScroll).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Count 120 together. Start from the map.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Open tonight's first chord" })); + expect(harmonyScroll).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Check C#m7 at the first entrance before the room starts.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Lock Bass Guitar's setup" })); + expect(harmonyScroll).toHaveBeenCalledTimes(2); + expect(screen.getByText("Lock Bass Guitar's setup, then count in from the map.")).toBeTruthy(); + }); + + it("keeps a cockpit next action incomplete when the workspace surface is missing", async () => { + mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong()); + render(); + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy(); + }); + document.getElementById("workspace-surface-tempo")?.remove(); + + fireEvent.click(screen.getByRole("button", { name: "Open tonight's count-in" })); + expect(screen.getByText("Count 120 in before the first entrance.")).toBeTruthy(); + expect(screen.queryByText("Count 120 together. Start from the map.")).toBeNull(); + }); + + it("localizes ready cockpit metric copy for Korean rehearsal", async () => { + const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); + mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong()); + + try { + render(); + fireEvent.click(screen.getByRole("button", { name: /프로젝트 열기/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "오늘 카운트 위치 열기" })).toBeTruthy(); + }); + expect(screen.getByText("첫 시작 전에 120으로 카운트하세요.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "오늘 첫 코드 열기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Bass Guitar 세팅 잠그기" })).toBeTruthy(); + } finally { + languageSpy.mockRestore(); + } + }); + it("summarizes confidence from the lowest-confidence loaded section", async () => { const loadedProject = succeededResult().result; loadedProject.sections.push({ diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..b566589e7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -47,6 +47,16 @@ import { createTranslator, detectPreferredLocale, type TranslationKey } from "./ import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { + formatMetricCopy, + resolveTonightStartingChord, + resolveTonightTempo, + resolveTonightTransposePlan, + scrollToWorkspaceSurface, + WORKSPACE_SURFACE_HARMONY, + WORKSPACE_SURFACE_TEMPO, + WORKSPACE_SURFACE_TRANSPOSE +} from "./features/workspace/rehearsalMetrics"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; @@ -172,13 +182,17 @@ function MetricCard({ label, value, detail, - accent = "text-cyan-300" + accent = "text-cyan-300", + actionLabel, + onAction }: { icon: ReactNode; label: string; value: string; detail: string; accent?: string; + actionLabel?: string; + onAction?: () => void; }) { return (
@@ -189,6 +203,15 @@ function MetricCard({

{label}

{value}

{detail}

+ {actionLabel && onAction ? ( + + ) : null}
@@ -264,6 +287,12 @@ export function App() { const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); const [activeView, setActiveView] = useState("workspace"); + const [openedCockpitMetrics, setOpenedCockpitMetrics] = useState<{ + songId: string; + tempo: boolean; + key: boolean; + transpose: boolean; + } | null>(null); const activeJobIdRef = useRef(null); const youtubeInputRef = useRef(null); @@ -518,6 +547,29 @@ export function App() { }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const readySongId = typeof jobResult?.id === "string" ? jobResult.id : ""; + const tonightTempo = resolveTonightTempo(jobResult); + const tonightChord = resolveTonightStartingChord(jobResult); + const tonightTranspose = resolveTonightTransposePlan(jobResult); + const openedMetrics = + openedCockpitMetrics?.songId === readySongId + ? openedCockpitMetrics + : { songId: readySongId, tempo: false, key: false, transpose: false }; + + /** Mark a cockpit next action complete only after the workspace surface actually moved. */ + const handleCockpitMetricAction = ( + metric: "tempo" | "key" | "transpose", + surfaceIds: readonly string[] + ) => { + if (!scrollToWorkspaceSurface(surfaceIds) || readySongId.length === 0) { + return; + } + setOpenedCockpitMetrics({ + ...openedMetrics, + songId: readySongId, + [metric]: true + }); + }; /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { @@ -834,9 +886,87 @@ export function App() {
-
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..330539332 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,16 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("exposes renderer-owned surfaces for cockpit tempo, chord, and transpose actions", () => { + const song = createDemoRehearsalSong(); + render(); + + expect(document.getElementById("workspace-surface-tempo")).toBeTruthy(); + expect(document.getElementById("workspace-surface-harmony")).toBeTruthy(); + expect(document.getElementById("workspace-surface-transpose")).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect(document.getElementById("workspace-surface-transpose")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..3953be7c2 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -245,7 +245,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRehearsalMapLabel")}

{song.tempo && ( - + {t("workspaceTempoLabel")}: {song.tempo} BPM )} @@ -333,7 +336,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp -
+

{t("workspaceRolesHarmonyLabel")}

@@ -416,7 +419,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
+