Skip to content
Open
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 @@ -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.
- After analysis fails, customer-facing copy must enable the next rehearsal action (try this song again or choose another file). Do not leave a failed analysis as a message-only dead end.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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 @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- After analysis fails, the workspace error card names try-this-song-again and choose-another-song as the next actions instead of a message-only dead end.

## Security source

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

- After analysis fails, the workspace names Try this song again and Choose another song as the next rehearsal actions.
- 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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.

After analysis fails, the workspace must name Try this song again or Choose another song rather than leaving a message-only error.

Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.

## Common commands
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ fn select_local_audio_source(
let path = FileDialog::new()
.add_filter("Audio", &AUDIO_EXTENSIONS)
.pick_file()
.ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?;
.ok_or_else(|| "User cancelled".to_string())?;
Comment thread
seonghobae marked this conversation as resolved.
let source = normalize_local_audio_source(&path)?;
let project_id = next_project_id(&state);
let project_root = app_owned_root(&app, "projects", &project_id)?;
Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/src/App.analysis-recovery-cancellation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { App } from "./App";

const analysisMocks = vi.hoisted(() => ({
selectLocalAudioSource: vi.fn(),
startAnalysisJob: vi.fn()
}));

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

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

const admittedBootstrap = {
projectId: "project-1",
sourceMode: "reference",
projectRoot: "/tmp/bandscope/projects/project-1",
cacheRoot: "/tmp/bandscope/cache/project-1",
tempRoot: "/tmp/bandscope/temp/project-1",
source: {
sourcePath: "/Users/test/Music/late-night-set.wav",
fileName: "late-night-set.wav",
extension: "wav",
fileSizeBytes: 1024000
}
};

describe("analysis failure recovery cancellation", () => {
beforeEach(() => {
analysisMocks.selectLocalAudioSource.mockReset();
analysisMocks.startAnalysisJob.mockReset();
});

it("keeps the admitted song and recovery actions when the replacement picker is cancelled", async () => {
analysisMocks.selectLocalAudioSource
.mockResolvedValueOnce({ ok: true, bootstrap: admittedBootstrap })
.mockResolvedValueOnce({
ok: false,
error: { code: "invalid_request", message: "User cancelled" }
});
analysisMocks.startAnalysisJob.mockResolvedValue({
jobId: "job-1",
state: "failed",
requestedAt: "2026-08-22T00:00:00.000Z",
updatedAt: "2026-08-22T00: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(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));
await waitFor(() => expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(2));

expect(screen.queryByText(/user cancelled/i)).toBeNull();
expect(screen.queryByText(/choose a wav, mp3, flac, or m4a file/i)).toBeNull();
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false);
expect(screen.getByRole("button", { name: /choose another song/i }).hasAttribute("disabled")).toBe(false);
});
});
113 changes: 113 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,119 @@ describe("App", () => {
});
});

it("retries the admitted song from the analysis failure card", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(failedJobStatus("job-5", "Analysis queue is full. Please wait for a running job to finish."))
.mockResolvedValueOnce(succeededResult());

render(<App />);

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

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));

await waitFor(() => {
expect(screen.getByRole("button", { name: /try this song again/i })).toBeTruthy();
});
expect(screen.getByText(/this song is still on this device/i)).toBeTruthy();
expect(screen.getByText(/analysis queue is full/i)).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: /try this song again/i }));

await waitFor(() => {
expect(screen.getByText(/Section Roadmap/i)).toBeTruthy();
});
expect(screen.queryByRole("alert")).toBeNull();
});

it("lets the player choose another song after analysis fails", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(failedJobStatus("job-7", "Analysis engine is unavailable."))
.mockResolvedValueOnce(bootstrapResponse({
projectId: "project-2",
source: {
sourcePath: "/Users/test/Music/next-song.wav",
fileName: "next-song.wav",
extension: "wav",
fileSizeBytes: 2048000
}
}));

render(<App />);

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

await waitFor(() => {
expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));

await waitFor(() => {
expect(screen.getByText(/next-song\.wav/i)).toBeTruthy();
});
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy();
});

it("keeps the admitted song when choosing another file fails after analysis failure", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(failedJobStatus("job-8", "Analysis engine is unavailable."));

render(<App />);

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

await waitFor(() => {
expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy();
});

mockLocalAudioSelectionResult = {
ok: false,
error: { code: "invalid_request", message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." }
};
fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));

await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
});
expect(screen.getAllByRole("alert").some((alert) => /analysis engine is unavailable/i.test(alert.textContent ?? ""))).toBe(true);
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false);
});

it("does not offer analysis retry after a project save failure", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(succeededResult());
mockSaveProject.mockRejectedValueOnce(new Error("Disk full"));

render(<App />);

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

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

await waitFor(() => {
expect(screen.getByText(/failed to save project: disk full/i)).toBeTruthy();
});
expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull();
expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull();
});

it("renders the result immediately when start returns a succeeded job", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
Expand Down
39 changes: 37 additions & 2 deletions apps/desktop/src/App.tsx
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi;
const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi;

type RehearsalView = "workspace" | "score";
type WorkspaceJobErrorKind = "analysis" | "project";

const NAV_ITEMS = [
{ labelKey: "navWorkspace", icon: Home, view: "workspace" },
Expand Down Expand Up @@ -255,6 +256,7 @@ export function App() {
const [jobResult, setJobResult] = useState<RehearsalSong | null>(null);
const [jobResultBootstrap, setJobResultBootstrap] = useState<ProjectBootstrapSummary | null>(null);
const [jobError, setJobError] = useState<string | null>(null);
const [jobErrorKind, setJobErrorKind] = useState<WorkspaceJobErrorKind | null>(null);
const [renderedProgressPercent, setRenderedProgressPercent] = useState<number | undefined>(undefined);
const [isStarting, setIsStarting] = useState(false);
const [selectedBootstrap, setSelectedBootstrap] = useState<ProjectBootstrapSummary | null>(null);
Expand Down Expand Up @@ -289,10 +291,12 @@ export function App() {
setJobResultBootstrap(activeAnalysisBootstrap);
setActiveAnalysisBootstrap(null);
setJobError(null);
setJobErrorKind(null);
}
if (nextStatus.state === "failed") {
setActiveAnalysisBootstrap(null);
setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart")));
setJobErrorKind("analysis");
}
}, [activeAnalysisBootstrap, t]);

Expand Down Expand Up @@ -361,7 +365,9 @@ export function App() {
return;
}
const fallbackMessage = t("analysisCouldNotStart");
setActiveAnalysisBootstrap(null);
setJobError(fallbackMessage);
setJobErrorKind("analysis");
setJobStatus({
...jobStatus,
state: "failed",
Comment thread
seonghobae marked this conversation as resolved.
Expand Down Expand Up @@ -389,6 +395,7 @@ export function App() {
const handleStartAnalysis = async () => {
const submittedBootstrap = selectedBootstrap;
setJobError(null);
setJobErrorKind(null);
setJobResult(null);
setJobResultBootstrap(null);
setJobStatus(null);
Expand All @@ -408,6 +415,7 @@ export function App() {
setJobStatus(null);
setActiveAnalysisBootstrap(null);
setJobError(t("analysisCouldNotStart"));
setJobErrorKind("analysis");
} finally {
setIsStarting(false);
}
Expand All @@ -420,12 +428,23 @@ export function App() {
const selection = await selectLocalAudioSource();
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
setJobError(null);
setJobErrorKind(null);
setJobStatus(null);
setActiveAnalysisBootstrap(null);
return;
}

if (isUserCancellation(selection.error.message)) {
return;
}

setSelectedBootstrap(null);
setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
setSelectionErrorSource("local");
if (jobErrorKind === "analysis") {
return;
}
setSelectedBootstrap(null);
setJobStatus(null);
};
Comment thread
seonghobae marked this conversation as resolved.

Expand All @@ -452,6 +471,10 @@ export function App() {
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
setYoutubeUrl("");
setJobError(null);
setJobErrorKind(null);
setJobStatus(null);
setActiveAnalysisBootstrap(null);
} else {
setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed")));
setSelectionErrorSource("youtube");
Expand All @@ -477,12 +500,14 @@ export function App() {
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
setJobErrorKind(null);
setSelectedBootstrap(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
if (!isUserCancellation(e)) {
setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`);
setJobErrorKind("project");
}
}
};
Expand All @@ -494,6 +519,7 @@ export function App() {
} catch (e) {
if (!isUserCancellation(e)) {
setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`);
setJobErrorKind("project");
}
}
};
Expand All @@ -506,7 +532,16 @@ export function App() {
/** Documented. */
const renderWorkspaceState = () => {
if (jobError) {
return <ErrorState error={jobError} />;
const analysisRecovery = jobErrorKind === "analysis";
return (
<ErrorState
error={jobError}
canRetry={analysisRecovery && selectedBootstrap !== null}
Comment thread
seonghobae marked this conversation as resolved.
onRetry={analysisRecovery ? () => { void handleStartAnalysis(); } : undefined}
onChooseAnotherSong={analysisRecovery ? () => { void handleChooseLocalAudio(); } : undefined}
actionsDisabled={analysisInFlight || isStarting || isImporting}
/>
);
}
Comment thread
seonghobae marked this conversation as resolved.
if (analysisInFlight || isStarting) {
return <LoadingState />;
Comment thread
seonghobae marked this conversation as resolved.
Expand Down
Loading
Loading