Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Before a song is selected, customer-facing copy must name the next rehearsal action (use your own song). Do not leave the empty workspace as a text-only prompt.
- 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.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Before local or YouTube source admission, the workspace empty card names using a local song as the next action and does not invent a bundled demo.

## Security source

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Before a song is chosen, the workspace names Use my own song as the next rehearsal action and keeps a licensed demo as an honest later slice.
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Before a song is selected, the workspace empty card must name Use my own song as the next action rather than leaving a text-only prompt.

Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.

## Common commands
Expand Down
159 changes: 159 additions & 0 deletions apps/desktop/src/App.localSelectionConcurrency.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { ProjectBootstrapSummary } 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";
import type { LocalAudioSelectionResult } from "./lib/analysis";

const analysisMocks = vi.hoisted(() => ({
getAnalysisJobStatus: vi.fn(),
importYoutubeUrl: vi.fn(),
isSupportedYoutubeUrl: vi.fn(() => false),
loadProject: vi.fn(),
saveProject: vi.fn(),
selectLocalAudioSource: vi.fn<() => Promise<LocalAudioSelectionResult>>(),
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: () => <div>Score view</div>,
}));

const selectedBootstrap = {
projectId: "project-local-intake",
sourceMode: "reference",
projectRoot: "/tmp/bandscope/projects/project-local-intake",
cacheRoot: "/tmp/bandscope/cache/project-local-intake",
tempRoot: "/tmp/bandscope/temp/project-local-intake",
source: {
sourcePath: "/Users/test/Music/selected-song.wav",
fileName: "selected-song.wav",
extension: "wav",
fileSizeBytes: 1024,
},
} satisfies ProjectBootstrapSummary;

type SuccessfulLocalAudioSelection = Extract<LocalAudioSelectionResult, { ok: true }>;

/**
* Security Notes:
* - The selected path is a synthetic test fixture and is not asserted as buyer-visible copy.
* - This test mocks the existing local-picker boundary and adds no filesystem, network, or IPC authority.
*/
describe("App local song intake concurrency", () => {
beforeEach(() => {
for (const mock of Object.values(analysisMocks)) {
mock.mockReset();
}
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false);
analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
});

it("allows only one local picker while the first selection is pending", async () => {
let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined;
analysisMocks.selectLocalAudioSource.mockImplementation(
() =>
new Promise<SuccessfulLocalAudioSelection>((resolve) => {
resolveSelection = resolve;
}),
);

render(<App />);

const emptyAction = screen.getByRole("button", { name: "Use my own song" });
const headerAction = screen.getByRole("button", { name: "Choose local audio" });
fireEvent.click(emptyAction);

await waitFor(() => {
expect(emptyAction).toBeDisabled();
expect(headerAction).toBeDisabled();
});

fireEvent.click(emptyAction);
fireEvent.click(headerAction);
emptyAction.removeAttribute("disabled");
expect(emptyAction).not.toBeDisabled();
fireEvent.click(emptyAction);
expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

resolveSelection?.({ ok: true, bootstrap: selectedBootstrap });
await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy());
});

it("blocks every YouTube source control while the local picker owns source selection", async () => {
let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined;
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true);
analysisMocks.selectLocalAudioSource.mockImplementation(
() =>
new Promise<SuccessfulLocalAudioSelection>((resolve) => {
resolveSelection = resolve;
}),
);

render(<App />);

const youtubeInput = screen.getByRole("textbox", { name: "YouTube URL" });
const youtubeImport = screen.getByRole("button", { name: "Import YouTube" });
fireEvent.change(youtubeInput, { target: { value: "https://www.youtube.com/watch?v=demo" } });
expect(youtubeImport).not.toBeDisabled();
expect(screen.getByRole("button", { name: "Clear YouTube URL" })).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Use my own song" }));

await waitFor(() => {
expect(youtubeInput).toBeDisabled();
expect(youtubeImport).toBeDisabled();
expect(screen.queryByRole("button", { name: "Clear YouTube URL" })).toBeNull();
});

youtubeImport.removeAttribute("disabled");
expect(youtubeImport).not.toBeDisabled();
fireEvent.click(youtubeImport);
expect(analysisMocks.importYoutubeUrl).not.toHaveBeenCalled();

resolveSelection?.({ ok: true, bootstrap: selectedBootstrap });
await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy());
});

it("blocks analysis while a replacement local picker is pending", async () => {
let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined;
analysisMocks.selectLocalAudioSource
.mockResolvedValueOnce({ ok: true, bootstrap: selectedBootstrap })
.mockImplementationOnce(
() =>
new Promise<SuccessfulLocalAudioSelection>((resolve) => {
resolveSelection = resolve;
}),
);

render(<App />);
fireEvent.click(screen.getByRole("button", { name: "Use my own song" }));
await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy());

const startAnalysis = screen.getByRole("button", { name: "Start analysis" });
expect(startAnalysis).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));

await waitFor(() => expect(startAnalysis).toBeDisabled());

resolveSelection?.({ ok: true, bootstrap: selectedBootstrap });
await waitFor(() => expect(startAnalysis).not.toBeDisabled());
});
});
20 changes: 20 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,25 @@ describe("App", () => {
expect(screen.getByText(/YouTube only leaves the app when you choose import/i)).toBeTruthy();
});

it("names using a local song as the empty-workspace next action", () => {
render(<App />);

expect(screen.getByRole("heading", { name: "Start tonight's rehearsal" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Use my own song" })).toBeTruthy();
expect(screen.getByText(/licensed demo is not bundled yet/i)).toBeTruthy();
});

it("starts local file intake from the empty-workspace next action", async () => {
tauriInvoke.mockResolvedValueOnce(bootstrapResponse());
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Use my own song" }));

await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
});

it("renders localized Korean shell copy for buyer-demo surfaces", () => {
const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");

Expand All @@ -260,6 +279,7 @@ describe("App", () => {
expect(screen.getByRole("button", { name: /^작업 공간$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /프로젝트 열기/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /유튜브 가져오기/i })).toBeTruthy();
expect(screen.getByRole("button", { name: "내 곡 사용하기" })).toBeTruthy();
expect(screen.getByText(/로컬 우선/i)).toBeTruthy();
expect(screen.getByText(/합주 지도는 이 기기에 머뭅니다/i)).toBeTruthy();
expect(screen.getByText(/^템포$/i)).toBeTruthy();
Expand Down
48 changes: 33 additions & 15 deletions apps/desktop/src/App.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<RehearsalView>("workspace");
const activeJobIdRef = useRef<string | null>(null);
const youtubeInputRef = useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -387,6 +388,10 @@ export function App() {

/** Documented. */
const handleStartAnalysis = async () => {
if (isChoosingLocalAudio) {
return;
}

const submittedBootstrap = selectedBootstrap;
setJobError(null);
setJobResult(null);
Expand Down Expand Up @@ -415,22 +420,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);
setSelectionError(null);
setSelectionErrorSource(null);
setIsChoosingLocalAudio(true);
try {
const selection = await selectLocalAudioSource();
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
return;
}

setSelectedBootstrap(null);
setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
setSelectionErrorSource("local");
setJobStatus(null);
} finally {
setIsChoosingLocalAudio(false);
}
};
Comment thread
seonghobae marked this conversation as resolved.

/** Documented. */
const handleImportYoutube = async () => {
if (isChoosingLocalAudio) {
return;
}

setSelectionError(null);
setSelectionErrorSource(null);
const normalizedUrl = youtubeUrl.trim();
Expand Down Expand Up @@ -514,7 +532,7 @@ export function App() {
if (jobResult) {
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
}
return <EmptyState />;
return <EmptyState onUseOwnSong={() => { void handleChooseLocalAudio(); }} chooseDisabled={isImporting || isChoosingLocalAudio} />;
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
};

const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
Expand Down Expand Up @@ -682,7 +700,7 @@ export function App() {
<div className="grid min-w-0 gap-3 xl:grid-cols-[auto_minmax(0,1fr)] xl:items-center">
<Button
onClick={handleChooseLocalAudio}
disabled={analysisInFlight || isStarting || isImporting}
disabled={analysisInFlight || isStarting || isImporting || isChoosingLocalAudio}
variant="secondary"
className="min-h-11 w-full border border-cyan-300/20 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 xl:w-auto"
aria-label={t("chooseLocalAudio")}
Expand All @@ -702,13 +720,13 @@ export function App() {
value={youtubeUrl}
maxLength={MAX_YOUTUBE_URL_LENGTH}
onChange={(e) => setYoutubeUrl(e.target.value)}
disabled={analysisInFlight || isStarting || isImporting}
disabled={analysisInFlight || isStarting || isImporting || isChoosingLocalAudio}
className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300"
aria-label={t("youtubeUrlAriaLabel")}
aria-invalid={selectionError && selectionErrorSource === "youtube" ? true : undefined}
aria-describedby={selectionError && selectionErrorSource === "youtube" ? "selection-error" : undefined}
/>
{youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? (
{youtubeUrl && !analysisInFlight && !isStarting && !isImporting && !isChoosingLocalAudio ? (
<button
type="button"
onClick={handleClearYoutubeUrl}
Expand All @@ -723,7 +741,7 @@ export function App() {
</div>
<Button
onClick={handleImportYoutube}
disabled={!youtubeUrl || analysisInFlight || isStarting || isImporting}
disabled={!youtubeUrl || analysisInFlight || isStarting || isImporting || isChoosingLocalAudio}
variant="outline"
className="min-h-10 w-full border-white/10 bg-white/5 font-semibold text-slate-100 hover:bg-white/10 hover:text-white sm:w-auto"
aria-label={t("importYoutube")}
Expand Down Expand Up @@ -770,7 +788,7 @@ export function App() {
)}
<Button
onClick={handleStartAnalysis}
disabled={analysisInFlight || isStarting || !selectedBootstrap || isImporting}
disabled={analysisInFlight || isStarting || !selectedBootstrap || isImporting || isChoosingLocalAudio}
size="lg"
className="min-h-11 bg-gradient-to-r from-cyan-400 to-violet-500 font-black text-slate-950 shadow-[0_14px_38px_rgba(34,211,238,0.28)] hover:from-cyan-300 hover:to-violet-400"
aria-label={isStarting ? t("startingAnalysis") : t("startAnalysis")}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,10 @@ describe("Workspace", () => {

it("localizes empty and loading state titles", () => {
setNavigatorLanguage("ko-KR");
render(<EmptyState />);
render(<EmptyState onUseOwnSong={vi.fn()} />);
render(<LoadingState />);

expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "오늘 합주를 시작하세요" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy();
});

Expand Down
Loading
Loading