diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..381f29524 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
+- 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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..e876ceae0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..08a5022eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -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
@@ -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`).
\ No newline at end of file
+- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..548586cd1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.
+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
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..fe41e97ca 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -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" });
@@ -1536,6 +1540,20 @@ describe("App", () => {
expect(mockSaveProject).not.toHaveBeenCalled();
});
+ it("opens Settings from the compact rehearsal navigation", () => {
+ render();
+
+ 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;
@@ -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();
- 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(
+ "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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index f3d678454..aeedc6990 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -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";
@@ -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";
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" },
@@ -517,7 +519,8 @@ export function App() {
return ;
};
- 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]) => {
@@ -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();
+ };
+
+ /** 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 (
@@ -586,6 +610,21 @@ export function App() {
);
})}
+
@@ -613,11 +652,15 @@ export function App() {
@@ -635,7 +678,7 @@ export function App() {
-
+
@@ -681,6 +739,7 @@ export function App() {