From 53601a9aa07c562ef17c90bb2772771e4e8c04c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 26 Mar 2026 09:03:00 +0900 Subject: [PATCH] feat: implement practical rehearsal workspace UI (Issue #28) --- apps/desktop/src/App.test.tsx | 154 ++++++++++++------ apps/desktop/src/App.tsx | 146 +++++++---------- .../features/workspace/ConfidenceBadge.tsx | 46 ++++++ .../src/features/workspace/RoleSwitcher.tsx | 50 ++++++ .../src/features/workspace/SectionRoadmap.tsx | 81 +++++++++ .../src/features/workspace/Workspace.tsx | 45 +++++ .../features/workspace/WorkspaceStates.tsx | 32 ++++ apps/desktop/src/locales/en/common.json | 9 +- apps/desktop/src/locales/ko/common.json | 9 +- 9 files changed, 431 insertions(+), 141 deletions(-) create mode 100644 apps/desktop/src/features/workspace/ConfidenceBadge.tsx create mode 100644 apps/desktop/src/features/workspace/RoleSwitcher.tsx create mode 100644 apps/desktop/src/features/workspace/SectionRoadmap.tsx create mode 100644 apps/desktop/src/features/workspace/Workspace.tsx create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.tsx diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 057b4bf2..479caf36 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -37,6 +37,7 @@ function succeededResult() { id: "verse-1", label: "Verse 1", groove: "Straight eighths with a late snare feel", + timeRange: { start: 10, end: 30 }, confidence: { level: "medium", source: "model", @@ -75,6 +76,7 @@ function succeededResult() { rehearsalPriority: "medium", simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", setupNote: "Watch the breath before the last line of the verse.", + overlapWarnings: [{ targetRoleId: "keys-right", severity: "high", description: "Clash" }], manualOverrides: [ { field: "harmony", @@ -199,6 +201,19 @@ describe("App", () => { it("starts an analysis job and renders the returned rehearsal result", async () => { tauriInvoke + .mockResolvedValueOnce({ + 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 + } + }) .mockResolvedValueOnce({ jobId: "job-1", state: "queued", @@ -209,6 +224,11 @@ describe("App", () => { .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 })); @@ -219,41 +239,39 @@ describe("App", () => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - expect(screen.getByText(/Bass Guitar/i)).toBeTruthy(); - expect(tauriInvoke).toHaveBeenNthCalledWith(1, "start_analysis_job", { + expect(screen.getAllByText(/Bass Guitar/i).length).toBeGreaterThan(0); + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { request: { - sourceKind: "demo", - sourceLabel: "Late Night Set", + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] } }); - expect(tauriInvoke).toHaveBeenNthCalledWith(2, "get_analysis_job_status", { - jobId: "job-1" - }); }); it("shows a safe failed status when the job poll returns an error", async () => { tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) .mockResolvedValueOnce({ jobId: "job-2", - state: "running", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - progressLabel: "Running analysis" + state: "running" }) .mockResolvedValueOnce({ jobId: "job-2", state: "failed", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - error: { - code: "engine_unavailable", - message: "Analysis engine is unavailable." - } + error: { 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(() => { @@ -263,46 +281,51 @@ describe("App", () => { it("falls back to a generic failure message when the engine omits details", async () => { tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) .mockResolvedValueOnce({ jobId: "job-3", - state: "running", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - progressLabel: "Running analysis" + state: "running" }) .mockResolvedValueOnce({ jobId: "job-3", state: "failed", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:01.000Z", - error: { - code: "engine_unavailable" - } + error: { code: "engine_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.getByText(/analysis could not start/i)).toBeTruthy(); - expect(screen.getByText(/analysis failed during execution/i)).toBeTruthy(); }); }); it("shows a generic failure when polling rejects", async () => { tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) .mockResolvedValueOnce({ jobId: "job-4", - state: "running", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - progressLabel: "Running analysis" + state: "running" }) .mockRejectedValueOnce(new Error("transport down")); 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(() => { @@ -311,10 +334,19 @@ describe("App", () => { }); it("shows a generic failure when starting the job rejects", async () => { - tauriInvoke.mockRejectedValueOnce(new Error("invoke failed")); + tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) + .mockRejectedValueOnce(new Error("invoke failed")); 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(() => { @@ -323,19 +355,23 @@ describe("App", () => { }); it("shows the direct failure message when start returns a failed job", async () => { - tauriInvoke.mockResolvedValueOnce({ - jobId: "job-5", - state: "failed", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z", - error: { - code: "engine_unavailable", - message: "Analysis queue is full. Please wait for a running job to finish." - } - }); + tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) + .mockResolvedValueOnce({ + jobId: "job-5", + state: "failed", + error: { message: "Analysis queue is full. Please wait for a running job to finish." } + }); 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(() => { @@ -344,15 +380,22 @@ describe("App", () => { }); it("falls back to generic text when start returns a failed job without details", async () => { - tauriInvoke.mockResolvedValueOnce({ - jobId: "job-6", - state: "failed", - requestedAt: "2026-03-12T00:00:00.000Z", - updatedAt: "2026-03-12T00:00:00.000Z" - }); + tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) + .mockResolvedValueOnce({ + jobId: "job-6", + state: "failed" + }); 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(() => { @@ -361,15 +404,24 @@ describe("App", () => { }); it("renders the result immediately when start returns a succeeded job", async () => { - tauriInvoke.mockResolvedValueOnce(succeededResult()); + tauriInvoke + .mockResolvedValueOnce({ + projectId: "project-1", + sourceMode: "reference", + source: { fileName: "late-night-set.wav" } + }) + .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.getByText(/manual override: C#m11 \(User-confirmed\)/i)).toBeTruthy(); + expect(screen.getByText(/Section Roadmap/i)).toBeTruthy(); }); - expect(tauriInvoke).toHaveBeenCalledTimes(1); + expect(tauriInvoke).toHaveBeenCalledTimes(2); // select + start }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index af8b300e..053f4534 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -6,11 +6,6 @@ import { type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; -import { ChordsFeature } from "./features/chords"; -import { HomeFeature } from "./features/home"; -import { PlayerFeature } from "./features/player"; -import { RangesFeature } from "./features/ranges"; -import { SettingsFeature } from "./features/settings"; import { createDefaultAnalysisRequest, getAnalysisJobStatus, @@ -18,6 +13,8 @@ import { startAnalysisJob } from "./lib/analysis"; import { createTranslator, detectPreferredLocale } from "./i18n"; +import { Workspace } from "./features/workspace/Workspace"; +import { EmptyState, LoadingState, ErrorState } from "./features/workspace/WorkspaceStates"; const ANALYSIS_POLL_INTERVAL_MS = 250; @@ -37,51 +34,6 @@ function progressMessage( } } -function renderSong( - song: RehearsalSong, - sectionConfidenceLabel: string, - roleConfidenceLabel: string, - harmonySourceLabel: string, - manualOverrideLabel: string, - confidenceLabels: Record<"low" | "medium" | "high", string>, - provenanceLabels: Record<"model" | "user", string> -) { - return ( - <> -
-

{song.title}

-

{song.exportSummary.headline}

-
- {song.sections.map((section) => ( -
-

{section.label}

-

{section.groove}

-

- {sectionConfidenceLabel}: {confidenceLabels[section.confidence.level]} ({provenanceLabels[section.confidence.source]}) -

-
    - {section.roles.map((role) => ( -
  • - {role.name} - - {role.harmony.chord} - - {role.cue.value} - - {roleConfidenceLabel}: {confidenceLabels[role.confidence.level]} - - {harmonySourceLabel}: {provenanceLabels[role.harmony.source]} - {role.manualOverrides.map((override, index) => ( - - {" "} - - {manualOverrideLabel}: {override.value.chord} ({provenanceLabels[override.source]}) - - ))} -
  • - ))} -
-
- ))} - - ); -} - export function App() { const t = createTranslator(detectPreferredLocale()); const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []); @@ -91,15 +43,7 @@ export function App() { const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); const [selectionError, setSelectionError] = useState(null); - const confidenceLabels = { - low: t("confidenceLevelLow"), - medium: t("confidenceLevelMedium"), - high: t("confidenceLevelHigh") - } as const; - const provenanceLabels = { - model: t("provenanceSourceModel"), - user: t("provenanceSourceUser") - } as const; + const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; const selectedRequest: AnalysisJobRequest = selectedBootstrap ? { @@ -170,36 +114,62 @@ export function App() { setJobStatus(null); }; + const renderWorkspaceState = () => { + if (jobError) { + return ; + } + if (analysisInFlight || isStarting) { + return ; + } + if (jobResult) { + return ; + } + return ; + }; + return ( -
-

{t("appTitle")}

-

{t("appSubtitle")}

-

- {t("supportedFormats")}: {SUPPORTED_AUDIO_FORMATS.join(", ")} -

- - - {selectedBootstrap ?

{t("selectedAudio")}: {selectedBootstrap.source.fileName}

: null} - {selectedBootstrap ?

{t("sourceModeReference")}

: null} - {jobStatus ?

{progressMessage(t, jobStatus.state)}

: null} - {jobError ?

{jobError}

: null} - {selectionError ?

{selectionError}

: null} - {jobResult - ? renderSong( - jobResult, - t("sectionConfidence"), - t("roleConfidence"), - t("harmonySource"), - t("manualOverride"), - confidenceLabels, - provenanceLabels - ) - : null} - - - - - +
+
+

{t("appTitle")}

+

{t("appSubtitle")}

+
+ +
+ + +
+ +
+

+ {t("supportedFormats")}: {SUPPORTED_AUDIO_FORMATS.join(", ")} +

+ {selectedBootstrap && ( + <> +

{t("selectedAudio")}: {selectedBootstrap.source.fileName}

+

{t("sourceModeReference")}

+ + )} + {jobStatus &&

{progressMessage(t, jobStatus.state)}

} + {selectionError &&

{selectionError}

} +
+ +
+ {renderWorkspaceState()} +
); } diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx new file mode 100644 index 00000000..a6ab0c08 --- /dev/null +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx @@ -0,0 +1,46 @@ +import type { ConfidenceLevel } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; + +interface ConfidenceBadgeProps { + level: ConfidenceLevel; +} + +export function ConfidenceBadge({ level }: ConfidenceBadgeProps) { + const t = createTranslator(detectPreferredLocale()); + + let label = ""; + let color = ""; + + switch (level) { + case "low": + label = t("confidenceLevelLow"); + color = "#ff4d4f"; // Red-ish for warning + break; + case "medium": + label = t("confidenceLevelMedium"); + color = "#faad14"; // Orange/Yellow + break; + case "high": + label = t("confidenceLevelHigh"); + color = "#52c41a"; // Green + break; + } + + return ( + + {label} + + ); +} diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx new file mode 100644 index 00000000..851aa017 --- /dev/null +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -0,0 +1,50 @@ +import { createTranslator, detectPreferredLocale } from "../../i18n"; + +interface RoleSwitcherProps { + roles: { id: string; name: string }[]; + activeRole: string | null; + onRoleChange: (roleId: string | null) => void; +} + +export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) { + const t = createTranslator(detectPreferredLocale()); + + return ( +
+ {t("roleSwitcherTitle")}: + + {roles.map((role) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx new file mode 100644 index 00000000..34a273c2 --- /dev/null +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -0,0 +1,81 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { ConfidenceBadge } from "./ConfidenceBadge"; + +interface SectionRoadmapProps { + song: RehearsalSong; + activeRole: string | null; // null means all roles +} + +export function SectionRoadmap({ song, activeRole }: SectionRoadmapProps) { + const t = createTranslator(detectPreferredLocale()); + + return ( +
+

{t("sectionRoadmapTitle")}

+
+ {song.sections.map((section) => ( +
+
+

{section.label}

+ +
+ +
+

Groove: {section.groove}

+
+ +
+ {section.roles + .filter(role => !activeRole || role.id === activeRole) + .map(role => ( +
+
+ {role.name} + {role.confidence.level === "low" && ( + + ({t("confidenceLevelLow")}) + + )} +
+
+ Chord: {role.harmony.chord} +
+
+ Cue: {role.cue.value} +
+ {role.setupNote && ( +
+ 💡 {role.setupNote} +
+ )} + {role.simplification && ( +
+ ✨ {role.simplification} +
+ )} +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx new file mode 100644 index 00000000..32cd0d67 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -0,0 +1,45 @@ +import { useState, useMemo } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { RoleSwitcher } from "./RoleSwitcher"; +import { SectionRoadmap } from "./SectionRoadmap"; + +interface WorkspaceProps { + song: RehearsalSong; +} + +export function Workspace({ song }: WorkspaceProps) { + const [activeRole, setActiveRole] = useState(null); + + // Extract all unique roles from the song's sections + const allRoles = useMemo(() => { + const roleMap = new Map(); + song.sections.forEach(section => { + section.roles.forEach(role => { + if (!roleMap.has(role.id)) { + roleMap.set(role.id, role.name); + } + }); + }); + return Array.from(roleMap.entries()).map(([id, name]) => ({ id, name })); + }, [song]); + + return ( +
+
+

{song.title}

+

{song.exportSummary.headline}

+
+ + + + +
+ ); +} diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx new file mode 100644 index 00000000..022b5ae6 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -0,0 +1,32 @@ +import { createTranslator, detectPreferredLocale } from "../../i18n"; + +export function EmptyState() { + const t = createTranslator(detectPreferredLocale()); + return ( +
+

🎵

+

{t("workspaceEmptyState")}

+
+ ); +} + +export function LoadingState() { + const t = createTranslator(detectPreferredLocale()); + return ( +
+

+

{t("workspaceLoadingState")}

+
+ ); +} + +export function ErrorState({ error }: { error?: string }) { + const t = createTranslator(detectPreferredLocale()); + return ( +
+

+

{t("workspaceErrorState")}

+ {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 3027a655..f81f9012 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -25,5 +25,12 @@ "confidenceLevelMedium": "Needs ear check", "confidenceLevelHigh": "Ready to trust", "provenanceSourceModel": "Auto-detected", - "provenanceSourceUser": "User-confirmed" + "provenanceSourceUser": "User-confirmed", + "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "workspaceLoadingState": "Analyzing the song's form and instrument roles...", + "workspaceErrorState": "An error occurred during analysis. Please try again.", + "sectionRoadmapTitle": "Section Roadmap", + "roleSwitcherTitle": "Role-specific View", + "allRoles": "All Roles", + "overlapWarning": "Clash warning" } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 3401a92a..35289a0e 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -25,5 +25,12 @@ "confidenceLevelMedium": "귀로 한 번 더 확인", "confidenceLevelHigh": "믿고 가져가도 됨", "provenanceSourceModel": "자동 추정", - "provenanceSourceUser": "사용자 확인" + "provenanceSourceUser": "사용자 확인", + "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", + "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", + "sectionRoadmapTitle": "구간 흐름", + "roleSwitcherTitle": "악기/보컬 역할", + "allRoles": "전체 보기", + "overlapWarning": "충돌 주의" }