Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3603439
feat(workspace): guide tonight's first pickup on map and player
seonghobae Aug 18, 2026
f68e77f
test(workspace): reject inactive pickup handoff source
seonghobae Aug 18, 2026
2842d00
fix(workspace): reject inactive labeled pickup source
seonghobae Aug 18, 2026
96d8dc5
test: reject inactive labeled pickup receiver
seonghobae Aug 18, 2026
d462dc4
fix: require active role for labeled pickup
seonghobae Aug 18, 2026
e01321e
test: reject non-boolean pickup activity flags
seonghobae Aug 18, 2026
cbcdc2d
fix: require boolean pickup activity evidence
seonghobae Aug 18, 2026
34e77dd
test: reject malformed pickup role ids
seonghobae Aug 18, 2026
b01951d
fix: reject malformed pickup role ids
seonghobae Aug 18, 2026
9c8320c
test(workspace): reject malformed pickup section collections
seonghobae Aug 19, 2026
f0aa257
fix(workspace): reject malformed pickup section collections
seonghobae Aug 19, 2026
509cc91
test(workspace): require pickup action execution before arming
seonghobae Aug 19, 2026
7f8e417
fix(workspace): arm pickup only after action executes
seonghobae Aug 19, 2026
f4c1059
test(workspace): execute pickup target in success harness
seonghobae Aug 19, 2026
ea5f932
test(workspace): contain malformed pickup song roots
seonghobae Aug 19, 2026
f003dca
fix(workspace): contain malformed pickup callout roots
seonghobae Aug 19, 2026
2bda565
test(workspace): reject malformed pickup section members
seonghobae Aug 19, 2026
e020a1c
fix(workspace): reject malformed pickup section members
seonghobae Aug 19, 2026
1a2f4ff
test(workspace): enforce pickup time-range contract
seonghobae Aug 19, 2026
bae35c7
fix(workspace): enforce pickup time-range contract
seonghobae Aug 19, 2026
43aa9b4
test(workspace): reject ambiguous pickup identities
seonghobae Aug 19, 2026
c4edbb8
fix(workspace): reject ambiguous pickup identities
seonghobae Aug 19, 2026
9140d12
test(workspace): reject malformed pickup role names
seonghobae Aug 19, 2026
320d040
fix(workspace): reject malformed pickup role names
seonghobae Aug 19, 2026
f909cde
test(i18n): localize pickup section forms
seonghobae Aug 19, 2026
905e934
feat(i18n): localize pickup section forms
seonghobae Aug 19, 2026
379dcdd
fix(i18n): keep pickup section copy localized
seonghobae Aug 19, 2026
0e9f375
test(workspace): preserve pickup identity authority in mixed-edge case
seonghobae Aug 19, 2026
ac6a72a
test(workspace): require executed pickup map navigation
seonghobae Aug 19, 2026
3824622
test(workspace): lock particle-safe Korean pickup copy
seonghobae Aug 21, 2026
6657a47
fix(workspace): keep Korean pickup role copy particle-safe
seonghobae Aug 21, 2026
75e5ad2
Merge branch 'develop' into feat/workspace-first-pickup-handoff
seonghobae Aug 25, 2026
72e9d80
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 pickup with the incoming part, outgoing partner when the graph corroborates it, section, and time so the next action is obvious.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- Workspace and player copy for tonight's first pickup must name the incoming part, outgoing partner when corroborated, section, and time so the next action is obvious.

## 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 pickup on the workspace and player so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback.
- 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). Workspace and player name tonight's first pickup so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. The ready workspace also 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/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
44 changes: 44 additions & 0 deletions apps/desktop/src/features/player/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it, vi } from "vitest";
import { PlayerFeature } from "./index";

describe("PlayerFeature", () => {
it("asks the room to analyze first when no song is loaded", () => {
render(<PlayerFeature title="Player" />);
expect(
screen.getByText("Analyze tonight's song first, then hear the first pickup from this player.")
).toBeTruthy();
});

it("keeps the pickup hear action unavailable without a player playback callback", () => {
render(<PlayerFeature title="Player" song={createDemoRehearsalSong()} />);

expect(
screen.queryByRole("button", {
name: "Hear Lead Vocal pick up from Bass Guitar at 0:30"
})
).toBeNull();
expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy();
});

it("delegates the pickup hear action to the owning player callback", () => {
const onPlayFromSeconds = vi.fn();
render(
<PlayerFeature
title="Player"
song={createDemoRehearsalSong()}
onPlayFromSeconds={onPlayFromSeconds}
/>
);

fireEvent.click(
screen.getByRole("button", {
name: "Hear Lead Vocal pick up from Bass Guitar at 0:30"
})
);

expect(onPlayFromSeconds).toHaveBeenCalledTimes(1);
expect(onPlayFromSeconds).toHaveBeenCalledWith(30);
});
});
26 changes: 20 additions & 6 deletions apps/desktop/src/features/player/index.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import { FirstPickupCallout } from "../workspace/FirstPickupCallout";
import { createTranslator, detectPreferredLocale } from "../../i18n";

/** Documented. */
export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) {
const { title, song } = props;
type PlayerFeatureProps = {
title: string;
song?: RehearsalSong | null;
onPlayFromSeconds?: (startSeconds: number) => void;
};

/** Player surface that names tonight's first pickup and delegates playback to the owning player. */
export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) {
const t = createTranslator(detectPreferredLocale());

if (!song) {
return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<p style={{ color: "#999" }}>No song loaded. Start an analysis to use the player.</p>
<p style={{ color: "#999" }}>{t("firstPickupNeedsSong")}</p>
</section>
);
}

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
<FirstPickupCallout
song={song}
actionMode="callback-only"
onHearPickup={onPlayFromSeconds}
/>
<div
style={{
padding: "16px",
backgroundColor: "#fafafa",
borderRadius: "8px",
border: "1px solid #e8e8e8",
marginTop: "16px",
}}
>
<div style={{ marginBottom: "12px" }}>
Expand All @@ -31,9 +45,9 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{song.sections.map((section) => (
{song.sections.map((section, sectionIndex) => (
<span
key={section.id}
key={`${section.id}-${sectionIndex}`}
style={{
padding: "4px 12px",
borderRadius: "16px",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstPickupCallout } from "./FirstPickupCallout";

/** Cast runtime input through the static song contract to exercise the renderer trust boundary. */
function runtimeSong(value: unknown): RehearsalSong {
return value as RehearsalSong;
}

describe("FirstPickupCallout malformed song root", () => {
it("renders unavailable guidance instead of crashing when the runtime song root is null", () => {
expect(() => render(<FirstPickupCallout song={runtimeSong(null)} />)).not.toThrow();
expect(
screen.getByText("No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.")
).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstPickupCallout } from "./FirstPickupCallout";

describe("FirstPickupCallout section-form localization", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("uses particle-safe Korean section-form copy instead of exposing the raw verse enum", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0]!.name = "베이스 기타";
song.sections[0]!.roles[2]!.name = "피아노";

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

expect(
screen.getByText("0:30 벌스 끝에서 피아노 파트가 베이스 기타의 넘김을 받습니다.")
).toBeTruthy();
expect(screen.queryByText("피아노이 0:30 벌스 끝에서 베이스 기타의 넘김을 받습니다.")).toBeNull();
expect(screen.queryByText(/verse 끝에서/)).toBeNull();
});

it("localizes an explicit pickup form without changing its domain label or guessing Hangul particles", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const section = song.sections[0]!;
section.id = "pickup-localized";
section.label = "pickup";
section.timeRange = { start: 8, end: 10 };
section.roles = [
{
...section.roles[2]!,
id: "lead-vocal-pickup",
name: "피아노"
}
];
section.partGraph = [
{
role_id: "lead-vocal-pickup",
is_active: true,
handoff_to: [],
handoff_from: []
}
];

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

expect(screen.getByText("0:08 픽업에서 피아노 파트가 픽업합니다.")).toBeTruthy();
expect(screen.queryByText("피아노이 0:08 픽업에서 픽업합니다.")).toBeNull();
expect(section.label).toBe("pickup");
expect(screen.queryByText(/pickup에서/)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstPickupCallout } from "./FirstPickupCallout";

describe("FirstPickupCallout renderer-owned action completion", () => {
it("does not arm the pickup when the renderer-owned section target is missing", () => {
render(<FirstPickupCallout song={createDemoRehearsalSong()} />);

fireEvent.click(
screen.getByRole("button", {
name: "Open Lead Vocal pickup from Bass Guitar at 0:30"
})
);

expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy();
expect(
screen.queryByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/)
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstPickupCallout } from "./FirstPickupCallout";

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

it("scrolls immediately when the operating system requests reduced motion", () => {
const matchMedia = vi.fn().mockReturnValue({ matches: true });
vi.stubGlobal("matchMedia", matchMedia);

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const target = document.createElement("div");
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstPickupCallout song={createDemoRehearsalSong()} />);
fireEvent.click(
screen.getByRole("button", {
name: "Open Lead Vocal pickup from Bass Guitar at 0:30"
})
);

expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });
grid.remove();
});
});
Loading
Loading