Skip to content
Draft
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, a local count-in click before tonight's first range, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- 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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-30
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Brand source

Expand Down Expand Up @@ -82,7 +82,7 @@ Last updated: 2026-03-11
- likely harmony by section and by role
- 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
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, a local count-in click from trusted tempo, and the next instrument check
- 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

- Name tonight's first count-in on the ready rehearsal map and play a local click at the trusted tempo before the first range check.
- 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, plays a local count-in click, and names 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/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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createTranslator } from "../../i18n";
import { CountInClick } from "./CountInClick";
import type { CountInClickEngine } from "./countInClickEngine";
import type { FirstCountInPlan } from "./firstCountIn";

const t = createTranslator("en");
const plan: FirstCountInPlan = {
tempoBpm: 120,
beats: 4,
intervalMs: 500,
sectionLabel: "verse"
};

describe("CountInClick semantic plan lifecycle", () => {
it("keeps an active count-in running when an equivalent plan object replaces the prior object", async () => {
let finishPlay: (() => void) | undefined;
const engine: CountInClickEngine = {
available: true,
play: vi.fn(
() =>
new Promise<void>((resolve) => {
finishPlay = resolve;
})
),
stop: vi.fn()
};
const { rerender } = render(<CountInClick plan={plan} t={t} engine={engine} />);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));

rerender(<CountInClick plan={{ ...plan }} t={t} engine={engine} />);

expect(engine.stop).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in");
finishPlay?.();
await waitFor(() => {
expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy();
});
});
});
180 changes: 180 additions & 0 deletions apps/desktop/src/features/workspace/CountInClick.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CountInClick } from "./CountInClick";
import type { CountInClickEngine } from "./countInClickEngine";
import type { FirstCountInPlan } from "./firstCountIn";
import { createTranslator } from "../../i18n";

const t = createTranslator("en");
const plan: FirstCountInPlan = {
tempoBpm: 120,
beats: 4,
intervalMs: 500,
sectionLabel: "verse"
};

function renderCountIn(engine: CountInClickEngine, nextPlan: FirstCountInPlan | null = plan) {
return render(<CountInClick plan={nextPlan} t={t} engine={engine} />);
}

describe("CountInClick", () => {
it("names the count-in next action and plays a local click", async () => {
const engine: CountInClickEngine = {
available: true,
play: vi.fn(async () => undefined),
stop: vi.fn()
};
renderCountIn(engine);

const region = screen.getByTestId("first-count-in");
expect(region).toHaveTextContent("Tonight's first count-in");
expect(region).toHaveTextContent(
"Count in 4 at 120 BPM, then check tonight's first range before the verse."
);

fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));
expect(engine.play).toHaveBeenCalledWith(plan);
await waitFor(() => {
expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy();
});
});

it("asks the room to name a section when tempo is trusted but unlabeled", () => {
const engine: CountInClickEngine = {
available: true,
play: vi.fn(async () => undefined),
stop: vi.fn()
};
renderCountIn(engine, { ...plan, sectionLabel: undefined });
expect(screen.getByTestId("first-count-in")).toHaveTextContent(
"Count in 4 at 120 BPM, then name the first section so the room knows where it starts."
);
});

it("fails closed without a tempo and does not start a click", () => {
const engine: CountInClickEngine = {
available: true,
play: vi.fn(async () => undefined),
stop: vi.fn()
};
renderCountIn(engine, null);
expect(screen.getByTestId("first-count-in")).toHaveTextContent(
"Tonight's first count-in still needs a tempo. Count the first section in by ear before you start."
);
fireEvent.click(screen.getByRole("button", { name: /^count in$/i }));
expect(engine.play).not.toHaveBeenCalled();
});

it("blocks when the host cannot synthesize a click and when play throws", async () => {
const unavailable: CountInClickEngine = {
available: false,
play: vi.fn(async () => undefined),
stop: vi.fn()
};
const { rerender } = renderCountIn(unavailable);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));
expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy();

const failing: CountInClickEngine = {
available: true,
play: vi.fn(async () => {
throw new Error("context failed");
}),
stop: vi.fn()
};
rerender(<CountInClick plan={plan} t={t} engine={failing} />);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));
await waitFor(() => {
expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy();
});
});

it("ignores a second count-in click while the first is in flight", async () => {
let finishPlay: (() => void) | undefined;
const engine: CountInClickEngine = {
available: true,
play: vi.fn(
() =>
new Promise<void>((resolve) => {
finishPlay = resolve;
})
),
stop: vi.fn()
};
renderCountIn(engine);
const button = screen.getByRole("button", { name: /count in 4 at 120 bpm/i });
fireEvent.click(button);
fireEvent.click(button);
expect(engine.play).toHaveBeenCalledTimes(1);
finishPlay?.();
await waitFor(() => {
expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy();
});
});

it("stops a playing count-in and ignores a stale completion", async () => {
let finishPlay: (() => void) | undefined;
const engine: CountInClickEngine = {
available: true,
play: vi.fn(
() =>
new Promise<void>((resolve) => {
finishPlay = resolve;
})
),
stop: vi.fn()
};
renderCountIn(engine);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));
expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in");
fireEvent.click(screen.getByRole("button", { name: /stop count-in/i }));
expect(engine.stop).toHaveBeenCalled();
finishPlay?.();
await waitFor(() => {
expect(screen.queryByText("Now check that span on your instrument.")).toBeNull();
});
});

it("stops the old engine and invalidates completion when the active plan changes", async () => {
let finishPlay: (() => void) | undefined;
const engine: CountInClickEngine = {
available: true,
play: vi.fn(
() =>
new Promise<void>((resolve) => {
finishPlay = resolve;
})
),
stop: vi.fn()
};
const { rerender } = renderCountIn(engine);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));

const nextPlan: FirstCountInPlan = {
tempoBpm: 90,
beats: 4,
intervalMs: 60_000 / 90,
sectionLabel: "chorus"
};
rerender(<CountInClick plan={nextPlan} t={t} engine={engine} />);

expect(engine.stop).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: /count in 4 at 90 bpm/i })).toHaveTextContent("Count in");
finishPlay?.();
await waitFor(() => {
expect(screen.queryByText("Now check that span on your instrument.")).toBeNull();
});
});

it("stops the active engine when the count-in surface unmounts", () => {
const engine: CountInClickEngine = {
available: true,
play: vi.fn(() => new Promise<void>(() => undefined)),
stop: vi.fn()
};
const { unmount } = renderCountIn(engine);
fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i }));
unmount();
expect(engine.stop).toHaveBeenCalledTimes(1);
});
});
Loading
Loading