diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..568328d4f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
+- When local audio intake fails, customer-facing copy must name Choose another song as the next rehearsal action. Picker cancellation is silent and must not look like an unsupported-format error.
- 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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..a9b7167f9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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.
+- Local-audio picker cancellation stays silent. Unsupported or unreadable local files name Choose another song as the next action and do not invent a bundled demo.
## Security source
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..f74edb63a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- When a local song cannot be admitted, the workspace names Choose another song as the next rehearsal action and keeps picker cancellation silent.
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..ed1bd4817 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
+When local audio intake fails, the workspace must name Choose another song as the next action. Picker cancellation is silent.
+
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index ed4f967bd..f9a5eabe5 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -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())?;
let source = normalize_local_audio_source(&path)?;
let project_id = next_project_id(&state);
let project_root = app_owned_root(&app, "projects", &project_id)?;
diff --git a/apps/desktop/src/App.localSelectionFailureVisibility.test.tsx b/apps/desktop/src/App.localSelectionFailureVisibility.test.tsx
new file mode 100644
index 000000000..a2464a6d6
--- /dev/null
+++ b/apps/desktop/src/App.localSelectionFailureVisibility.test.tsx
@@ -0,0 +1,102 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+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(() => ({
+ getAnalysisJobStatus: vi.fn(),
+ importYoutubeUrl: vi.fn(),
+ isSupportedYoutubeUrl: vi.fn(() => false),
+ 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: () =>
Score view
+}));
+
+/**
+ * Security Notes:
+ * - This test supplies only synthetic analysis data and safe allowlisted local-intake failure copy.
+ * - No filesystem, network, subprocess, or native-picker authority is exercised.
+ */
+describe("App local selection failure visibility", () => {
+ beforeEach(() => {
+ for (const mock of Object.values(analysisMocks)) {
+ mock.mockReset();
+ }
+ analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false);
+ analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
+ analysisMocks.loadProject.mockResolvedValue(createDemoRehearsalSong());
+ analysisMocks.selectLocalAudioSource.mockResolvedValue({
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis."
+ }
+ });
+ });
+
+ it("surfaces local replacement failure even when a rehearsal result is already loaded", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Project" }));
+ await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+ });
+
+ it("clears a stale local selection failure after a saved project opens successfully", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy()
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Project" }));
+
+ await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy());
+ expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
+ });
+
+ it("replaces a stale project-load error with the later local recovery action", async () => {
+ analysisMocks.loadProject.mockRejectedValueOnce(new Error("synthetic project load failure"));
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Project" }));
+ await waitFor(() => expect(analysisMocks.loadProject).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..7e2181a7e 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -428,9 +428,10 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => {
- expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
});
expect(screen.getByRole("alert").textContent).toMatch(/choose a wav, mp3, flac, or m4a file/i);
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});
@@ -450,6 +451,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
});
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});
@@ -463,9 +465,159 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy();
});
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});
+ it("starts local file intake from the selection-failure next action", async () => {
+ tauriInvoke
+ .mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."))
+ .mockResolvedValueOnce(bootstrapResponse());
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
+ });
+ expect(screen.queryByRole("button", { name: "Choose another song" })).toBeNull();
+ expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
+ });
+
+ it("keeps the empty workspace silent when the local picker is cancelled", async () => {
+ tauriInvoke.mockRejectedValueOnce(new Error("User cancelled"));
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+
+ await waitFor(() => {
+ expect(tauriInvoke).toHaveBeenCalledWith("select_local_audio_source");
+ });
+
+ expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
+ });
+
+ it("keeps an admitted song when a replacement picker is cancelled", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockRejectedValueOnce(new Error("User cancelled"));
+
+ render();
+
+ 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: /choose local audio/i }));
+ await waitFor(() => {
+ expect(tauriInvoke).toHaveBeenCalledTimes(2);
+ });
+
+ expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
+ });
+
+ it("keeps the selection-failure next action when a replacement picker is cancelled", async () => {
+ tauriInvoke
+ .mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."))
+ .mockRejectedValueOnce(new Error("User cancelled"));
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));
+ await waitFor(() => {
+ expect(tauriInvoke).toHaveBeenCalledTimes(2);
+ });
+
+ expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeEnabled();
+ });
+
+ it("localizes the local selection-failure next action", async () => {
+ const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
+ tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."));
+
+ try {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /로컬 오디오 선택/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "그 파일로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy();
+ });
+ expect(screen.getByRole("button", { name: "다른 곡 선택하기" })).toBeTruthy();
+ } finally {
+ languageSpy.mockRestore();
+ }
+ });
+
+ it("allows only one local picker while the first selection is pending", async () => {
+ let resolveSelection: ((value: ReturnType) => void) | undefined;
+ tauriInvoke.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSelection = resolve;
+ })
+ );
+
+ render();
+
+ const headerAction = screen.getByRole("button", { name: /choose local audio/i });
+ fireEvent.click(headerAction);
+
+ await waitFor(() => {
+ expect(headerAction).toBeDisabled();
+ });
+
+ fireEvent.click(headerAction);
+ expect(tauriInvoke).toHaveBeenCalledTimes(1);
+
+ resolveSelection?.(bootstrapResponse());
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+ });
+
+ it("disables the selection-failure next action while a replacement picker is pending", async () => {
+ tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."));
+
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+
+ let resolveSelection: ((value: ReturnType) => void) | undefined;
+ tauriInvoke.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSelection = resolve;
+ })
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /choose local audio/i })).toBeDisabled();
+ });
+
+ resolveSelection?.(bootstrapResponse());
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+ });
+
it("starts an analysis job and renders the returned rehearsal result", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
@@ -1595,6 +1747,29 @@ describe("App", () => {
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
+ it("surfaces a local intake failure from the Score view by returning to the workspace", async () => {
+ mockLoadProject.mockResolvedValueOnce(succeededResult().result);
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /open project/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getAllByRole("button", { name: /^Score$/i })[0]!);
+ await waitFor(() => expect(screen.getByRole("region", { name: "Score" })).toBeTruthy());
+
+ mockLocalAudioSelectionResult = {
+ ok: false,
+ error: { code: "invalid_request", message: "" }
+ };
+ fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));
+
+ await waitFor(() => expect(screen.queryByRole("region", { name: "Score" })).toBeNull());
+ expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
+ });
+
it("switches to the Score view from the compact mobile navigation", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index f3d678454..2df67a352 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -263,6 +263,7 @@ export function App() {
const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null);
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
+ const [isChoosingLocalAudio, setIsChoosingLocalAudio] = useState(false);
const [activeView, setActiveView] = useState("workspace");
const activeJobIdRef = useRef(null);
const youtubeInputRef = useRef(null);
@@ -415,18 +416,35 @@ export function App() {
/** Documented. */
const handleChooseLocalAudio = async () => {
- setSelectionError(null);
- setSelectionErrorSource(null);
- const selection = await selectLocalAudioSource();
- if (selection.ok) {
- setSelectedBootstrap(selection.bootstrap);
+ if (isChoosingLocalAudio) {
return;
}
- setSelectedBootstrap(null);
- setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
- setSelectionErrorSource("local");
- setJobStatus(null);
+ setIsChoosingLocalAudio(true);
+ try {
+ const selection = await selectLocalAudioSource();
+ if (selection.ok) {
+ setJobError(null);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
+ setSelectedBootstrap(selection.bootstrap);
+ return;
+ }
+
+ if ("cancelled" in selection) {
+ return;
+ }
+
+ setJobError(null);
+ setSelectedBootstrap(null);
+ setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
+ setSelectionErrorSource("local");
+ // Recovery lives on the workspace surface; leave Score so the failure is never silent.
+ setActiveView("workspace");
+ setJobStatus(null);
+ } finally {
+ setIsChoosingLocalAudio(false);
+ }
};
/** Documented. */
@@ -477,6 +495,8 @@ export function App() {
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
+ setSelectionError(null);
+ setSelectionErrorSource(null);
setSelectedBootstrap(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
@@ -511,6 +531,20 @@ export function App() {
if (analysisInFlight || isStarting) {
return ;
}
+ if (selectionError && selectionErrorSource === "local") {
+ return (
+ {
+ void handleChooseLocalAudio();
+ }}
+ actionDisabled={isChoosingLocalAudio}
+ />
+ );
+ }
if (jobResult) {
return ;
}
@@ -682,7 +716,7 @@ export function App() {