Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
65de458
feat(workspace): name the next rehearsal action from help
seonghobae Aug 21, 2026
83f36ef
test(help): detect case-insensitive module collisions
seonghobae Aug 21, 2026
c78e4df
fix(help): use case-distinct component module path
seonghobae Aug 21, 2026
15a5a15
fix(help): import case-distinct help component module
seonghobae Aug 21, 2026
af86124
test(help): import case-distinct dialog module
seonghobae Aug 21, 2026
f0e9247
fix(help): remove case-colliding component path
seonghobae Aug 21, 2026
2d457be
fix(help): separate import declarations
seonghobae Aug 21, 2026
a053c8c
test(help): cover retry selecting a different song
seonghobae Aug 21, 2026
97f332f
fix(help): reset failed analysis when choosing a new song
seonghobae Aug 21, 2026
58450b5
docs(test): record help module path trust boundary
seonghobae Aug 21, 2026
f46a7ca
test(help): reject stale result after choosing a new song
seonghobae Aug 21, 2026
85b3924
fix(help): clear stale result when selecting a new song
seonghobae Aug 21, 2026
b22c78c
test(help): block stale analysis during source import
seonghobae Aug 21, 2026
8d51eea
fix(help): enforce analysis start guard during import
seonghobae Aug 21, 2026
a786fae
test(help): require map action to leave score view
seonghobae Aug 21, 2026
a7b25de
fix(help): switch to workspace before showing the map
seonghobae Aug 21, 2026
dcc2b46
test(help): keep focus on the rehearsal map after close
seonghobae Aug 21, 2026
d018d98
fix(help): preserve map focus after dialog close
seonghobae Aug 21, 2026
24fdf58
test(help): include the dialog in desktop coverage
seonghobae Aug 21, 2026
91c789a
test(help): require compact access to rehearsal help
seonghobae Aug 21, 2026
636c898
fix(help): expose rehearsal help on compact layouts
seonghobae Aug 21, 2026
d5e31f1
refactor(help): remove obsolete coming-soon copy
seonghobae Aug 21, 2026
7efbe15
test: match rehearsal map action label exactly
seonghobae Aug 21, 2026
63dec82
test: align source replacement and save failure contracts
seonghobae Aug 21, 2026
d2af745
test(help): show wait state during source import
seonghobae Aug 21, 2026
db3ced1
test(help): distinguish load failures and compact action label
seonghobae Aug 21, 2026
dc5463d
fix(help): align busy, failure, and compact action states
seonghobae Aug 21, 2026
0747f3c
fix(help): restore background grid class while aligning help state
seonghobae Aug 21, 2026
abfbae1
test: cover YouTube source state reset
seonghobae Aug 21, 2026
901e70f
fix(workspace): clear stale analysis after YouTube import
seonghobae Aug 21, 2026
6b1f7d5
fix(workspace): keep rehearsal map visible on save failure
seonghobae Aug 22, 2026
b9ecf6c
test(rehearsal): cover distinct compact help and stale save alert
seonghobae Aug 22, 2026
c0095ca
fix(rehearsal): clear stale save state and disambiguate help
seonghobae Aug 22, 2026
7283b52
Merge branch 'develop' into feat/rehearsal-help-next-action
seonghobae Aug 25, 2026
4788e36
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
749b5e6
fix(help): align wait copy with tests, stop offering a hidden map
seonghobae Aug 26, 2026
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.
- Rehearsal help must name the next action: choose a local song, start analysis, wait, retry after failure, or open tonight's map. Stem playback and a licensed demo remain later work.
- 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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-21

## Brand source

Expand Down Expand Up @@ -86,6 +86,7 @@ Last updated: 2026-03-11
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
- a rehearsal-help surface that names the next local-first action (choose a song, start analysis, wait, retry, or open the map) without claiming stem playback or a licensed demo

## 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

- Rehearsal help now names the next action: choose a local song, start analysis, wait while analysis runs, retry after a failure, or open tonight's map.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- 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 @@ -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). The ready workspace names tonight's first playable range and the next instrument check. `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, help). The help control names tonight's next local-first action instead of a coming-soon label. The ready workspace also names tonight's first playable range and the next instrument check. `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.

Expand Down
335 changes: 335 additions & 0 deletions apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

const analysisMocks = vi.hoisted(() => ({
getAnalysisJobStatus: vi.fn(),
importYoutubeUrl: vi.fn(),
isSupportedYoutubeUrl: vi.fn(),
loadProject: vi.fn(),
saveProject: vi.fn(),
selectLocalAudioSource: vi.fn(),
startAnalysisJob: vi.fn(),
subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined),
}));

vi.mock("./lib/analysis", () => ({
createDefaultAnalysisRequest: () => ({
sourceKind: "demo",
sourceLabel: "Late Night Set",
roleFocus: ["bass-guitar", "keys-right", "lead-vocal"],
}),
getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus,
importYoutubeUrl: analysisMocks.importYoutubeUrl,
isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl,
loadProject: analysisMocks.loadProject,
MAX_YOUTUBE_URL_LENGTH: 2048,
saveProject: analysisMocks.saveProject,
selectLocalAudioSource: analysisMocks.selectLocalAudioSource,
startAnalysisJob: analysisMocks.startAnalysisJob,
subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates,
}));

vi.mock("./features/score/ScoreView", () => ({
ScoreView: () => <div data-testid="score-view">Score view</div>,
}));

function bootstrap(projectId: string, fileName: string) {
return {
projectId,
sourceMode: "reference",
projectRoot: `/tmp/bandscope/projects/${projectId}`,
cacheRoot: `/tmp/bandscope/cache/${projectId}`,
tempRoot: `/tmp/bandscope/temp/${projectId}`,
source: {
sourcePath: `/Users/test/Music/${fileName}`,
fileName,
extension: "wav",
fileSizeBytes: 1024000,
},
};
}

/**
* Security Notes:
* - Local paths in this suite are synthetic test fixtures and are never rendered in customer-facing copy.
* - The suite mocks the existing picker and analysis boundaries; it adds no network access or IPC permission.
*/
describe("App rehearsal-help failure recovery", () => {
beforeEach(() => {
for (const mock of Object.values(analysisMocks)) {
mock.mockReset();
}
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false);
analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
});

it("gives compact rehearsal help a distinct accessible name", () => {
render(<App />);

const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const compactHelp = within(compactNav).getByRole("button", {
name: /^open rehearsal help compact view$/i,
});

expect(screen.getByRole("button", { name: /^open rehearsal help$/i })).toBeTruthy();
fireEvent.click(compactHelp);
expect(screen.getByTestId("rehearsal-help-dialog")).toBeTruthy();
});

it("does not describe a project-load error as an analysis failure", async () => {
analysisMocks.loadProject.mockRejectedValueOnce(new Error("Broken project fixture"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
const helpDialog = screen.getByTestId("rehearsal-help-dialog");
expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/choose a local song first/i,
);
expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).not.toMatch(
/analysis did not finish/i,
);
});

it("advances from retry to start analysis after a different local song is selected", async () => {
analysisMocks.selectLocalAudioSource
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "failed-song.wav") })
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") });
analysisMocks.startAnalysisJob.mockResolvedValueOnce({
jobId: "job-help-failed",
state: "failed",
requestedAt: "2026-08-21T05:00:00.000Z",
updatedAt: "2026-08-21T05:00:01.000Z",
error: {
code: "engine_unavailable",
message: "Analysis engine is unavailable.",
},
});

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/failed-song\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toMatch(/analysis engine is unavailable/i);
});

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
let helpDialog = screen.getByTestId("rehearsal-help-dialog");
expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/choose another local song and try again/i,
);
fireEvent.click(within(helpDialog).getByRole("button", { name: /choose another song/i }));

await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
helpDialog = screen.getByTestId("rehearsal-help-dialog");

expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/start analysis to get tonight's first cues/i,
);
expect(within(helpDialog).queryByRole("button", { name: /choose another song/i })).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("forgets the previous analyzed song when a different local source is selected", async () => {
analysisMocks.selectLocalAudioSource
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "analyzed-song.wav") })
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") });
analysisMocks.startAnalysisJob.mockResolvedValueOnce({
jobId: "job-help-succeeded",
state: "succeeded",
requestedAt: "2026-08-21T05:10:00.000Z",
updatedAt: "2026-08-21T05:10:01.000Z",
progressLabel: "Analysis ready",
progressStage: "ready",
progressPercent: 100,
cacheStatus: "disabled",
result: createDemoRehearsalSong(),
});

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/analyzed-song\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
});

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
const helpDialog = screen.getByTestId("rehearsal-help-dialog");
expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/start analysis to get tonight's first cues/i,
);
expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull();
});

it("clears a stale save failure when a different local source is selected", async () => {
analysisMocks.selectLocalAudioSource
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-a", "save-failed-song.wav") })
.mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-b", "fresh-song.wav") });
analysisMocks.startAnalysisJob.mockResolvedValueOnce({
jobId: "job-help-save-failure",
state: "succeeded",
requestedAt: "2026-08-22T06:00:00.000Z",
updatedAt: "2026-08-22T06:00:01.000Z",
progressLabel: "Analysis ready",
progressStage: "ready",
progressPercent: 100,
cacheStatus: "disabled",
result: createDemoRehearsalSong(),
});
analysisMocks.saveProject.mockRejectedValueOnce(new Error("Disk unavailable"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/save-failed-song\.wav/i)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
});

fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/disk unavailable/i));

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());
expect(screen.queryByRole("alert")).toBeNull();
});

it("forgets the previous analyzed song when a YouTube source is imported", async () => {
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true);
analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: bootstrap("project-local", "analyzed-song.wav"),
});
analysisMocks.startAnalysisJob.mockResolvedValueOnce({
jobId: "job-help-youtube-reset",
state: "succeeded",
requestedAt: "2026-08-21T05:15:00.000Z",
updatedAt: "2026-08-21T05:15:01.000Z",
progressLabel: "Analysis ready",
progressStage: "ready",
progressPercent: 100,
cacheStatus: "disabled",
result: createDemoRehearsalSong(),
});
analysisMocks.importYoutubeUrl.mockResolvedValueOnce({
ok: true,
bootstrap: bootstrap("project-youtube", "imported-song.m4a"),
});

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/analyzed-song\.wav/i)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
});

fireEvent.change(screen.getByRole("textbox", { name: /youtube url/i }), {
target: { value: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
});
fireEvent.click(screen.getByRole("button", { name: /import youtube/i }));
await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
const helpDialog = screen.getByTestId("rehearsal-help-dialog");
expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/start analysis to get tonight's first cues/i,
);
expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull();
});

it("shows a wait-only help state while a YouTube import is in flight", async () => {
let resolveImport: ((value: unknown) => void) | undefined;
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true);
analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: bootstrap("project-local", "local-song.wav"),
});
analysisMocks.importYoutubeUrl.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveImport = resolve;
}),
);

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/local-song\.wav/i)).toBeTruthy());

fireEvent.change(screen.getByRole("textbox", { name: /youtube url/i }), {
target: { value: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
});
fireEvent.click(screen.getByRole("button", { name: /import youtube/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /import youtube/i }).textContent).toMatch(/importing/i);
});

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
const helpDialog = screen.getByTestId("rehearsal-help-dialog");

expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
/wait for this step to finish/i,
);
expect(within(helpDialog).queryByRole("button", { name: /^start analysis$/i })).toBeNull();
expect(analysisMocks.startAnalysisJob).not.toHaveBeenCalled();

resolveImport?.({ ok: true, bootstrap: bootstrap("project-youtube", "imported-song.m4a") });
await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy());
});

it("switches from score back to the workspace before showing the rehearsal map", async () => {
analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: bootstrap("project-map", "map-song.wav"),
});
analysisMocks.startAnalysisJob.mockResolvedValueOnce({
jobId: "job-help-map",
state: "succeeded",
requestedAt: "2026-08-21T05:20:00.000Z",
updatedAt: "2026-08-21T05:20:01.000Z",
progressLabel: "Analysis ready",
progressStage: "ready",
progressPercent: 100,
cacheStatus: "disabled",
result: createDemoRehearsalSong(),
});

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/map-song\.wav/i)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
});

fireEvent.click(screen.getAllByRole("button", { name: /^score$/i })[0]!);
await waitFor(() => expect(screen.getByTestId("score-view")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
const helpDialog = screen.getByTestId("rehearsal-help-dialog");
fireEvent.click(within(helpDialog).getByRole("button", { name: /show the rehearsal map/i }));

await waitFor(() => expect(screen.queryByTestId("score-view")).toBeNull());
await waitFor(() => expect(screen.queryByTestId("rehearsal-help-dialog")).toBeNull());
expect(document.activeElement?.id).toBe("main-content");
});
});
Loading
Loading