diff --git a/apps/desktop/src/App.localized-source-error.test.tsx b/apps/desktop/src/App.localized-source-error.test.tsx new file mode 100644 index 000000000..949d35ce8 --- /dev/null +++ b/apps/desktop/src/App.localized-source-error.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +// App mounts the Score surface, whose pdf.js bridge depends on browser canvas +// globals such as DOMMatrix that jsdom does not provide. This regression only +// exercises source-selection localization, so isolate that unrelated boundary +// exactly as the canonical App suite does. +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("App localized source errors", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("keeps the browser local-audio fallback in the selected Korean locale", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + render(); + fireEvent.click(screen.getByRole("button", { name: "로컬 오디오 선택" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요." + ); + expect(screen.queryByText("Choose a WAV, MP3, FLAC, or M4A file to start analysis.")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..a229f6418 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -299,7 +299,7 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); - expect(screen.getByText(/Stems/i)).toBeTruthy(); + expect(screen.getByText("Stems")).toBeTruthy(); expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); }); @@ -453,7 +453,7 @@ describe("App", () => { expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); }); - it("preserves safe file-read failure copy from the intake bridge", async () => { + it("localizes safe file-read failure copy from the intake bridge", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Could not read the selected audio file.")); render(); @@ -461,8 +461,9 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); await waitFor(() => { - expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy(); + expect(screen.getByText(/selected audio file could not be read.*choose the file again/i)).toBeTruthy(); }); + expect(screen.queryByText(/^Could not read the selected audio file\.$/i)).toBeNull(); expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); }); @@ -644,7 +645,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toMatch(/analysis could not start/i); }); - expect(screen.getAllByRole("status").some((status) => /analysis failed during execution/i.test(status.textContent ?? ""))).toBe(true); + expect(screen.getAllByRole("status").some((status) => /stopped partway through/i.test(status.textContent ?? ""))).toBe(true); }); it("holds a terminal progress value immediately for pushed failed statuses", async () => { @@ -1150,7 +1151,7 @@ describe("App", () => { expect(input).not.toHaveAttribute("aria-describedby"); }); - it("handles YouTube import failure with a message", async () => { + it("redacts bridge detail when YouTube import fails after URL admission", async () => { tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); render(); @@ -1163,14 +1164,15 @@ describe("App", () => { await waitFor(() => { const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); + expect(alert).toHaveTextContent(/Failed to import YouTube URL/i); + expect(alert).not.toHaveTextContent(/This video is age restricted/i); expect(alert).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", alert.id); }); }); - it("handles generic exception during YouTube import", async () => { + it("redacts generic bridge exceptions during YouTube import", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Network Error")); render(); @@ -1182,7 +1184,9 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Network Error/i)).toBeTruthy(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(/Failed to import YouTube URL/i); + expect(alert).not.toHaveTextContent(/Network Error/i); }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..4f504cb15 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -210,6 +210,11 @@ function sectionCountDetail(t: ReturnType, sectionCount function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnType }) { const sectionCount = song?.sections.length ?? 0; const confidenceOrder = { high: 3, medium: 2, low: 1 } as const; + const confidenceShortKeys = { + low: "confidenceShortLow", + medium: "confidenceShortMedium", + high: "confidenceShortHigh" + } as const satisfies Record; // Performance: Avoid O(N) array scan with .reduce() to find minimum confidence. // Instead use a for loop that can early exit (O(K)) as soon as the lowest bound ("low") is hit. @@ -224,7 +229,7 @@ function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnTy } } } - const confidence = lowestConfidence ? `${lowestConfidence[0].toUpperCase()}${lowestConfidence.slice(1)}` : t("metricConfidenceReady"); + const confidence = lowestConfidence ? t(confidenceShortKeys[lowestConfidence]) : t("metricConfidenceReady"); const detail = sectionCountDetail(t, sectionCount); return ( @@ -435,13 +440,13 @@ export function App() { setSelectionErrorSource(null); const normalizedUrl = youtubeUrl.trim(); if (!normalizedUrl) { - setSelectionError(t("youtubeImportFailed")); + setSelectionError(t("youtubeLinkGuidance")); setSelectionErrorSource("youtube"); return; } if (!isSupportedYoutubeUrl(normalizedUrl)) { - setSelectionError(t("youtubeImportFailed")); + setSelectionError(t("youtubeLinkGuidance")); setSelectionErrorSource("youtube"); return; } diff --git a/apps/desktop/src/App.youtube-url-guidance.test.tsx b/apps/desktop/src/App.youtube-url-guidance.test.tsx new file mode 100644 index 000000000..603cd0755 --- /dev/null +++ b/apps/desktop/src/App.youtube-url-guidance.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +// App mounts the Score surface, whose pdf.js bridge depends on browser canvas +// globals such as DOMMatrix that jsdom does not provide. This regression only +// exercises pre-network YouTube URL admission guidance. +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("App YouTube URL admission guidance", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("uses format guidance for a URL rejected before any import attempt", async () => { + setNavigatorLanguage("en-US"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + render(); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Use a standard YouTube video link (youtube.com/watch or youtu.be)."); + expect(alert).not.toHaveTextContent(/check your connection/i); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..a3bb93c0b 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -33,6 +33,8 @@ vi.mock("../../i18n", () => ({ scoreAttachFailed: "Could not attach the score PDF.", scoreReadFailed: "Could not open the score PDF.", scoreRemoveFailed: "Could not remove the score PDF.", + scoreDesktopOnly: "Score PDFs are only available in the desktop app.", + scoreInvalidResponse: "The score could not be prepared. Try adding it again.", scoreRequiresProject: "Scores attach to the active analysis project." })[key] ?? key, detectPreferredLocale: () => "en" @@ -153,7 +155,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Add score" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again."); }); it("opens an existing attachment through the read command", async () => { @@ -216,7 +218,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); expect(await screen.findByRole("alert")).toHaveTextContent( - "Could not open the score PDF. Invalid score bridge response" + "Could not open the score PDF. The score could not be prepared. Try adding it again." ); }); @@ -289,7 +291,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again."); }); it("fails closed when no desktop bridge is available", async () => { diff --git a/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts b/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts new file mode 100644 index 000000000..968699201 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { attachScorePdf } from "./scoreStorage"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("score storage buyer-visible error localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("keeps the browser-only score message in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + "악보 PDF는 BandScope 데스크톱 앱에서만 사용할 수 있습니다." + ); + }); + + it("keeps an invalid bridge response in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => ({}); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + "악보를 준비할 수 없습니다. 다시 추가해 주세요." + ); + }); +}); diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f12591..6c79cf431 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { ScoreAttachment } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -14,8 +15,13 @@ type TauriBridgeWindow = Window & { */ export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; -const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; -const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const BRIDGE_UNAVAILABLE_KEY = "scoreDesktopOnly" satisfies TranslationKey; +const INVALID_RESPONSE_KEY = "scoreInvalidResponse" satisfies TranslationKey; + +/** Resolve a buyer-visible score-storage message in the currently selected locale. */ +function scoreMessage(key: TranslationKey): string { + return createTranslator(detectPreferredLocale())(key); +} /** * Resolve the desktop invoke bridge following the same detection rules as @@ -41,12 +47,12 @@ function getInvoke(): TauriInvoke | null { /** * Invoke a score storage command on the desktop bridge, failing closed with - * a stable error when no bridge is available (browser preview builds). + * a stable localized error when no bridge is available (browser preview builds). */ async function invokeScoreCommand(command: string, args: Record): Promise { const invokeCommand = getInvoke(); if (!invokeCommand) { - throw new Error(BRIDGE_UNAVAILABLE_MESSAGE); + throw new Error(scoreMessage(BRIDGE_UNAVAILABLE_KEY)); } return invokeCommand(command, args); @@ -67,7 +73,7 @@ export async function attachScorePdf(projectId: string, songId: string): Promise typeof (response as Record).fileName !== "string" || typeof (response as Record).fileSizeBytes !== "number" ) { - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } const payload = response as { scoreId: string; fileName: string; fileSizeBytes: number }; @@ -95,7 +101,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< return Uint8Array.from(response as number[]); } - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } /** @@ -105,7 +111,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< export async function removeScorePdf(projectId: string, scoreId: string): Promise { const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId }); if (typeof response !== "boolean") { - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } return response; diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx index f3da0b8e0..7561c5425 100644 --- a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx @@ -32,7 +32,7 @@ export function ConfidenceBadge({ level }: ConfidenceBadgeProps) { {label} diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx index 2745d4d79..7300bc683 100644 --- a/apps/desktop/src/features/workspace/GrooveMap.tsx +++ b/apps/desktop/src/features/workspace/GrooveMap.tsx @@ -1,5 +1,6 @@ import { memo, useMemo } from "react"; import type { TranscriptionNote } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; @@ -13,6 +14,7 @@ interface GrooveMapProps { /** Documented. */ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); const renderedNotes = notes ?? EMPTY_NOTES; // Find max offset to determine timeline width @@ -44,10 +46,10 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { > - ); @@ -58,7 +60,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
- No bass line transcription yet. Use it when you want to check the groove before rehearsal. + {t("grooveEmptyHint")}
); } @@ -68,13 +70,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" role="region" tabIndex={0} - aria-label="Bass transcription groove map" + aria-label={t("grooveMapAriaLabel")} >
- Transcription complete. {renderedNotes.length} notes analyzed. + {t("grooveCompleteSrLabel").replace("{count}", String(renderedNotes.length))}

- {renderedNotes.length} notes mapped for rehearsal + {t("grooveNotesMappedLabel").replace("{count}", String(renderedNotes.length))}

diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..dfd6a4cd7 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -96,9 +96,29 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement; + const transcribeButton = screen.getByRole("button", { name: "Map bass notes" }) as HTMLButtonElement; expect(transcribeButton.disabled).toBe(false); - expect(transcribeButton.title).toBe("Transcribe part"); + expect(transcribeButton.title).toBe("Show this part's bass notes on the groove map."); + }); + + it("labels unavailable transcription for the selected non-bass role instead of bass", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal" + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const pendingButton = screen.getByRole("button", { name: "Lead Vocal · Coming soon" }); + expect(pendingButton.textContent).toBe("Lead Vocal · Coming soon"); + expect(pendingButton.textContent).not.toContain("Map bass notes"); + expect(pendingButton.getAttribute("title")).toBe( + "Lead Vocal part is coming soon — bass is ready first." + ); }); it("renders bass transcription in the dark rehearsal cockpit system", () => { @@ -115,7 +135,7 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i }); + const grooveMap = screen.getByRole("region", { name: /bass groove map/i }); expect(grooveMap.className).toContain("bg-slate-950"); expect(screen.getByText("E2")).toBeTruthy(); expect(screen.getByText("G2")).toBeTruthy(); @@ -270,4 +290,4 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); -}); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx new file mode 100644 index 000000000..92c5fde84 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace song timeline summary", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("uses cardinal-safe English copy for a one-section song", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = song.sections.slice(0, 1); + + render(); + + expect( + screen.getByText( + "Sections mapped: 1. Use the groove, role cues, and chord confidence notes to plan the first pass." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..92dad17f4 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -83,7 +83,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R role="region" tabIndex={0} className="overflow-x-auto rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(8,18,35,0.96),rgba(2,6,23,0.98))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" - aria-label="Scrollable song structure timeline" + aria-label={t("songStructureTimelineAriaLabel")} >
@@ -292,7 +292,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceSongTimelineLabel")}

- {song.sections.length} section{song.sections.length === 1 ? "" : "s"} mapped with groove, role cues, and chord confidence notes. + {t("songTimelineSummary").replace("{count}", String(song.sections.length))}

@@ -320,13 +320,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceStemsLabel")}

-

Stem lanes will appear when separation results are available.

+

{t("stemsEmptyHint")}

{t("workspaceRehearsalPrioritiesLabel")}

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. + {t("rehearsalFocusPrefix")} {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || t("rehearsalFocusFallback")}.

@@ -337,7 +337,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRolesHarmonyLabel")}

-

Filter the board by player or vocal role without losing the full form context.

+

{t("rolesHarmonyHint")}

-

Stem Player

+

{t("stemPlayerTitle")}

{activeRoleDetails?.name ?? activeRole}

{canTranscribeBass ? ( ) : ( )}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..9ee10589a 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -59,7 +59,7 @@ describe("i18n", () => { it("translates to Korean explicitly", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); - expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("appSubtitle")).toBe("합주 준비를 도와주는 밴드 메이트"); }); it("falls back to English when a Korean translation is missing", () => { @@ -69,7 +69,7 @@ describe("i18n", () => { delete koDictionary.appSubtitle; try { - expect(t("appSubtitle")).toBe("Local-first desktop analysis tool for rehearsal prep"); + expect(t("appSubtitle")).toBe("Your band mate for rehearsal prep"); } finally { koDictionary.appSubtitle = originalSubtitle; } diff --git a/apps/desktop/src/lib/analysis.localized-errors.test.ts b/apps/desktop/src/lib/analysis.localized-errors.test.ts new file mode 100644 index 000000000..e39d72b95 --- /dev/null +++ b/apps/desktop/src/lib/analysis.localized-errors.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getAnalysisJobStatus, + importYoutubeUrl, + loadProject, + selectLocalAudioSource +} from "./analysis"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("analysis buyer-visible fallback localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("returns Korean guidance for an invalid YouTube URL", async () => { + setNavigatorLanguage("ko-KR"); + const result = await importYoutubeUrl("https://example.com/not-youtube"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "표준 유튜브 영상 링크(youtube.com/watch 또는 youtu.be)를 사용해 주세요." + ); + } + }); + + it("describes an invalid English YouTube URL as a format problem before import", async () => { + setNavigatorLanguage("en-US"); + const result = await importYoutubeUrl("https://example.com/not-youtube"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "Use a standard YouTube video link (youtube.com/watch or youtu.be)." + ); + } + }); + + it.each([ + ["Could not read the selected audio file.", "선택한 오디오 파일을 읽을 수 없습니다. 파일을 다시 선택해 주세요."], + ["Could not prepare the local project workspace.", "프로젝트 작업 공간을 준비할 수 없습니다. 저장 위치를 확인한 뒤 다시 시도해 주세요."], + ["Could not prepare the local cache workspace.", "분석 캐시를 준비할 수 없습니다. 잠시 후 다시 시도해 주세요."], + ["Could not prepare the local temp workspace.", "분석 임시 공간을 준비할 수 없습니다. 잠시 후 다시 시도해 주세요."] + ])("preserves distinct Korean next actions for safe local failure %s", async (bridgeMessage, expected) => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error(bridgeMessage); + }; + + const result = await selectLocalAudioSource(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe(expected); + } + }); + + it("does not surface unknown local bridge errors in Korean UI", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("sensitive implementation detail"); + }; + + const result = await selectLocalAudioSource(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요." + ); + expect(result.error.message).not.toContain("sensitive implementation detail"); + } + }); + + it("describes an unknown browser analysis job as not found", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + const status = await getAnalysisJobStatus("missing-job"); + + expect(status.state).toBe("failed"); + expect(status.error?.code).toBe("not_found"); + expect(status.error?.message).toBe( + "해당 분석 작업을 찾을 수 없습니다. 분석을 다시 시작해 주세요." + ); + }); + + it("keeps the browser project fallback in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + await expect(loadProject()).rejects.toThrow( + "프로젝트는 BandScope 데스크톱 앱에서 열어 주세요." + ); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..91cfc7824 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -56,7 +56,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -71,7 +71,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -130,14 +130,14 @@ describe("analysis bridge", () => { const running = await getAnalysisJobStatus(queued.jobId); expect(running).toMatchObject({ state: "running", - progressLabel: "Decoding audio", + progressLabel: "Reading your track", progressStage: "decode", progressPercent: 20 }); expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ state: "running", - progressLabel: "Separating stems... (45%)", + progressLabel: "Separating stems", progressStage: "separate", progressPercent: 45 }); @@ -149,7 +149,7 @@ describe("analysis bridge", () => { }); expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ state: "running", - progressLabel: "Saving reusable features", + progressLabel: "Preparing results for next time", progressStage: "persist", progressPercent: 90 }); @@ -181,7 +181,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -196,7 +196,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -213,7 +213,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -233,7 +233,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..e18a6bc42 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -15,6 +15,7 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../i18n"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -29,18 +30,22 @@ declare global { const browserJobStore = new Map(); const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 }, - { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } -] as const; + { progressLabelKey: "analysisProgressReadingTrack", progressStage: "decode", progressPercent: 20 }, + { progressLabelKey: "analysisProgressSeparatingStems", progressStage: "separate", progressPercent: 45 }, + { progressLabelKey: "analysisProgressBuildingCues", progressStage: "analyze", progressPercent: 70 }, + { progressLabelKey: "analysisProgressPreparingResults", progressStage: "persist", progressPercent: 90 } +] as const satisfies readonly { + progressLabelKey: TranslationKey; + progressStage: string; + progressPercent: number; +}[]; 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, - "Could not read the selected audio file.", - "Could not prepare the local project workspace.", - "Could not prepare the local cache workspace.", - "Could not prepare the local temp workspace." +const SAFE_LOCAL_AUDIO_MESSAGE_KEYS = new Map([ + [UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "unsupportedLocalAudio"], + ["Could not read the selected audio file.", "localAudioReadFailed"], + ["Could not prepare the local project workspace.", "localProjectWorkspaceFailed"], + ["Could not prepare the local cache workspace.", "localCacheWorkspaceFailed"], + ["Could not prepare the local temp workspace.", "localTempWorkspaceFailed"] ]); const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -52,6 +57,22 @@ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } | { ok: false; error: AnalysisJobError }; +/** Resolve a buyer-visible analysis message in the currently selected locale. */ +function analysisMessage(key: TranslationKey): string { + return createTranslator(detectPreferredLocale())(key); +} + +/** Map bridge-safe local-audio failures to localized customer guidance without exposing unknown details. */ +function localAudioSelectionMessage(error: unknown): string { + if (error instanceof Error) { + const messageKey = SAFE_LOCAL_AUDIO_MESSAGE_KEYS.get(error.message); + if (messageKey) { + return analysisMessage(messageKey); + } + } + return analysisMessage("unsupportedLocalAudio"); +} + /** Documented. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { @@ -119,7 +140,7 @@ async function browserFallback(command: string, args?: Record): const queued = createAnalysisJobStatus({ jobId, state: "queued", - progressLabel: "Queued for analysis", + progressLabel: analysisMessage("analysisStateQueued"), progressStage: "queued", progressPercent: 0, cacheStatus: "disabled" @@ -141,7 +162,7 @@ async function browserFallback(command: string, args?: Record): state: "failed", error: { code: "not_found", - message: "Analysis job was not found." + message: analysisMessage("analysisJobNotFound") } }); } @@ -153,7 +174,7 @@ async function browserFallback(command: string, args?: Record): jobId, state: "running", requestedAt: existing.requestedAt, - progressLabel: nextStep.progressLabel, + progressLabel: analysisMessage(nextStep.progressLabelKey), progressStage: nextStep.progressStage, progressPercent: nextStep.progressPercent, cacheStatus: "disabled" @@ -165,7 +186,7 @@ async function browserFallback(command: string, args?: Record): const succeeded = createAnalysisJobStatus({ jobId, state: "succeeded", - progressLabel: "Analysis ready", + progressLabel: analysisMessage("analysisStateSucceeded"), progressStage: "ready", progressPercent: 100, cacheStatus: "disabled", @@ -182,7 +203,7 @@ async function browserFallback(command: string, args?: Record): if (command === "import_youtube_url") { if (!isSupportedYoutubeUrl(args?.url)) { - throw new Error("Only standard YouTube URLs are supported."); + throw new Error(analysisMessage("youtubeLinkGuidance")); } const projectId = "browser-youtube-project"; @@ -201,10 +222,10 @@ async function browserFallback(command: string, args?: Record): } if (command === "load_project") { - throw new Error("Local load not supported in browser"); + throw new Error(analysisMessage("projectsDesktopOnly")); } - throw new Error(`Unknown analysis bridge command: ${command}`); + throw new Error(analysisMessage("actionUnavailable")); } /** Documented. */ @@ -235,10 +256,7 @@ export async function selectLocalAudioSource(): Promise { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("gives an admitted English YouTube link a connection-or-availability next action", async () => { + setNavigatorLanguage("en-US"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("provider detail must stay private"); + }; + + const result = await importYoutubeUrl("https://youtube.com/watch?v=abc123DEF45"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again." + ); + expect(result.error.message).not.toContain("standard YouTube video link"); + expect(result.error.message).not.toContain("provider detail must stay private"); + } + }); + + it("gives an admitted Korean YouTube link the same actionable semantics", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("provider detail must stay private"); + }; + + const result = await importYoutubeUrl("https://youtu.be/abc123DEF45"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "유튜브 URL 가져오기에 실패했습니다. 네트워크 연결과 영상 이용 가능 여부를 확인한 뒤 다시 시도해 주세요." + ); + expect(result.error.message).not.toContain("표준 유튜브 영상"); + expect(result.error.message).not.toContain("provider detail must stay private"); + } + }); +}); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..7f7d69f42 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -1,11 +1,11 @@ { "appTitle": "BandScope", - "appSubtitle": "Local-first desktop analysis tool for rehearsal prep", - "homeCard": "Home functionality is ready.", - "playerCard": "Player functionality is ready.", - "chordsCard": "Chord analysis functionality is ready.", - "rangesCard": "Range analysis functionality is ready.", - "settingsCard": "Settings functionality is ready.", + "appSubtitle": "Your band mate for rehearsal prep", + "homeCard": "Import a song to open your rehearsal prep view.", + "playerCard": "Once analysis finishes, listen to each part here.", + "chordsCard": "Chord flow by section and role appears here.", + "rangesCard": "Check each role's range and clash points here.", + "settingsCard": "Settings will appear here when needed.", "supportedFormats": "Supported input formats", "chooseLocalAudio": "Choose local audio", "selectedAudio": "Selected audio", @@ -17,11 +17,20 @@ "manualOverride": "manual override", "startAnalysis": "Start analysis", "startingAnalysis": "Starting...", - "analysisCouldNotStart": "Analysis could not start.", + "analysisCouldNotStart": "Analysis could not start. Check the selected audio or YouTube link, then try again.", + "localAudioReadFailed": "The selected audio file could not be read. Choose the file again.", + "localProjectWorkspaceFailed": "The project workspace could not be prepared. Check the save location, then try again.", + "localCacheWorkspaceFailed": "The analysis cache could not be prepared. Try again in a moment.", + "localTempWorkspaceFailed": "The analysis temporary workspace could not be prepared. Try again in a moment.", "analysisStateQueued": "Queued for analysis", "analysisStateRunning": "Running analysis", "analysisStateSucceeded": "Analysis ready", - "analysisStateFailed": "Analysis failed during execution.", + "analysisStateFailed": "The analysis stopped partway through. Try running it again.", + "analysisJobNotFound": "That analysis job could not be found. Start the analysis again.", + "analysisProgressReadingTrack": "Reading your track", + "analysisProgressSeparatingStems": "Separating stems", + "analysisProgressBuildingCues": "Building rehearsal cues", + "analysisProgressPreparingResults": "Preparing results for next time", "confidenceLevelLow": "Low confidence", "confidenceLevelMedium": "Needs ear check", "confidenceLevelHigh": "Ready to trust", @@ -63,7 +72,7 @@ "roleSwitcherTitle": "Role-specific View", "allRoles": "All Roles", "overlapWarning": "Clash warning", - "scoreViewerEmpty": "No score PDF attached. Attach a validated score PDF to view it here.", + "scoreViewerEmpty": "No score attached yet. Add a PDF score and open it anytime during rehearsal.", "scoreViewerLoading": "Loading score PDF...", "scoreViewerFailedTitle": "Could not display the score", "scoreViewerRetry": "Retry", @@ -87,12 +96,15 @@ "scoreAttachFailed": "Could not attach the score PDF.", "scoreReadFailed": "Could not open the score PDF.", "scoreRemoveFailed": "Could not remove the score PDF.", + "scoreDesktopOnly": "Score PDFs are only available in the desktop app.", + "scoreInvalidResponse": "The score could not be prepared. Try adding it again.", "scoreRequiresProject": "Scores attach to the active analysis project. Analyze local audio or a YouTube import first.", "scoreNavDisabledHint": "Analyze or open a song first", "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", - "youtubeImportFailed": "Failed to import YouTube URL.", + "youtubeImportFailed": "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again.", + "youtubeLinkGuidance": "Use a standard YouTube video link (youtube.com/watch or youtu.be).", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", @@ -132,21 +144,48 @@ "metricConfidenceLabel": "Confidence", "metricPriorityLabel": "Priority", "metricPendingValue": "Pending", - "metricTempoPendingDetail": "Awaiting reliable detection", - "metricKeyPendingDetail": "No trusted key yet", - "metricTransposePendingDetail": "Review after key detection", + "metricTempoPendingDetail": "Appears once the song is analyzed", + "metricKeyPendingDetail": "Detected during analysis", + "metricTransposePendingDetail": "Suggested once the key is known", "metricConfidenceReady": "Ready", "metricConfidenceLocalAnalysis": "Local analysis", "metricConfidenceSectionSingular": "section", "metricConfidenceSectionPlural": "sections", "metricPriorityFallback": "Pick track", - "metricPriorityPendingDetail": "Choose or open audio", + "metricPriorityPendingDetail": "Pick a song to see what to practice first", "loadProjectFailedPrefix": "Failed to load project", - "loadProjectFailedFallback": "The selected project could not be loaded.", + "loadProjectFailedFallback": "The selected project could not be loaded. Pick the file again or try another project.", + "projectsDesktopOnly": "Projects open in the BandScope desktop app.", + "actionUnavailable": "This action is not available right now.", "saveProjectFailedPrefix": "Failed to save project", - "saveProjectFailedFallback": "The project could not be saved.", + "saveProjectFailedFallback": "The project could not be saved. Try again in a moment.", "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" -} + "increasePracticeProgressLabel": "Increase progress", + "exportCueSheetButton": "Export Cue Sheet (CSV)", + "exportChartButton": "Export Chart (JSON)", + "exportHandoffButton": "Export Handoff (JSON)", + "songTimelineSummary": "Sections mapped: {count}. Use the groove, role cues, and chord confidence notes to plan the first pass.", + "stemsEmptyHint": "Part audio (stems) will show up here as soon as it's ready — play it on repeat while practicing.", + "rolesHarmonyHint": "Filter the board by player or vocal role without losing the full form context.", + "stemPlayerTitle": "Stem Player", + "stemPlayLabel": "Play stem", + "stemLoopLabel": "Loop section", + "stemSoloMuteLabel": "Solo / mute others", + "transcribeBassLabel": "Map bass notes", + "transcribeBassHint": "Show this part's bass notes on the groove map.", + "transcribeRolePendingSuffix": "part is coming soon — bass is ready first.", + "rehearsalFocusPrefix": "Focus:", + "rehearsalFocusFallback": "first pass", + "songStructureTimelineAriaLabel": "Scrollable song structure timeline", + "grooveLoadingLabel": "Checking the bass line...", + "grooveCancelLabel": "Cancel", + "grooveEmptyHint": "No bass line yet. Once ready, use it to lock the groove before rehearsal.", + "grooveMapAriaLabel": "Bass groove map", + "grooveCompleteSrLabel": "{count} notes mapped.", + "grooveNotesMappedLabel": "{count} notes mapped for rehearsal", + "confidenceShortLow": "Low", + "confidenceShortMedium": "Medium", + "confidenceShortHigh": "High" +} \ No newline at end of file diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..fb4e33726 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -1,11 +1,11 @@ { "appTitle": "BandScope", - "appSubtitle": "합주 준비를 위한 로컬-퍼스트 분석 도구", - "homeCard": "홈 기능이 준비되었습니다.", - "playerCard": "플레이어 기능이 준비되었습니다.", - "chordsCard": "코드 분석 기능이 준비되었습니다.", - "rangesCard": "음역 분석 기능이 준비되었습니다.", - "settingsCard": "설정 기능이 준비되었습니다.", + "appSubtitle": "합주 준비를 도와주는 밴드 메이트", + "homeCard": "곡을 가져오면 합주 준비 화면이 열립니다.", + "playerCard": "분석이 끝나면 파트별 소리를 여기서 들을 수 있습니다.", + "chordsCard": "구간과 역할별 코드 흐름이 여기에 정리됩니다.", + "rangesCard": "역할별 음역과 겹치는 구간을 여기서 확인하세요.", + "settingsCard": "필요한 설정이 생기면 이곳에 표시됩니다.", "supportedFormats": "지원 입력 형식", "chooseLocalAudio": "로컬 오디오 선택", "selectedAudio": "선택한 오디오", @@ -17,11 +17,20 @@ "manualOverride": "수동 수정", "startAnalysis": "분석 시작", "startingAnalysis": "시작 중...", - "analysisCouldNotStart": "분석을 시작할 수 없습니다.", + "analysisCouldNotStart": "분석을 시작할 수 없습니다. 선택한 오디오나 유튜브 링크를 확인한 뒤 다시 시도해 주세요.", + "localAudioReadFailed": "선택한 오디오 파일을 읽을 수 없습니다. 파일을 다시 선택해 주세요.", + "localProjectWorkspaceFailed": "프로젝트 작업 공간을 준비할 수 없습니다. 저장 위치를 확인한 뒤 다시 시도해 주세요.", + "localCacheWorkspaceFailed": "분석 캐시를 준비할 수 없습니다. 잠시 후 다시 시도해 주세요.", + "localTempWorkspaceFailed": "분석 임시 공간을 준비할 수 없습니다. 잠시 후 다시 시도해 주세요.", "analysisStateQueued": "분석 대기 중", "analysisStateRunning": "분석 실행 중", "analysisStateSucceeded": "분석 준비 완료", - "analysisStateFailed": "분석 실행 중 실패했습니다.", + "analysisStateFailed": "분석이 끝까지 진행되지 않았어요. 다시 실행해 주세요.", + "analysisJobNotFound": "해당 분석 작업을 찾을 수 없습니다. 분석을 다시 시작해 주세요.", + "analysisProgressReadingTrack": "선택한 곡을 읽는 중", + "analysisProgressSeparatingStems": "파트 소리를 분리하는 중", + "analysisProgressBuildingCues": "합주 큐를 만드는 중", + "analysisProgressPreparingResults": "다음 합주를 위해 결과를 정리하는 중", "confidenceLevelLow": "확신이 낮음", "confidenceLevelMedium": "귀로 한 번 더 확인", "confidenceLevelHigh": "믿고 가져가도 됨", @@ -63,7 +72,7 @@ "roleSwitcherTitle": "악기/보컬 역할", "allRoles": "전체 보기", "overlapWarning": "충돌 주의", - "scoreViewerEmpty": "첨부된 악보 PDF가 없습니다. 검증된 악보 PDF를 첨부하면 여기에 표시됩니다.", + "scoreViewerEmpty": "첨부된 악보가 없습니다. 악보를 추가하면 합주 중에 바로 펼쳐볼 수 있어요.", "scoreViewerLoading": "악보 PDF를 불러오는 중...", "scoreViewerFailedTitle": "악보를 표시할 수 없습니다", "scoreViewerRetry": "다시 시도", @@ -87,12 +96,15 @@ "scoreAttachFailed": "악보 PDF를 첨부하지 못했습니다.", "scoreReadFailed": "악보 PDF를 열지 못했습니다.", "scoreRemoveFailed": "악보 PDF를 삭제하지 못했습니다.", + "scoreDesktopOnly": "악보 PDF는 BandScope 데스크톱 앱에서만 사용할 수 있습니다.", + "scoreInvalidResponse": "악보를 준비할 수 없습니다. 다시 추가해 주세요.", "scoreRequiresProject": "악보는 활성 분석 프로젝트에 첨부됩니다. 먼저 로컬 오디오나 유튜브 가져오기로 분석을 실행하세요.", "scoreNavDisabledHint": "먼저 곡을 분석하거나 프로젝트를 여세요", "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", - "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", + "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다. 네트워크 연결과 영상 이용 가능 여부를 확인한 뒤 다시 시도해 주세요.", + "youtubeLinkGuidance": "표준 유튜브 영상 링크(youtube.com/watch 또는 youtu.be)를 사용해 주세요.", "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", @@ -132,21 +144,48 @@ "metricConfidenceLabel": "신뢰도", "metricPriorityLabel": "우선순위", "metricPendingValue": "대기 중", - "metricTempoPendingDetail": "신뢰 가능한 감지 대기", - "metricKeyPendingDetail": "아직 신뢰할 키 없음", - "metricTransposePendingDetail": "키 감지 후 검토", + "metricTempoPendingDetail": "곡을 분석하면 표시돼요", + "metricKeyPendingDetail": "분석에서 키를 찾아드릴게요", + "metricTransposePendingDetail": "키가 정해지면 전조 후보를 알려드려요", "metricConfidenceReady": "준비됨", "metricConfidenceLocalAnalysis": "로컬 분석", "metricConfidenceSectionSingular": "구간", "metricConfidenceSectionPlural": "구간", "metricPriorityFallback": "트랙 선택", - "metricPriorityPendingDetail": "오디오를 선택하거나 여세요", + "metricPriorityPendingDetail": "곡을 고르면 먼저 연습할 구간을 알려드려요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", - "loadProjectFailedFallback": "선택한 프로젝트를 불러올 수 없습니다.", + "loadProjectFailedFallback": "선택한 프로젝트를 불러올 수 없습니다. 파일을 다시 선택하거나 다른 프로젝트를 열어 주세요.", + "projectsDesktopOnly": "프로젝트는 BandScope 데스크톱 앱에서 열어 주세요.", + "actionUnavailable": "지금은 이 작업을 사용할 수 없습니다.", "saveProjectFailedPrefix": "프로젝트를 저장하지 못했습니다", - "saveProjectFailedFallback": "프로젝트를 저장할 수 없습니다.", + "saveProjectFailedFallback": "프로젝트를 저장할 수 없습니다. 잠시 후 다시 시도해 주세요.", "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "exportCueSheetButton": "큐시트 내보내기(CSV)", + "exportChartButton": "차트 내보내기(JSON)", + "exportHandoffButton": "인계 노트 내보내기(JSON)", + "songTimelineSummary": "{count}개 구간을 그루브·역할 큐·코드 확신도와 함께 정리했어요.", + "stemsEmptyHint": "파트 소리(스템)가 준비되는 대로 이곳에 표시돼요. 반복해서 들으며 연습에 바로 쓸 수 있어요.", + "rolesHarmonyHint": "연주자나 보컬 역할별로 골라 보세요. 곡 전체 흐름은 그대로 유지돼요.", + "stemPlayerTitle": "스템 플레이어", + "stemPlayLabel": "스템 재생", + "stemLoopLabel": "구간 반복", + "stemSoloMuteLabel": "솔로 · 나머지 줄이기", + "transcribeBassLabel": "베이스 노트 표시", + "transcribeBassHint": "이 파트의 베이스 노트를 그루브 지도에 표시합니다.", + "transcribeRolePendingSuffix": "파트는 곧 제공됩니다. 먼저 베이스로 시작해 보세요.", + "rehearsalFocusPrefix": "먼저 볼 구간:", + "rehearsalFocusFallback": "첫 패스", + "songStructureTimelineAriaLabel": "좌우로 넘겨 보는 곡 구조 타임라인", + "grooveLoadingLabel": "베이스 라인을 확인하는 중...", + "grooveCancelLabel": "취소", + "grooveEmptyHint": "아직 베이스 라인이 없습니다. 준비되면 합주 전에 그루브를 눈으로 확인할 수 있어요.", + "grooveMapAriaLabel": "베이스 그루브 지도", + "grooveCompleteSrLabel": "{count}개 노트를 정리했어요.", + "grooveNotesMappedLabel": "{count}개 노트를 합주용으로 정리했어요.", + "confidenceShortLow": "낮음", + "confidenceShortMedium": "보통", + "confidenceShortHigh": "높음" }