Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f620ed6
feat(workspace): name tonight's first ear check on the map
seonghobae Aug 23, 2026
9b89b61
test(workspace): cover ear-check ownership stability
seonghobae Aug 23, 2026
bd9e9e3
fix(workspace): keep ear-check notes with named part
seonghobae Aug 23, 2026
327e5db
fix(workspace): preserve ear-check state for stable songs
seonghobae Aug 23, 2026
394001f
test(workspace): scope ear-check navigation
seonghobae Aug 23, 2026
c0f0fec
fix(workspace): scope ear-check navigation
seonghobae Aug 23, 2026
96d2e37
test(workspace): require section in Korean ear-check completion
seonghobae Aug 23, 2026
e5d64df
fix(workspace): keep section in Korean ear-check completion
seonghobae Aug 23, 2026
109b86c
test(workspace): reject inactive ear-check hint leakage
seonghobae Aug 24, 2026
cc2f7c2
fix(workspace): keep inactive ear-check notes private
seonghobae Aug 24, 2026
68e3fbc
test(workspace): bind band-wide ear-check hints to section owner
seonghobae Aug 24, 2026
72d34c0
test(workspace): align inherited ear-check hint contract
seonghobae Aug 24, 2026
15f189b
test(workspace): require initial Korean ear-check wording
seonghobae Aug 24, 2026
38781c8
fix(workspace): clarify Korean ear-check next action
seonghobae Aug 24, 2026
bec4991
fix(workspace): harden first ear check identity, scoping, and candida…
seonghobae Aug 24, 2026
8dd54ac
test(workspace): require ear-check coverage ownership
seonghobae Aug 24, 2026
66bb7d1
fix(coverage): include ear-check production paths
seonghobae Aug 24, 2026
1c12a37
test(workspace): reject truncated ear-check identity collisions
seonghobae Aug 24, 2026
d04ea19
fix(workspace): fail closed on oversized ear-check identity
seonghobae Aug 24, 2026
db57800
test(workspace): align Korean ear-check tone
seonghobae Aug 24, 2026
df35aed
fix(workspace): align Korean ear-check status tone
seonghobae Aug 24, 2026
cb86ea2
test(workspace): align Korean ear-check copy contract
seonghobae Aug 24, 2026
9e7bbea
test(workspace): reject hostile ear-check sections access
seonghobae Aug 24, 2026
685e701
test(workspace): align Korean ear-check expectations
seonghobae Aug 24, 2026
aa61010
test(accessibility): reject duplicate ear-check landmark ids
seonghobae Aug 24, 2026
da9c1a7
fix(accessibility): keep ear-check landmark ids unique
seonghobae Aug 24, 2026
c5d3a74
test(i18n): reject noncanonical ear-check section labels
seonghobae Aug 24, 2026
c8804b7
fix(i18n): require canonical ear-check section labels
seonghobae Aug 24, 2026
6762e26
Merge branch 'develop' into feat/workspace-first-ear-check
seonghobae Aug 25, 2026
d3bbd82
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 ear check with the uncertain part when an active role is corroborated, the owned confidence notes, the labeled section, and the time so the next action is obvious. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence.
- 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-23

## 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 ear check must name the uncertain part when corroborated, the owned confidence notes, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence.
- 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 ear check in the mounted rehearsal workspace so the room can confirm uncertain analysis by ear 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 ear check and opens the matching rendered map section. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence. `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,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstEarCheckCallout } from "./FirstEarCheckCallout";

function appendSongStructureTarget() {
const timeline = document.createElement("div");
timeline.setAttribute("role", "region");
timeline.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()
});
timeline.appendChild(target);
document.body.appendChild(timeline);
return timeline;
}

function songWithThirtyThreeSections() {
const song = createDemoRehearsalSong();
const filler = structuredClone(song.sections[1] ?? song.sections[0]!);
song.sections = [
structuredClone(song.sections[0]!),
...Array.from({ length: 32 }, (_, index) => ({
...structuredClone(filler),
id: `identity-filler-${index + 1}`,
timeRange: {
...structuredClone(filler.timeRange),
start: 100 + index * 10,
end: 105 + index * 10
}
}))
];
return song;
}

describe("FirstEarCheckCallout bounded song identity", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("resets armed guidance when same-id oversized songs differ beyond the fingerprint bound", () => {
const firstSong = songWithThirtyThreeSections();
const secondSong = structuredClone(firstSong);
secondSong.sections[32]!.id = "different-tail-section";
const timeline = appendSongStructureTarget();

try {
const { rerender } = render(<FirstEarCheckCallout song={firstSong} />);

fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" }));
expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy();

rerender(<FirstEarCheckCallout song={secondSong} />);

expect(screen.getByText("Bass Guitar still needs an ear check in the verse at 0:10.")).toBeTruthy();
expect(screen.queryByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeNull();
} finally {
timeline.remove();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstEarCheckCallout } from "./FirstEarCheckCallout";

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

it("keeps vowel-ending dynamic role names particle-safe before and after the ear-check 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",
confidence: {
level: "medium",
source: "model",
notes: "Top voicing may need a quick ear check."
}
}
];
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(<FirstEarCheckCallout 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();
});

it("keeps the section locator after opening a band-wide ear check", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[0]!,
confidence: {
level: "medium",
source: "model",
notes: "Confirm the section by ear."
}
}
];
seed.partGraph = [];

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(<FirstEarCheckCallout song={song} />);

expect(screen.getByText("0:10 벌스에서 아직 귀 확인이 필요합니다.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "0:10 첫 귀 확인 위치 열기" }));
expect(screen.getByText("0:10 벌스에서 귀로 확인한 다음 합주를 시작하세요.")).toBeTruthy();

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 { FirstEarCheckCallout } from "./FirstEarCheckCallout";

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

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