Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
abfb399
feat(workspace): name the selected part's first entrance cue
seonghobae Sep 3, 2026
3a09ae1
feat(workspace): consolidate selected-part first-pass domain
seonghobae Sep 3, 2026
5c9d96f
test(workspace): preserve selected-part first-pass mount
seonghobae Sep 3, 2026
baddc60
test(workspace): preserve selected-part first-pass regressions
seonghobae Sep 3, 2026
ff9260e
docs(workspace): preserve selected-part first-pass evidence
seonghobae Sep 3, 2026
39f7fae
feat(workspace): mount consolidated selected-part first-pass guidance
seonghobae Sep 3, 2026
e583873
feat(i18n): consolidate selected-part first-pass copy
seonghobae Sep 3, 2026
424821b
feat(i18n): consolidate Korean first-pass copy
seonghobae Sep 3, 2026
7037ce7
feat(workspace): consolidate selected-part confirmed chord
seonghobae Sep 3, 2026
9375cf1
test(workspace): preserve confirmed-chord mount
seonghobae Sep 3, 2026
7ad3924
test(workspace): preserve confirmed-chord regressions
seonghobae Sep 3, 2026
a5d0750
docs(workspace): preserve confirmed-chord evidence
seonghobae Sep 3, 2026
9a8b30e
feat(workspace): mount consolidated confirmed-chord guidance
seonghobae Sep 3, 2026
958b096
feat(i18n): consolidate confirmed-chord copy
seonghobae Sep 3, 2026
d662173
feat(i18n): consolidate Korean confirmed-chord copy
seonghobae Sep 3, 2026
8581afb
refactor(workspace): share canonical section labels
seonghobae Sep 3, 2026
8821ae1
docs(ui): consolidate selected-part guidance contracts
seonghobae Sep 3, 2026
7d03dc9
test(workspace): reject stale selected roles
seonghobae Sep 3, 2026
c264458
test(workspace): hide stale selected-part guidance
seonghobae Sep 3, 2026
10d9ed0
test(i18n): require particle-safe confirmed chord copy
seonghobae Sep 3, 2026
141d8e9
fix(workspace): fail closed on chord override conflicts
seonghobae Sep 3, 2026
c5ce688
test(workspace): cover conflicting chord overrides
seonghobae Sep 3, 2026
81d022c
fix(i18n): make confirmed chord copy particle-safe
seonghobae Sep 3, 2026
44b1233
fix(workspace): clear roles missing from replacement project
seonghobae Sep 3, 2026
84443ce
docs(workspace): record selected-part ordering invariants
seonghobae Sep 3, 2026
f8a6c2b
docs(workspace): record confirmed chord conflict policy
seonghobae Sep 3, 2026
1f78675
Merge protected develop into selected-part guidance owner
seonghobae Sep 4, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AGENTS.md

## Project overview
- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. After a named part is selected, the ready workspace names that part's first trusted lyric, count, or transition cue as the next entrance.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Last updated: 2026-03-11
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
- the selected part's first trusted lyric, count, or transition cue as the next entrance
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
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

- After a named part is selected, name that part's first trusted lyric, count, or transition cue and tell the player to catch it before the entrance.
- 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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into

Three layers, decoupled through shared contracts:

- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. After a named part is selected, it also names that part's first trusted lyric, count, or transition cue as the next entrance. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.

Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/features/workspace/RoleSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,27 @@ describe("RoleSwitcher", () => {
expect(tabValueToRoleId("role:unknown-role", roles)).toBeNull();
expect(tabValueToRoleId("raw-unknown-role", roles)).toBeNull();
});

it("clears an active role that is absent from the current song role allowlist", () => {
const onRoleChange = vi.fn();
const { rerender } = render(
<RoleSwitcher
roles={[{ id: "bass-guitar", name: "Bass Guitar" }]}
activeRole="bass-guitar"
onRoleChange={onRoleChange}
/>
);

expect(onRoleChange).not.toHaveBeenCalled();

rerender(
<RoleSwitcher
roles={[{ id: "lead-vocal", name: "Lead Vocal" }]}
activeRole="bass-guitar"
onRoleChange={onRoleChange}
/>
);

expect(onRoleChange).toHaveBeenCalledWith(null);
});
});
7 changes: 7 additions & 0 deletions apps/desktop/src/features/workspace/RoleSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users } from "lucide-react";
Expand Down Expand Up @@ -40,6 +41,12 @@ export function tabValueToRoleId(value: string, roles: RehearsalRoleOption[]): s
export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) {
const t = createTranslator(detectPreferredLocale());

useEffect(() => {
if (activeRole !== null && !roles.some((role) => role.id === activeRole)) {
onRoleChange(null);
}
}, [activeRole, onRoleChange, roles]);

return (
<div className="flex flex-col gap-4 py-2 sm:flex-row sm:items-center">
<div className="flex whitespace-nowrap text-sm font-semibold text-slate-200">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, 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 selected-part confirmed chord", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("stays hidden until a part with a room-confirmed chord is selected", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull();

fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull();
});

it("names the selected part's confirmed chord and the next lock-in action", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

const callout = screen.getByTestId("selected-part-confirmed-chord");
expect(callout).toHaveTextContent("Tonight's confirmed chord");
expect(callout).toHaveTextContent(
"Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse."
);
});

it("keeps Korean copy particle-safe for arbitrary chord symbols", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);

fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("selected-part-confirmed-chord")).toHaveTextContent(
"verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요."
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { fireEvent, 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 selected-part entrance cue", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("hides the entrance cue until a named part is selected", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull();
});

it("names the selected bass part's transition as the next entrance", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-entrance-cue");
expect(callout).toHaveTextContent("Tonight's entrance cue");
expect(callout).toHaveTextContent(
"Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat."
);
});

it("names the selected vocal lyric as the next entrance", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
'Listen for "city lights" in verse, then Lead Vocal enters.'
);
});

it("keeps Korean copy particle-safe for a Latin role name", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-entrance-cue");
expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐");
expect(callout).toHaveTextContent("Bass Guitar 파트");
expect(callout).not.toHaveTextContent("Bass Guitar으로");
expect(callout).toHaveTextContent(
"verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat."
);
});

it("tells the player to confirm a missing cue instead of hiding the next action", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
cue: { kind: "transition", value: "none" }
};

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
"This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance."
);
});

it("clears selected-part guidance when the next project no longer contains the selected role", () => {
setNavigatorLanguage("en-US");
const firstSong = createDemoRehearsalSong();
const { rerender } = render(<Workspace song={firstSong} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByTestId("selected-part-entrance-cue")).toBeInTheDocument();

const nextSong = createDemoRehearsalSong();
nextSong.id = "replacement-project";
nextSong.sections = nextSong.sections.map((section) => ({
...section,
roles: section.roles.filter((role) => role.id !== "bass-guitar")
}));

rerender(<Workspace song={nextSong} />);

expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull();
expect(screen.queryByTestId("selected-part-first-pass")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fireEvent, 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 selected-part first-pass take", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("hides the first-pass take until a named part is selected", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.queryByTestId("selected-part-first-pass")).toBeNull();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
});

it("names the selected bass part's simpler take as the first pass", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-first-pass");
expect(callout).toHaveTextContent("Tonight's first-pass take");
expect(callout).toHaveTextContent(
"First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest."
);
});

it("names the selected vocal part's simpler take as the first pass", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
"First pass for Lead Vocal in verse: Keep the sustained note centered; skip the ad-lib on the first pass. Play that simpler take before adding the rest."
);
});

it("keeps Korean copy particle-safe for a Latin role name", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-first-pass");
expect(callout).toHaveTextContent("오늘 이 파트의 첫 간소화");
expect(callout).toHaveTextContent("Bass Guitar 파트");
expect(callout).not.toHaveTextContent("Bass Guitar으로");
expect(callout).toHaveTextContent(
"verse에서 Bass Guitar 파트의 첫 패스: Stay on roots if the chorus entrance gets muddy. 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요."
);
});

it("tells the player to confirm a missing first-pass take instead of hiding the next action", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
simplification: "none"
};

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
"This part still needs a trusted first-pass take. Confirm the simpler version before the first run."
);
});
});
Loading
Loading