Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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 @@ -2,6 +2,7 @@

## 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.
- After analysis, when mapped sections exist, the song-structure timeline must name tonight's first lock-in and cue the section matching the first export focus label or id; if that focus is missing or unmatched, cue the first mapped section. With no mapped sections, keep the cue unavailable as “No lock-in section yet.” Do not invent playback, isolation, or a parallel MIR product; #828 remains the known-stem owner.
- 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.
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 song-structure timeline that, when sections are mapped, cues the first export-focus label/id match or falls back to the first mapped section; without mapped sections it keeps the cue unavailable as “No lock-in section yet.”
- 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 song-structure timeline now names tonight's first lock-in and cues that bar so the band can count in from the mark.
- 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. When mapped sections exist, the song-structure timeline must name and cue the first export-focus label/id match, falling back to the first mapped section when the focus is missing or unmatched; with no mapped sections it keeps the cue unavailable as “No lock-in section yet.” It must not invent playback. `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
30 changes: 2 additions & 28 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(within(screen.getByTestId("song-structure-grid")).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 });
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 @@ -1294,7 +1289,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 +1353,13 @@ 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 +1371,13 @@ 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 +1389,13 @@ 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 +1410,13 @@ 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 +1454,13 @@ 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 +1475,15 @@ 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 +1532,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 +1568,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 +1581,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 All @@ -1616,4 +1590,4 @@ describe("App", () => {
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
});
});
79 changes: 77 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,80 @@ 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("cues tonight's first lock-in on the song-structure timeline", () => {
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-timeline-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: "Cue verse on the song timeline from 0:10 to 0:30" }));

expect(screen.getByText("Tonight's first lock-in is cued at verse · 0:10–0:30. Count in from that mark.")).toBeTruthy();
expect(document.getElementById("workspace-timeline-verse-1")?.getAttribute("aria-current")).toBe("true");
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} />);

const bar = document.getElementById("workspace-timeline-intro-1");
const scrollIntoView = vi.fn();
expect(bar).toBeTruthy();
Object.defineProperty(bar!, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});

fireEvent.click(screen.getByRole("button", { name: "Cue intro on the song timeline from 0:00 to 0:08" }));

expect(screen.getByText("Tonight's first lock-in is cued at intro · 0:00–0:08. Count in from that mark.")).toBeTruthy();
expect(document.getElementById("workspace-timeline-intro-1")?.getAttribute("aria-current")).toBe("true");
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});

it("keeps the timeline cue 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 from that mark/i)).toBeNull();
});
});
Loading
Loading