diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..da3624c60 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. +- 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..2b6545582 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. +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..28b7b9076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..79eae7e43 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. +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 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.analysis-recovery-cancellation.test.tsx b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx new file mode 100644 index 000000000..e8eacf331 --- /dev/null +++ b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx @@ -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: () =>
Score view
+})); + +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(); + + 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); + }); +}); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..f74ea9219 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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()) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..11a8e46df 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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" }, @@ -255,6 +256,7 @@ export function App() { const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); const [jobError, setJobError] = useState(null); + const [jobErrorKind, setJobErrorKind] = useState(null); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); @@ -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]); @@ -361,7 +365,9 @@ export function App() { return; } const fallbackMessage = t("analysisCouldNotStart"); + setActiveAnalysisBootstrap(null); setJobError(fallbackMessage); + setJobErrorKind("analysis"); setJobStatus({ ...jobStatus, state: "failed", @@ -389,6 +395,7 @@ export function App() { const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; setJobError(null); + setJobErrorKind(null); setJobResult(null); setJobResultBootstrap(null); setJobStatus(null); @@ -408,6 +415,7 @@ export function App() { setJobStatus(null); setActiveAnalysisBootstrap(null); setJobError(t("analysisCouldNotStart")); + setJobErrorKind("analysis"); } finally { setIsStarting(false); } @@ -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); }; @@ -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"); @@ -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"); } } }; @@ -494,6 +519,7 @@ export function App() { } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); + setJobErrorKind("project"); } } }; @@ -506,7 +532,16 @@ export function App() { /** Documented. */ const renderWorkspaceState = () => { if (jobError) { - return ; + const analysisRecovery = jobErrorKind === "analysis"; + return ( + { void handleStartAnalysis(); } : undefined} + onChooseAnotherSong={analysisRecovery ? () => { void handleChooseLocalAudio(); } : undefined} + actionsDisabled={analysisInFlight || isStarting || isImporting} + /> + ); } if (analysisInFlight || isStarting) { return ; diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx new file mode 100644 index 000000000..94b27b2ee --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { ErrorState } from "./WorkspaceStates" + +const meta = { + title: "Workspace/Analysis failure recovery", + component: ErrorState, + parameters: { layout: "padded" }, + args: { + error: "Analysis queue is full. Please wait for a running job to finish.", + canRetry: true, + onRetry: () => undefined, + onChooseAnotherSong: () => undefined, + actionsDisabled: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Analysis failures keep the admitted song actionable instead of ending at a message-only alert. */ +export const RecoverableAnalysisFailure: Story = {} + +/** Retry stays visibly unavailable until a song has been admitted, while replacement remains available. */ +export const RetryUnavailable: Story = { + args: { + error: "Analysis could not start.", + canRetry: false, + }, +} + +/** In-flight recovery prevents duplicate submissions without removing the customer's next actions. */ +export const RecoveryInFlight: Story = { + args: { + actionsDisabled: true, + }, +} + +/** Project persistence failures remain message-only and do not imply that re-analysis is the remedy. */ +export const ProjectFailure: Story = { + args: { + error: "Failed to save project: Disk full", + canRetry: false, + onRetry: undefined, + onChooseAnotherSong: undefined, + }, +} diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..4869a5d52 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +describe("WorkspaceStates", () => { + it("renders the empty prompt that names choosing a song", () => { + render(); + expect(screen.getByText(/ready to analyze/i)).toBeTruthy(); + expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy(); + }); + + it("renders a busy analysis status", () => { + render(); + expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); + expect(screen.getByText(/analyzing audio/i)).toBeTruthy(); + }); + + it("keeps project failures as a message-only alert", () => { + render(); + expect(screen.getByRole("alert").textContent).toMatch(/an error occurred during analysis/i); + expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull(); + }); + + it("names retry and choose-another as the next analysis actions", () => { + const onRetry = vi.fn(); + const onChooseAnotherSong = vi.fn(); + render( + + ); + + expect(screen.getByRole("heading", { name: /analysis didn't finish/i })).toBeTruthy(); + expect(screen.getByText(/this song is still on this device/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /try this song again/i })); + fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onChooseAnotherSong).toHaveBeenCalledTimes(1); + }); + + it("disables retry until a song is admitted", () => { + const onRetry = vi.fn(); + render( + undefined} + /> + ); + + const retry = screen.getByRole("button", { name: /try this song again/i }); + expect(retry).toBeDisabled(); + expect(retry).toHaveAttribute("title", "Choose a song first"); + fireEvent.click(retry); + expect(onRetry).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..8d8750701 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Play, Upload } from "lucide-react"; /** Documented. */ export function EmptyState() { @@ -38,17 +39,69 @@ export function LoadingState() { ); } -/** Documented. */ -export function ErrorState({ error }: { error?: string }) { +/** Next-action handlers after analysis fails without claiming a demo or stem player. */ +export interface ErrorStateProps { + error?: string; + canRetry?: boolean; + onRetry?: () => void; + onChooseAnotherSong?: () => void; + actionsDisabled?: boolean; +} + +/** Name the next rehearsal action after analysis fails. */ +export function ErrorState({ + error, + canRetry = false, + onRetry, + onChooseAnotherSong, + actionsDisabled = false +}: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); + const showRecoveryActions = Boolean(onRetry || onChooseAnotherSong); + return (
-

{t("workspaceErrorState")}

+

+ {showRecoveryActions ? t("analysisFailedTitle") : t("workspaceErrorState")} +

+ {showRecoveryActions ? ( +

{t("analysisFailedGuidance")}

+ ) : null} {error &&

{error}

} + {showRecoveryActions ? ( +
+ {onRetry ? ( + + ) : null} + {onChooseAnotherSong ? ( + + ) : null} +
+ ) : null}
); diff --git a/apps/desktop/src/lib/analysis.selection.test.ts b/apps/desktop/src/lib/analysis.selection.test.ts new file mode 100644 index 000000000..d22737420 --- /dev/null +++ b/apps/desktop/src/lib/analysis.selection.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { selectLocalAudioSource } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: (command: string, args?: Record) => Promise; +}; + +const tauriWindow = window as TauriWindow; + +describe("local audio selection boundary", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("preserves native picker cancellation as a silent-capable signal", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("User cancelled"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "User cancelled" + } + }); + }); + + it("does not expose arbitrary native picker errors to the buyer", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue(new Error("/Users/customer/Music/private.wav token=secret-value")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); +}); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..7393714f4 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -37,6 +37,7 @@ const BROWSER_PROGRESS_STEPS = [ const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, + "User cancelled", "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", @@ -231,13 +232,14 @@ export async function selectLocalAudioSource(): Promise