Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5cb3d6b
feat(workspace): name tonight's first cutoff plan on the map
seonghobae Aug 25, 2026
0c057a9
test(workspace): pin cutoff plan descriptor snapshot
seonghobae Aug 25, 2026
7eed678
fix(workspace): preserve cutoff plan validation snapshot
seonghobae Aug 25, 2026
5eaa58c
test(workspace): keep cutoff plan return contract strict
seonghobae Aug 25, 2026
0b62984
docs(design): mark cutoff plan as feature-local pattern
seonghobae Aug 25, 2026
662c038
Merge branch 'develop' into feat/workspace-first-cutoff-plan
seonghobae Aug 25, 2026
412f273
test(workspace): preserve custom cutoff copy provenance
seonghobae Aug 25, 2026
c5e5e30
perf(workspace): memoize cutoff plan translator
seonghobae Aug 25, 2026
3926799
test(workspace): require explicit cutoff-plan provenance
seonghobae Aug 26, 2026
c005f13
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
bddf9a7
feat(shared-types): carry cutoff-plan provenance
seonghobae Aug 27, 2026
2b44ec5
feat(analysis): type cutoff-plan provenance
seonghobae Aug 27, 2026
bcd9cbd
feat(analysis): mark generated cutoff plans as model-owned
seonghobae Aug 27, 2026
bf3527e
test(analysis): prove cutoff-plan provenance
seonghobae Aug 27, 2026
4366718
test(shared-types): cover cutoff-plan provenance contract
seonghobae Aug 27, 2026
99415ae
fix(workspace): trust cutoff provenance instead of sentence shape
seonghobae Aug 27, 2026
160cb77
fix(workspace): localize only model-owned cutoff plans
seonghobae Aug 27, 2026
bc86365
refactor(workspace): separate cutoff text bounding from provenance au…
seonghobae Aug 27, 2026
cc01295
test(workspace): mark generated cutoff fixtures
seonghobae Aug 27, 2026
0e663aa
fix(roles): remove unreachable cutoff branch
seonghobae Aug 27, 2026
41c1272
test(workspace): reproduce user cutoff shape truncation
seonghobae Aug 27, 2026
0ee8861
fix(workspace): bound cutoff copy by provenance
seonghobae Aug 27, 2026
a29c104
test(workspace): preserve generated cutoff provenance
seonghobae Aug 28, 2026
d37b57c
test(workspace): cover cutoff plan edge cases
seonghobae Aug 28, 2026
28e4f96
fix(workspace): enforce cutoff plan provenance
seonghobae Aug 28, 2026
cccd6c1
fix(contract): pair cutoff provenance fields
seonghobae Aug 29, 2026
b9d2513
test(workspace): cover cutoff plan branch contracts
seonghobae Aug 30, 2026
9a337e8
refactor(workspace): remove unreachable cutoff branches
seonghobae Aug 30, 2026
3ba2a64
refactor(workspace): remove unreachable renderer fallbacks
seonghobae Aug 30, 2026
217c5c4
refactor(workspace): remove unreachable copy fallback
seonghobae Aug 30, 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
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.
- Name tonight's first cutoff plan with the owning part when an active role is corroborated, the owned `cutoffPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, confirmed overrides, harmonic explanations, or confidence notes.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, 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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-26

## Brand source

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- The mounted workspace copy for tonight's first cutoff plan must name the owning part when corroborated, the owned `cutoffPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, and first-articulation-plan.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.

## 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

- Name tonight's first cutoff plan in the mounted rehearsal workspace so parts that leave a shared cutoff together can be named on the map; real analyzed songs now receive this guidance only when section-level stem activity shows at least two distinct sources becoming inactive together into the next section, while mixed simultaneous deactivation is the evidence and heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority.
- 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). `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 mounted workspace names tonight's first cutoff plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, and first-articulation-plan. `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,21 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { expect, it } from "vitest";
import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout";

it("gives co-mounted cutoff-plan callouts distinct DOM identities", () => {
render(
<>
<FirstCutoffPlanCallout song={createDemoRehearsalSong()} />
<FirstCutoffPlanCallout song={createDemoRehearsalSong()} />
</>
);

const callouts = screen.getAllByRole("complementary", {
name: "Tonight's first cutoff plan"
});
const ids = callouts.map((callout) => callout.id);

expect(ids.every((id) => id.length > 0)).toBe(true);
expect(new Set(ids).size).toBe(callouts.length);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout";

describe("FirstCutoffPlanCallout resolver reuse", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("does not rescan role metadata when a parent rerenders the same song object", () => {
const song = createDemoRehearsalSong();
const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!;
const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor");

const { rerender } = render(<FirstCutoffPlanCallout song={song} />);
const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;
expect(firstScanCount).toBeGreaterThan(0);

rerender(<FirstCutoffPlanCallout song={song} />);
const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;

expect(secondScanCount).toBe(firstScanCount);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout";

describe("FirstCutoffPlanCallout Korean role copy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("keeps vowel-ending role names particle-safe before and after the cutoff action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
cutoffPlan: "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."
}
];
seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
grid.setAttribute("role", "region");
grid.setAttribute("aria-label", "Scrollable song structure timeline");
const target = document.createElement("div");
target.dataset.sectionIndex = "0";
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstCutoffPlanCallout song={song} />);

expect(screen.getByText("0:30 벌스에서 피아노 파트의 컷오프 계획이 있습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();
expect(screen.queryByText(/피아노가/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 컷오프 열기" }));

expect(screen.getByText("0:30에서 피아노 파트의 컷오프를 맞춘 다음 합주를 시작하세요.")).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();

grid.remove();
});

it("localizes the analysis-engine cutoff template instead of exposing English guidance", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
cutoffPlan: "Cut this off with Lead Vocal; don't linger past the last beat."
}
];
seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

render(<FirstCutoffPlanCallout song={song} />);

expect(
screen.getByText("Lead Vocal 파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.")
).toBeTruthy();
expect(
screen.queryByText("Cut this off with Lead Vocal; don't linger past the last beat.")
).toBeNull();
});

it("localizes the rest-of-band cutoff template instead of exposing English guidance", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
cutoffPlan: "Cut this off with the rest of the band; don't linger past the last beat."
}
];
seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

render(<FirstCutoffPlanCallout song={song} />);

expect(
screen.getByText("나머지 밴드와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.")
).toBeTruthy();
expect(
screen.queryByText("Cut this off with the rest of the band; don't linger past the last beat.")
).toBeNull();
});

it("preserves the generated template shape when long target names are bounded", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
const targetRole = `Lead-${"A".repeat(180)}`;
seed.roles = [
{
...seed.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
cutoffPlan: `Cut this off with ${targetRole}; don't linger past the last beat.`
}
];
seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

render(<FirstCutoffPlanCallout song={song} />);

expect(screen.queryByText(/^Cut this off with /)).toBeNull();
expect(
screen.getByText(
(content) =>
content.startsWith("Lead-") && content.endsWith("파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.")
)
).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout";

describe("FirstCutoffPlanCallout cutoff-plan provenance", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("preserves custom cutoff guidance that happens to match the engine sentence shape", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const section = song.sections[0]!;
const customPlan = "Cut this off with Lead Vocal; don't linger past the last beat.";
section.roles = [
{
...section.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
cutoffPlan: customPlan
}
];
section.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

render(<FirstCutoffPlanCallout song={song} />);

expect(screen.getByText(customPlan)).toBeTruthy();

Check failure on line 29 in apps/desktop/src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx

View workflow job for this annotation

GitHub Actions / release-preflight

src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx > FirstCutoffPlanCallout cutoff-plan provenance > preserves custom cutoff guidance that happens to match the engine sentence shape

TestingLibraryElementError: Unable to find an element with the text: Cut this off with Lead Vocal; don't linger past the last beat.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <aside aria-label="오늘 첫 컷오프 계획" class="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4" id="workspace-surface-cutoff-plan-_r_0_" > <p class="text-xs font-black uppercase tracking-[0.24em] text-cyan-200" > 오늘 첫 컷오프 계획 </p> <p class="mt-2 text-sm leading-6 text-slate-300" > 0:30 벌스에서 피아노 파트의 컷오프 계획이 있습니다. </p> <p class="mt-1 text-sm leading-6 text-slate-400" > Lead Vocal 파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요. </p> <button class="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-primary/80 disabled:hover:bg-primary aria-disabled:hover:bg-primary h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" data-slot="button" tabindex="0" type="button" > 0:30 피아노 컷오프 열기 </button> </aside> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx:29:19

Check failure on line 29 in apps/desktop/src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx

View workflow job for this annotation

GitHub Actions / ci / build-and-test

src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx > FirstCutoffPlanCallout cutoff-plan provenance > preserves custom cutoff guidance that happens to match the engine sentence shape

TestingLibraryElementError: Unable to find an element with the text: Cut this off with Lead Vocal; don't linger past the last beat.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <aside aria-label="오늘 첫 컷오프 계획" class="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4" id="workspace-surface-cutoff-plan-_r_0_" > <p class="text-xs font-black uppercase tracking-[0.24em] text-cyan-200" > 오늘 첫 컷오프 계획 </p> <p class="mt-2 text-sm leading-6 text-slate-300" > 0:30 벌스에서 피아노 파트의 컷오프 계획이 있습니다. </p> <p class="mt-1 text-sm leading-6 text-slate-400" > Lead Vocal 파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요. </p> <button class="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-primary/80 disabled:hover:bg-primary aria-disabled:hover:bg-primary h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" data-slot="button" tabindex="0" type="button" > 0:30 피아노 컷오프 열기 </button> </aside> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/features/workspace/FirstCutoffPlanCallout.provenance.test.tsx:29:19
expect(
screen.queryByText("Lead Vocal 파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.")
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout";

describe("FirstCutoffPlanCallout reduced motion", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("scrolls immediately when the operating system requests reduced motion", () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}));

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
grid.setAttribute("role", "region");
grid.setAttribute("aria-label", "Scrollable song structure timeline");
const target = document.createElement("div");
target.dataset.sectionIndex = "0";
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstCutoffPlanCallout song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar cutoff at 0:30" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

grid.remove();
});
});
Loading
Loading