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.
- Settings must name which audio this device can open and the next rehearsal action. It must not say coming soon.
- 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 @@ -68,6 +68,7 @@ Last updated: 2026-03-11
- BandScope is not only a shell around chord labels, stems, and ranges.
- The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority.
- These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer.
- The desktop Settings control lists admitted local audio formats and names the next action: choose a supported file, or open tonight's rehearsal map once a song is ready.

## Analysis target model

Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Settings names which audio this device can open and the next rehearsal action instead of saying coming soon.
- 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 All @@ -14,6 +15,7 @@

### Fixed

- Keep the Settings entry reachable from the compact rehearsal navigation on narrow windows.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.

## [0.1.3] - 2026-04-29
Expand Down Expand Up @@ -74,4 +76,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
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.

Settings must name admitted local audio formats and the next rehearsal action. Help and Stem Lab stay out of this slice.

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
136 changes: 131 additions & 5 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ describe("App", () => {
expect(screen.getByRole("button", { name: /^Workspace$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy();
expect(fireEvent.click(screen.getByRole("button", { name: /settings coming soon/i }))).toBe(false);
const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i });
expect(settingsButtons).toHaveLength(3);
for (const settingsButton of settingsButtons) {
expect(settingsButton).not.toHaveAttribute("aria-disabled");
}
expect(fireEvent.click(screen.getByRole("button", { name: /help coming soon/i }))).toBe(false);
const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i });
const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" });
Expand Down Expand Up @@ -1536,6 +1540,20 @@ describe("App", () => {
expect(mockSaveProject).not.toHaveBeenCalled();
});

it("opens Settings from the compact rehearsal navigation", () => {
render(<App />);

const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const settingsButton = within(compactNav).getByRole("button", {
name: /See which audio this device can open compact view/i
});

fireEvent.click(settingsButton);

expect(settingsButton).toHaveAttribute("aria-current", "page");
expect(screen.getByText("Tonight's audio")).toBeTruthy();
});

it("handles exception thrown by importYoutubeUrl itself", async () => {
mockImportYoutubeUrlError = true;

Expand All @@ -1553,16 +1571,124 @@ describe("App", () => {
});


it("renders Settings and Help as focusable aria-disabled controls", () => {
it("keeps Help coming soon while Settings names the next audio action", () => {
render(<App />);
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i });
const helpButton = screen.getByRole("button", { name: "Help coming soon" });
expect(settingsButton).toHaveAttribute("aria-disabled", "true");
expect(settingsButton).not.toHaveAttribute("disabled");
expect(settingsButtons).toHaveLength(3);
for (const settingsButton of settingsButtons) {
expect(settingsButton).not.toHaveAttribute("aria-disabled");
expect(settingsButton).not.toHaveAttribute("disabled");
}
fireEvent.click(screen.getByRole("button", { name: /See which audio this device can open.*compact/i }));
expect(screen.getByRole("button", { name: /See which audio this device can open.*compact/i })).toHaveAttribute(
Comment thread
seonghobae marked this conversation as resolved.
"aria-current",
"page"
);
expect(helpButton).toHaveAttribute("aria-disabled", "true");
expect(helpButton).not.toHaveAttribute("disabled");
});

it("opens Settings to name admitted formats and start the local-audio picker", async () => {
tauriInvoke.mockResolvedValueOnce(bootstrapResponse());
render(<App />);

fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!);
expect(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]).toHaveAttribute("aria-current", "page");
expect(screen.getByText("Tonight's audio")).toBeTruthy();
expect(screen.getByText(".wav")).toBeTruthy();
expect(screen.getByText(".m4a")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: /Choose a supported file/i }));
await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
expect(screen.getByRole("heading", { name: /Workspace Home/i })).toBeTruthy();
expect(screen.queryByText("Tonight's audio")).toBeNull();
});

it.each(["queued", "running"] as const)("keeps the Settings chooser disabled while analysis is %s", async (state) => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockResolvedValueOnce(jobStatusResponse({ state }));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!);
mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) };

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i });
await waitFor(() => expect(settingsChooseButton).toBeDisabled());
fireEvent.click(settingsChooseButton);

expect(screen.getByTitle("late-night-set.wav")).toBeTruthy();
expect(screen.queryByText("replacement.wav")).toBeNull();
});

it("keeps the Settings chooser disabled while analysis startup is pending", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockImplementationOnce(() => new Promise(() => undefined));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!);
mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) };

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i });
await waitFor(() => expect(settingsChooseButton).toBeDisabled());
fireEvent.click(settingsChooseButton);

expect(screen.getByTitle("late-night-set.wav")).toBeTruthy();
expect(screen.queryByText("replacement.wav")).toBeNull();
});

it("keeps the Settings chooser disabled while YouTube import is pending", async () => {
tauriInvoke.mockImplementation(() => new Promise(() => undefined));
render(<App />);

fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!);
fireEvent.change(screen.getByPlaceholderText(/YouTube URL/i), {
target: { value: "https://youtube.com/watch?v=abc123DEF45" }
});
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));

const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i });
await waitFor(() => expect(settingsChooseButton).toBeDisabled());
});

it("returns from Settings to tonight's rehearsal map after a song is ready", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});

fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!);
expect(screen.getByText(/Tonight's map is ready/i)).toBeTruthy();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: /Open tonight's rehearsal map/i }));
await waitFor(() => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
expect(document.getElementById("main-content")).toHaveFocus();
expect(scrollIntoView).toHaveBeenCalled();
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});

it("keeps the Score view disabled until a song is loaded", () => {
render(<App />);

Expand Down
84 changes: 75 additions & 9 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from "./lib/analysis";
import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n";
import { ScoreView } from "./features/score/ScoreView";
import { RehearsalSettings } from "./features/settings/RehearsalSettings";
import { Workspace } from "./features/workspace/Workspace";
import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates";
import { Button } from "@/components/ui/button";
Expand All @@ -54,11 +55,12 @@ import { Toaster } from "@/components/ui/sonner";

const ANALYSIS_POLL_INTERVAL_MS = 250;
const MAX_ERROR_DETAIL_LENGTH = 220;
const SOURCE_CONTROLS_FOCUS_ID = "source-controls-choose-audio";
Comment thread
seonghobae marked this conversation as resolved.
const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:Users|home|var|tmp|private|Volumes)\/[^\s"'<>]+)/g;
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi;
const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi;

type RehearsalView = "workspace" | "score";
type RehearsalView = "workspace" | "score" | "settings";

const NAV_ITEMS = [
{ labelKey: "navWorkspace", icon: Home, view: "workspace" },
Expand Down Expand Up @@ -517,7 +519,8 @@ export function App() {
return <EmptyState />;
};

const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
const currentView: RehearsalView =
activeView === "settings" ? "settings" : jobResult && activeView === "score" ? "score" : "workspace";

/** Resolve label, enablement, and active state for one sidebar item. */
const navButtonState = (item: (typeof NAV_ITEMS)[number]) => {
Expand All @@ -535,6 +538,27 @@ export function App() {
setActiveView(view);
};

/** Leave Settings and start the local-audio picker. */
const handleSettingsChooseAudio = () => {
if (analysisInFlight || isStarting || isImporting) {
return;
}
setActiveView("workspace");
void handleChooseLocalAudio();
Comment thread
seonghobae marked this conversation as resolved.
};

/** Leave Settings and show tonight's rehearsal map. */
const handleSettingsOpenMap = () => {
setActiveView("workspace");
window.setTimeout(() => {
const main = document.getElementById("main-content");
if (main instanceof HTMLElement) {
main.scrollIntoView?.({ block: "start" });
main.focus();
}
}, 0);
};

return (
<div className="min-h-screen overflow-x-hidden bg-[var(--bandscope-bg)] text-slate-100 selection:bg-cyan-300/30">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_18%_8%,rgba(15,120,255,0.22),transparent_28%),radial-gradient(circle_at_78%_0%,rgba(124,58,237,0.20),transparent_30%),linear-gradient(180deg,#07111f_0%,#020713_55%,#020611_100%)]" />
Expand Down Expand Up @@ -586,6 +610,21 @@ export function App() {
</button>
);
})}
<button
type="button"
aria-current={currentView === "settings" ? "page" : undefined}
aria-label={t("settingsOpenHint")}
title={t("settingsOpenHint")}
onClick={() => handleNavSelect("settings")}
className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
currentView === "settings"
? "bg-blue-600/70 text-white"
: "text-slate-200 hover:bg-white/5"
}`}
>
<Settings className="size-4" aria-hidden="true" />
{t("settingsOpenHint")}
</button>
</nav>

<div className="mt-auto space-y-5">
Expand Down Expand Up @@ -613,11 +652,15 @@ export function App() {
<div className="flex items-center justify-between text-slate-400">
<button
type="button"
aria-disabled={true}
aria-label={t("settingsComingSoon")}
title={t("settingsComingSoon")}
onClick={preventUnavailableAction}
className="inline-flex cursor-not-allowed items-center justify-center rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-current={currentView === "settings" ? "page" : undefined}
aria-label={t("settingsOpenHint")}
title={t("settingsOpenHint")}
onClick={() => handleNavSelect("settings")}
Comment thread
seonghobae marked this conversation as resolved.
className={`inline-flex items-center justify-center rounded-xl p-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
currentView === "settings"
? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]"
: "text-slate-200 hover:bg-white/5"
}`}
>
<Settings className="size-5" aria-hidden="true" />
</button>
Expand All @@ -635,7 +678,7 @@ export function App() {
</div>
</aside>

<main id="main-content" className="max-h-screen min-w-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6 lg:px-8">
<main id="main-content" tabIndex={-1} className="max-h-screen min-w-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6 lg:px-8 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300">
<nav aria-label={t("compactRehearsalViewsAriaLabel")} className="mb-4 flex gap-2 overflow-x-auto rounded-2xl border border-white/10 bg-slate-950/72 p-2 backdrop-blur-xl lg:hidden">
{NAV_ITEMS.map((item) => {
const { label, enabled, active, title } = navButtonState(item);
Expand Down Expand Up @@ -663,6 +706,21 @@ export function App() {
</button>
);
})}
<button
type="button"
aria-current={currentView === "settings" ? "page" : undefined}
aria-label={`${t("settingsOpenHint")} ${t("compactViewSuffix")}`}
title={t("settingsOpenHint")}
onClick={() => handleNavSelect("settings")}
className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
currentView === "settings"
? "bg-blue-600/70 text-white"
: "text-slate-200 hover:bg-white/5"
}`}
>
<Settings className="size-4" aria-hidden="true" />
{t("settingsOpenHint")}
</button>
</nav>

<section aria-label={t("sourceControlsAriaLabel")} className="mb-4 rounded-3xl border border-white/10 bg-slate-950/72 p-4 shadow-[0_18px_60px_rgba(0,0,0,0.25)] backdrop-blur-xl">
Expand All @@ -681,6 +739,7 @@ export function App() {

<div className="grid min-w-0 gap-3 xl:grid-cols-[auto_minmax(0,1fr)] xl:items-center">
<Button
id={SOURCE_CONTROLS_FOCUS_ID}
onClick={handleChooseLocalAudio}
disabled={analysisInFlight || isStarting || isImporting}
variant="secondary"
Expand Down Expand Up @@ -842,7 +901,14 @@ export function App() {
</header>

<section className="animate-in fade-in duration-500 ease-out fill-mode-both">
{currentView === "score" && jobResult ? (
{currentView === "settings" ? (
<RehearsalSettings
disabled={analysisInFlight || isStarting || isImporting}
songReady={jobResult !== null}
onChooseAudio={handleSettingsChooseAudio}
onOpenMap={handleSettingsOpenMap}
/>
) : currentView === "score" && jobResult ? (
<ScoreView
song={jobResult}
projectId={jobResultBootstrap?.projectId ?? null}
Expand Down
Loading
Loading