Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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