From 1cb02959eb23670934db6cc8d02cba3c6db03eaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:28:20 +0000 Subject: [PATCH 01/10] feat(workspace): count in tonight's first section on the map Name the Section Roadmap card action so a player can count in the first or looped section at the analyzed tempo, then start the first pass. Fail closed when tempo is missing. Do not invent stem playback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/src/App.test.tsx | 2 +- .../workspace/SectionRoadmap.test.tsx | 96 ++++++++++- .../src/features/workspace/SectionRoadmap.tsx | 162 +++++++++++++++++- .../src/features/workspace/Workspace.test.tsx | 2 +- apps/desktop/src/locales/en/common.json | 7 + apps/desktop/src/locales/ko/common.json | 7 + docs/design-system/component-contract.md | 2 +- 11 files changed, 274 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..854883f1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. +- After a section is on tonight's map, the Section Roadmap card must count in that section at the analyzed tempo. Do not leave "Count in on that card" as a dead instruction, and do not invent stem playback here. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. ## Safety diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..8cf495e0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,6 +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 + - a Section Roadmap count-in on tonight's first or looped section at the analyzed tempo - playable ranges and density or overlap warnings - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..4df264541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The ready workspace can count in tonight's first section on the Section Roadmap card at the analyzed tempo, instead of leaving the card without a start action. - 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..fc2730cf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis -- ## Architecture -BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. +BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. After analysis, the Section Roadmap card counts in tonight's first (or looped) section at the analyzed tempo so the room can start the first pass. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. Three layers, decoupled through shared contracts: diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..7a1a9774c 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,7 +313,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(screen.getAllByText(/verse · 0:10–0:30/i).length).toBeGreaterThan(0); expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); expect(screen.queryByText(/Mock-board/i)).toBeNull(); const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..a93f75f16 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SectionRoadmap } from "./SectionRoadmap"; @@ -15,6 +15,7 @@ function setNavigatorLanguage(language: string) { describe("SectionRoadmap", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -60,4 +61,97 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("names tonight's count-in on the first section card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const countIn = screen.getByRole("button", { + name: "Count in verse from 0:10 to 0:30 at tonight's tempo" + }); + expect(countIn).toBeTruthy(); + expect((countIn as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByText("Count in verse · 0:10–0:30")).toBeTruthy(); + }); + + it("counts four beats at the analyzed tempo then names the first pass", () => { + vi.useFakeTimers(); + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click( + screen.getByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" }) + ); + + expect(screen.getByLabelText("Count-in beat 1 of 4")).toBeTruthy(); + expect(screen.getByText("Counting in verse · 1")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 2 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 3 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 4 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByText("Counted in verse · 0:10–0:30. Start the first pass.")).toBeTruthy(); + }); + + it("fails closed when tonight's song has no tempo", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + delete song.tempo; + + render(); + + const countIn = screen.getByRole("button", { name: "Add a tempo before counting in tonight." }); + expect((countIn as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(countIn); + expect(screen.queryByText(/Counting in/)).toBeNull(); + }); + + it("counts in the looped section when the map already named one", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections.push({ + ...song.sections[0]!, + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }); + + render(); + + expect( + screen.getByRole("button", { + name: "Count in chorus from 0:30 to 0:50 at tonight's tempo" + }) + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" }) + ).toBeNull(); + }); + + it("localizes tonight's count-in action", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "오늘 템포로 verse 0:10부터 0:30까지 카운트인" })).toBeTruthy(); + expect(screen.getByText("verse · 0:10–0:30 카운트인")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..fa73368da 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,23 +1,120 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucide-react"; +const COUNT_IN_BEATS = 4; + interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + loopedSectionId?: string | null; +} + +/** Format a timeline instant as m:ss for rehearsal cards. */ +function formatTimelineTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Fill count-in copy with a section label and its start–end window. */ +function countInCopy( + template: string, + section: RehearsalSong["sections"][number] +): string { + return template + .replace("{label}", section.label) + .replace("{start}", formatTimelineTime(section.timeRange.start)) + .replace("{end}", formatTimelineTime(section.timeRange.end)); +} + +/** Return milliseconds per beat when the analyzed tempo can drive a count-in. */ +function countInBeatMs(tempo: number | undefined): number | null { + if (typeof tempo !== "number" || !Number.isFinite(tempo) || tempo <= 0) { + return null; + } + + return 60_000 / tempo; +} + +/** Return the first section this player should count in tonight. */ +function firstCountInSection( + song: RehearsalSong, + activeRole: string | null, + loopedSectionId: string | null +): RehearsalSong["sections"][number] | undefined { + if (loopedSectionId) { + const looped = song.sections.find((section) => section.id === loopedSectionId); + if (looped) { + return looped; + } + } + + if (activeRole) { + const forRole = song.sections.find((section) => + section.roles.some((role) => role.id === activeRole) + ); + if (forRole) { + return forRole; + } + } + + return song.sections[0]; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ + song, + activeRole, + onSongUpdate, + loopedSectionId = null +}: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const countInSection = firstCountInSection(song, activeRole, loopedSectionId); + const beatMs = countInBeatMs(song.tempo); + const [countInPhase, setCountInPhase] = useState<"idle" | "counting" | "ready">("idle"); + const [countInBeat, setCountInBeat] = useState(0); + + useEffect(() => { + setCountInPhase("idle"); + setCountInBeat(0); + }, [countInSection?.id]); + + useEffect(() => { + if (countInPhase !== "counting") { + return; + } + + if (beatMs === null) { + setCountInPhase("idle"); + setCountInBeat(0); + return; + } + + if (countInBeat >= COUNT_IN_BEATS) { + const readyTimer = window.setTimeout(() => { + setCountInPhase("ready"); + }, beatMs); + return () => window.clearTimeout(readyTimer); + } + + const nextTimer = window.setTimeout(() => { + setCountInBeat((current) => current + 1); + }, beatMs); + return () => window.clearTimeout(nextTimer); + }, [beatMs, countInBeat, countInPhase]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -87,6 +184,16 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma return