Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ea7d685
feat(workspace): name tonight's first harmonic explanation on the map
seonghobae Aug 23, 2026
61151a2
test(workspace): scope harmonic explanation navigation
seonghobae Aug 23, 2026
f179bac
fix(workspace): scope harmonic explanation navigation
seonghobae Aug 23, 2026
d438075
test(workspace): pin harmonic explanation accessible name
seonghobae Aug 23, 2026
b0b1372
fix(workspace): name unavailable harmonic callout
seonghobae Aug 23, 2026
49d28cb
test(workspace): align harmonic guidance copy
seonghobae Aug 23, 2026
80d5e27
test(workspace): cover harmonic resolver rerenders
seonghobae Aug 24, 2026
e88f7dd
perf(workspace): memoize harmonic explanation resolution
seonghobae Aug 24, 2026
5e5802f
test(workspace): preserve harmonic action across section reorder
seonghobae Aug 24, 2026
b0dbfad
fix(workspace): preserve harmonic action across section reorder
seonghobae Aug 24, 2026
cbc647d
test(workspace): require unique harmonic callout ids
seonghobae Aug 24, 2026
7ab7cf7
fix(workspace): give harmonic callouts unique ids
seonghobae Aug 24, 2026
d539be0
test(workspace): reject off-contract harmonic section labels
seonghobae Aug 24, 2026
8f88db8
fix(workspace): reject off-contract harmonic section labels
seonghobae Aug 24, 2026
7613727
test(workspace): require harmonic explanation coverage ownership
seonghobae Aug 24, 2026
8a229e3
fix(coverage): include harmonic explanation production paths
seonghobae Aug 24, 2026
fb2eb58
test(workspace): contain hostile harmonic sections access
seonghobae Aug 24, 2026
2fde59c
fix(workspace): contain harmonic sections access
seonghobae Aug 24, 2026
6f117b4
test(workspace): lock harmonic explanation descriptor authority
seonghobae Aug 24, 2026
8b151ce
fix(workspace): snapshot harmonic explanation authority
seonghobae Aug 24, 2026
ede2187
test(workspace): lock harmonic explanation time authority
seonghobae Aug 24, 2026
7cbe5f4
fix(workspace): snapshot harmonic explanation time authority
seonghobae Aug 24, 2026
2dccfc5
test(workspace): cover ambiguous harmonic renderer scopes
seonghobae Aug 24, 2026
eb952b0
test(workspace): cover hostile harmonic song identity descriptor
seonghobae Aug 24, 2026
e402084
test(workspace): close harmonic explanation coverage gaps
seonghobae Aug 24, 2026
2425adf
Merge branch 'develop' into feat/workspace-first-harmonic-explanation
seonghobae Aug 25, 2026
c759a97
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 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 harmonic explanation with the owning part when an active role is corroborated, the owned `harmonicExplanation` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, setup, simplification, overlap, range, chord labels, function labels, confirmed overrides, 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-24

## 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 harmonic explanation must name the owning part when corroborated, the owned `harmonicExplanation` 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, setup, simplification, overlap, range, chord labels, function labels, confirmed overrides, or confidence notes.
- 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 harmonic explanation in the mounted rehearsal workspace so the room can play from the owned harmony reason before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming 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 harmonic explanation and opens the matching rendered map section. Do not invent that copy from groove, cue, setup, simplification, overlap, range, chord labels, function labels, confirmed overrides, or confidence notes. `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,17 @@
import { render, screen } from "@testing-library/react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstHarmonicExplanationCallout } from "./FirstHarmonicExplanationCallout";

describe("FirstHarmonicExplanationCallout accessibility", () => {
it("keeps the unavailable callout named by the concise surface label", () => {
render(<FirstHarmonicExplanationCallout song={null as unknown as RehearsalSong} />);

expect(screen.getByLabelText("Tonight's first harmonic explanation")).toBeTruthy();
expect(
screen.getByText(
"Nothing still has a harmonic explanation. Stay on tonight's map until a part owns rehearsal-facing harmony copy."
)
).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it, vi } from "vitest";

const { resolveCalls } = vi.hoisted(() => ({
resolveCalls: vi.fn()
}));

vi.mock("./firstHarmonicExplanation", async (importOriginal) => {
const actual = await importOriginal<typeof import("./firstHarmonicExplanation")>();
return {
...actual,
resolveFirstHarmonicExplanation: (song: Parameters<typeof actual.resolveFirstHarmonicExplanation>[0]) => {
resolveCalls(song);
return actual.resolveFirstHarmonicExplanation(song);
}
};
});

import { FirstHarmonicExplanationCallout } from "./FirstHarmonicExplanationCallout";

describe("FirstHarmonicExplanationCallout memoization", () => {
it("does not rescan an unchanged song when its parent rerenders", () => {
const song = createDemoRehearsalSong();
const { rerender } = render(<FirstHarmonicExplanationCallout song={song} />);

expect(resolveCalls).toHaveBeenCalledTimes(1);

rerender(<FirstHarmonicExplanationCallout song={song} />);

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

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

it("keeps vowel-ending dynamic role names particle-safe before and after the explanation action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[0]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
harmonicExplanation: "The bass holds the vi center."
}
];
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(<FirstHarmonicExplanationCallout song={song} />);

expect(screen.getByText("0:10 벌스에서 피아노 파트의 화성 설명이 있습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();
expect(screen.queryByText(/피아노가/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 화성 설명 위치 열기" }));

expect(screen.getByText("0:10에서 피아노 파트의 화성 설명을 확인한 다음 합주를 시작하세요.")).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();

grid.remove();
});
});
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 { FirstHarmonicExplanationCallout } from "./FirstHarmonicExplanationCallout";

describe("FirstHarmonicExplanationCallout 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(<FirstHarmonicExplanationCallout song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar explanation at 0:10" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

grid.remove();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstHarmonicExplanationCallout } from "./FirstHarmonicExplanationCallout";

describe("FirstHarmonicExplanationCallout sections access", () => {
it("contains a hostile sections get trap after descriptor-backed resolution", () => {
const song = createDemoRehearsalSong();
let sectionReads = 0;
const hostileSong = new Proxy(song, {
get(target, property, receiver) {
if (property === "sections") {
sectionReads += 1;
if (sectionReads > 2) {
throw new Error("hostile sections get trap");
}
}
return Reflect.get(target, property, receiver);
}
});

expect(() =>
render(<FirstHarmonicExplanationCallout song={hostileSong as RehearsalSong} />)
).not.toThrow();
expect(
screen.getByRole("button", { name: "Open Bass Guitar explanation at 0:10" })
).toBeTruthy();
});
});
Loading
Loading