Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Customer-facing workspace copy must name the next rehearsal action (open tonight's first lock-in, start a part, see first notes) instead of leaving priority or empty cards inert.
- 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.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Last updated: 2026-03-11
- playable ranges and density or overlap warnings
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- a workspace priorities card that opens tonight's first lock-in section on the Section Roadmap
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form

## Confidence, edits, and provenance
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- The rehearsal priorities card now opens tonight's first lock-in section on the Section Roadmap, with bilingual next-action copy.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. The workspace priorities card must name and open tonight's first lock-in on the Section Roadmap. `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:

Expand Down
41 changes: 4 additions & 37 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,10 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy();
});
expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy();
const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy();
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 });
expect(timelineRegion.className).toContain("overflow-x-auto");
expect(timelineRegion.getAttribute("tabindex")).toBe("0");
expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull();
Expand Down Expand Up @@ -361,8 +361,6 @@ 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.
loadedProject.sections.push(
{
...loadedProject.sections[0],
Expand Down Expand Up @@ -1191,9 +1189,6 @@ describe("App", () => {
const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: " " } });
const button = screen.getByRole("button", { name: /Import YouTube/i });
// Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage
// or we can test that the error is set when it somehow triggers, but actually it's disabled.
// Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled!
fireEvent.click(button);

await waitFor(() => {
Expand Down Expand Up @@ -1240,7 +1235,7 @@ describe("App", () => {

it("rejects downgraded YouTube URL intake before invoking the bridge", async () => {
render(<App />);
const input = screen.getByPlaceholderText(/YouTube URL.../i);
const input = screen.getByPlaceholderText(/YouTube URL/i);
fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } });

fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
Expand All @@ -1253,7 +1248,7 @@ describe("App", () => {

it("rejects duplicate YouTube video parameters even when one is blank", async () => {
render(<App />);
const input = screen.getByPlaceholderText(/YouTube URL.../i);
const input = screen.getByRole("textbox", { name: /YouTube URL/i });
fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } });

fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
Expand All @@ -1264,7 +1259,6 @@ describe("App", () => {
expect(tauriInvoke).not.toHaveBeenCalled();
});


it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);
Expand Down Expand Up @@ -1294,7 +1288,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);
});
Expand Down Expand Up @@ -1359,15 +1352,12 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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();
});

mockSaveProject.mockResolvedValueOnce(undefined);

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
Expand All @@ -1379,15 +1369,12 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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();
});

mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
Expand All @@ -1399,15 +1386,12 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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();
});

mockSaveProject.mockRejectedValueOnce(new Error("User cancelled"));

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
Expand All @@ -1422,15 +1406,12 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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();
});

mockSaveProject.mockRejectedValueOnce("Disk full");

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
Expand Down Expand Up @@ -1468,15 +1449,12 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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();
});

mockSaveProject.mockRejectedValueOnce("User cancelled");

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
Expand All @@ -1491,19 +1469,14 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// 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);
});
Expand Down Expand Up @@ -1552,7 +1525,6 @@ describe("App", () => {
});
});


it("renders Settings and Help as focusable aria-disabled controls", () => {
render(<App />);
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
Expand Down Expand Up @@ -1589,8 +1561,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();
});
Expand All @@ -1604,9 +1574,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();
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,15 @@ describe("SectionRoadmap", () => {

expect(onSongUpdate).not.toHaveBeenCalled();
});

it("exposes focus targets for tonight's first lock-in section", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

render(<SectionRoadmap song={song} activeRole={null} focusedSectionId="verse-1" />);

const card = document.getElementById("workspace-section-verse-1");
expect(card).toBeTruthy();
expect(card?.className).toContain("ring-amber-300/70");
});
});
13 changes: 10 additions & 3 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
focusedSectionId?: string | null;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
export function SectionRoadmap({ song, activeRole, onSongUpdate, focusedSectionId = null }: SectionRoadmapProps) {
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
Expand Down Expand Up @@ -106,8 +107,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{song.sections.map((section) => (
<Card
key={section.id}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] ${
section.confidence.level === "low" ? "border-rose-300/30 bg-rose-950/30" : "border-white/10 bg-slate-950/80"
id={`workspace-section-${section.id}`}
tabIndex={-1}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
focusedSectionId === section.id
? "border-amber-300/50 bg-amber-950/40 ring-2 ring-amber-300/70"
: section.confidence.level === "low"
? "border-rose-300/30 bg-rose-950/30"
: "border-white/10 bg-slate-950/80"
}`}
>
<CardHeader className="border-b border-white/10 bg-white/[0.04] p-5 pb-4">
Expand Down
70 changes: 68 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
Expand Down Expand Up @@ -82,7 +82,7 @@ describe("Workspace", () => {

render(<Workspace song={song} />);

expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});

it("enables bass transcription from selected role metadata rather than role id text", () => {
Expand Down Expand Up @@ -269,5 +269,71 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByRole("button", { name: "구간 흐름에서 verse 열기, 0:10부터 0:30까지" })).toBeTruthy();
});

it("opens tonight's first lock-in section on the roadmap", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
const focus = vi.fn();
const originalGetElementById = document.getElementById.bind(document);
vi.spyOn(document, "getElementById").mockImplementation((id: string) => {
const node = originalGetElementById(id);
if (node && id === "workspace-section-verse-1") {
Object.defineProperty(node, "scrollIntoView", { configurable: true, value: scrollIntoView });
Object.defineProperty(node, "focus", { configurable: true, value: focus });
}
return node;
});

render(<Workspace song={song} />);

expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open verse on the section roadmap from 0:10 to 0:30" }));

expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30. Count in on that card.")).toBeTruthy();
expect(document.getElementById("workspace-section-verse-1")).toBeTruthy();
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledTimes(1);
});

it("falls back to the first mapped section when focus labels do not match", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.exportSummary = {
...song.exportSummary,
focusSections: ["missing-bridge"]
};
song.sections[0] = {
...song.sections[0]!,
id: "intro-1",
label: "intro",
timeRange: { start: 0, end: 8 }
};

render(<Workspace song={song} />);

fireEvent.click(screen.getByRole("button", { name: "Open intro on the section roadmap from 0:00 to 0:08" }));

expect(screen.getByText("Tonight's first lock-in is intro · 0:00–0:08. Count in on that card.")).toBeTruthy();
expect(document.getElementById("workspace-section-intro-1")).toBeTruthy();
Comment thread
seonghobae marked this conversation as resolved.
});

it("keeps the priorities action closed when no sections are mapped", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: []
};

render(<Workspace song={song} />);

const locked = screen.getByRole("button", { name: "No lock-in section yet" });
expect(locked.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(locked);
expect(screen.queryByText(/Count in on that card/i)).toBeNull();
});
});
Loading
Loading