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() {
+ )} ); diff --git a/apps/desktop/src/lib/analysis.nativeContract.test.ts b/apps/desktop/src/lib/analysis.nativeContract.test.ts new file mode 100644 index 000000000..ad1b6e45f --- /dev/null +++ b/apps/desktop/src/lib/analysis.nativeContract.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const tauriMainSource = readFileSync(resolve(process.cwd(), "src-tauri", "src", "main.rs"), "utf8"); + +/** + * Security Notes: + * - This test reads only the checked-in Rust bridge source. + * - It guards the cross-language cancellation contract without invoking the filesystem picker. + */ +describe("local-audio native cancellation contract", () => { + it("keeps native picker cancellation distinct from unsupported-audio failure", () => { + const start = tauriMainSource.indexOf("fn select_local_audio_source("); + const end = tauriMainSource.indexOf("async fn import_youtube_url(", start); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(tauriMainSource.slice(start, end)).toContain('.ok_or_else(|| "User cancelled".to_string())?;'); + }); +}); 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..37a40f35e --- /dev/null +++ b/apps/desktop/src/lib/analysis.selection.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { importYoutubeUrl, selectLocalAudioSource } from "./analysis"; + +type YoutubeCancellation = Extract< + Awaited>, + { cancelled: true } +>; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +/** + * Security Notes: + * - Untrusted input: native picker rejection text, including path- and secret-shaped diagnostics. + * - Trust boundary: Tauri `select_local_audio_source` → this bridge → buyer-visible copy. + * - Safe failure: cancellation is silent; unknown native text is replaced with supported-format guidance. + * - Privacy: absolute paths and secret-shaped messages must not cross into LocalAudioSelectionResult.error. + */ +describe("selectLocalAudioSource cancellation and redaction", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("treats a native User cancelled Error as a silent cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("User cancelled")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ ok: false, cancelled: true }); + }); + + it("treats a native User cancelled string as a silent cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("User cancelled"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ ok: false, cancelled: true }); + }); + + it("preserves allowlisted file-read failure copy", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("Could not read the selected audio file.")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Could not read the selected audio file." + } + }); + }); + + it("redacts path-shaped native diagnostics before they become buyer copy", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue( + new Error("open failed: /Users/test/Music/secret-token.wav") + ); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + + it("redacts a non-Error native rejection that is not cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("open failed: /Users/test/Music/secret-token.wav"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + + it("keeps native-picker cancellation out of YouTube import results", () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..88f93036b 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -18,6 +18,9 @@ import { listen } from "@tauri-apps/api/event"; type TauriInvoke = (command: string, args?: Record) => Promise; +type AudioSourceSuccess = { ok: true; bootstrap: ProjectBootstrapSummary }; +type AudioSourceFailure = { ok: false; error: AnalysisJobError }; + declare global { interface Window { __TAURI_INTERNALS__?: { @@ -35,6 +38,7 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const LOCAL_AUDIO_USER_CANCELLED_MESSAGE = "User cancelled"; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", @@ -47,10 +51,14 @@ const MAX_YOUTUBE_URL_LENGTH = 2000; export { MAX_YOUTUBE_URL_LENGTH }; -/** Documented. */ +/** Local-picker result; only this boundary can report native picker cancellation. */ export type LocalAudioSelectionResult = - | { ok: true; bootstrap: ProjectBootstrapSummary } - | { ok: false; error: AnalysisJobError }; + | AudioSourceSuccess + | { ok: false; cancelled: true } + | AudioSourceFailure; + +/** YouTube import result; this boundary has no native-picker cancellation state. */ +export type YoutubeImportResult = AudioSourceSuccess | AudioSourceFailure; /** Documented. */ function getInvoke(): TauriInvoke | null { @@ -211,7 +219,7 @@ async function browserFallback(command: string, args?: Record): async function invokeAnalysis(command: string, args?: Record): Promise { const invokeCommand = getInvoke(); if (invokeCommand) { - return invokeCommand(command, args); + return args === undefined ? invokeCommand(command) : invokeCommand(command, args); } return browserFallback(command, args); @@ -222,6 +230,17 @@ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); } +/** Documented. */ +function localAudioCancellationMessage(error: unknown): string | null { + if (typeof error === "string") { + return error.trim() === LOCAL_AUDIO_USER_CANCELLED_MESSAGE ? error.trim() : null; + } + if (error instanceof Error && error.message.trim() === LOCAL_AUDIO_USER_CANCELLED_MESSAGE) { + return error.message.trim(); + } + return null; +} + /** Documented. */ export async function selectLocalAudioSource(): Promise { try { @@ -231,6 +250,9 @@ export async function selectLocalAudioSource(): Promise { +export async function importYoutubeUrl(url: string): Promise { if (!isSupportedYoutubeUrl(url)) { return { ok: false, diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..bd9adaffd 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -11,6 +11,9 @@ "selectedAudio": "Selected audio", "sourceModeReference": "References the original file", "unsupportedLocalAudio": "Choose a WAV, MP3, FLAC, or M4A file to start analysis.", + "localSelectionFailureTitle": "That file can't start tonight", + "localSelectionFailureGuidance": "Choose another song on this device. BandScope keeps the file local.", + "chooseAnotherSong": "Choose another song", "sectionConfidence": "Section confidence", "roleConfidence": "confidence", "harmonySource": "harmony source", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..d9990a10f 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -11,6 +11,9 @@ "selectedAudio": "선택한 오디오", "sourceModeReference": "원본 파일을 참조합니다", "unsupportedLocalAudio": "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요.", + "localSelectionFailureTitle": "그 파일로는 오늘 합주를 시작할 수 없습니다", + "localSelectionFailureGuidance": "이 기기에서 다른 곡을 선택하세요. 파일은 기기에만 남습니다.", + "chooseAnotherSong": "다른 곡 선택하기", "sectionConfidence": "구간 신뢰도", "roleConfidence": "신뢰도", "harmonySource": "화성 출처",