Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
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, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and the first part-to-part handoff to lock before the next section.
- 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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
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, the next instrument check, and the first part-to-part handoff to lock before the next section
- 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 @@ -5,6 +5,7 @@
### Added

- 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.
- Name tonight's first part-to-part handoff on the ready rehearsal map and tell the player to lock that pass 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, the next instrument check, and the first part-to-part handoff to lock before the next section. `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
58 changes: 58 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ function setNavigatorLanguage(language: string) {
});
}

function withHandoffDestination(song: RehearsalSong): RehearsalSong {
const source = song.sections[0];
return {
...song,
sections: [
source,
{
...source,
id: "chorus-1",
label: "chorus",
partGraph: source.partGraph.map((node) => ({
...node,
handoff_to: [],
handoff_from: []
}))
}
]
};
}
Comment thread
seonghobae marked this conversation as resolved.

describe("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
Expand Down Expand Up @@ -196,6 +216,43 @@ describe("Workspace", () => {
);
});

it("names tonight's first part handoff and the next lock-in", () => {
setNavigatorLanguage("en-US");
const song = withHandoffDestination(createDemoRehearsalSong());

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

const callout = screen.getByTestId("first-part-handoff");
expect(callout).toHaveTextContent("Tonight's first handoff");
expect(callout).toHaveTextContent(
"Bass Guitar hands off to Lead Vocal in chorus. Lock that pass before the chorus."
);
});

it("keeps the selected receiver in tonight's first handoff", () => {
setNavigatorLanguage("en-US");
const song = withHandoffDestination(createDemoRehearsalSong());

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("first-part-handoff")).toHaveTextContent(
"Bass Guitar hands off to Lead Vocal in chorus. Lock that pass before the chorus."
);
});

it("asks the player to pick a pass when the selected part is not on one", () => {
setNavigatorLanguage("en-US");
const song = withHandoffDestination(createDemoRehearsalSong());

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" }));

expect(screen.getByTestId("first-part-handoff")).toHaveTextContent(
"Tonight's first handoff still needs a pass. Pick who receives the next entrance and lock that pass before the first section."
);
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down Expand Up @@ -325,5 +382,6 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByText("오늘 먼저 맞출 넘김")).toBeTruthy();
});
});
17 changes: 17 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { firstHandoff } from "./firstHandoff";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -163,6 +164,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const namedHandoff = useMemo(() => firstHandoff(song, activeRole), [activeRole, song]);
const firstHandoffCopy = namedHandoff
? fillRangeCopy(t("workspaceFirstHandoffPass"), {
fromRole: namedHandoff.fromRole,
toRole: namedHandoff.toRole,
sectionLabel: namedHandoff.sectionLabel
})
: t("workspaceFirstHandoffMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +318,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<p className="text-xs font-black uppercase tracking-[0.24em] text-fuchsia-200">{t("workspaceFirstRangeTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstRangeCopy}</p>
</section>
<section
className="rounded-2xl border border-sky-300/20 bg-sky-300/[0.07] p-4"
data-testid="first-part-handoff"
aria-label={t("workspaceFirstHandoffTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-sky-200">{t("workspaceFirstHandoffTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstHandoffCopy}</p>
</section>

<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<section className="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4 md:col-span-2">
Expand Down
245 changes: 245 additions & 0 deletions apps/desktop/src/features/workspace/firstHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillRangeCopy } from "./firstRangeSqueeze";
import { firstHandoff } from "./firstHandoff";

function withDestination(song: RehearsalSong): RehearsalSong {
const source = song.sections[0];
return {
...song,
sections: [
source,
{
...source,
id: "chorus-1",
label: "chorus",
partGraph: source.partGraph.map((node) => ({
...node,
handoff_to: [],
handoff_from: []
}))
}
]
};
}

function withPartGraph(
song: RehearsalSong,
partGraph: RehearsalSong["sections"][number]["partGraph"]
): RehearsalSong {
const readySong = withDestination(song);
return {
...readySong,
sections: readySong.sections.map((section, index) =>
index === 0 ? { ...section, partGraph } : section
)
};
}

describe("firstHandoff", () => {
it("names the first active pass from the source graph at its destination section", () => {
expect(firstHandoff(withDestination(createDemoRehearsalSong()))).toEqual({
sectionLabel: "chorus",
fromRole: "Bass Guitar",
toRole: "Lead Vocal"
});
});

it("names an analysis-derived handoff at the destination section", () => {
const song = createDemoRehearsalSong();
const source = song.sections[0];
const destination = {
...source,
id: "chorus-1",
label: "chorus" as const,
partGraph: source.partGraph.map((node) => ({
...node,
handoff_to: [],
handoff_from: []
}))
};

expect(firstHandoff({ ...song, sections: [source, destination] })).toEqual({
sectionLabel: "chorus",
fromRole: "Bass Guitar",
toRole: "Lead Vocal"
});
});

it("resolves an activity-derived entering receiver from the destination roles", () => {
const song = createDemoRehearsalSong();
const template = song.sections[0];
const bass = template.roles.find((role) => role.id === "bass-guitar");
const vocal = template.roles.find((role) => role.id === "lead-vocal");
if (!bass || !vocal) {
throw new Error("Demo fixture must contain bass and lead vocal roles");
}

const source = {
...template,
roles: [bass],
partGraph: [
{
role_id: "bass-guitar",
is_active: true,
handoff_to: ["lead-vocal"],
handoff_from: []
},
{
role_id: "lead-vocal",
is_active: false,
handoff_to: [],
handoff_from: []
}
]
};
const destination = {
...template,
id: "chorus-1",
label: "chorus",
roles: [vocal],
partGraph: [
{
role_id: "bass-guitar",
is_active: false,
handoff_to: [],
handoff_from: []
},
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: ["bass-guitar"]
}
]
};

expect(firstHandoff({ ...song, sections: [source, destination] })).toEqual({
sectionLabel: "chorus",
fromRole: "Bass Guitar",
toRole: "Lead Vocal"
});
});

it("fails closed when a transition handoff has no valid destination section", () => {
const song = createDemoRehearsalSong();
const malformed = {
...song,
sections: [song.sections[0], null]
} as unknown as RehearsalSong;

expect(firstHandoff(malformed)).toBeNull();
});

it("fails closed when the destination section has no meaningful label", () => {
const song = withDestination(createDemoRehearsalSong());
const destination = { ...song.sections[1], label: " " };

expect(firstHandoff({ ...song, sections: [song.sections[0], destination] })).toBeNull();
});

it("does not invent a transition for a one-section song", () => {
expect(firstHandoff(createDemoRehearsalSong())).toBeNull();
});

it("skips inactive nodes until an active named receiver exists", () => {
const song = withPartGraph(createDemoRehearsalSong(), [
{
role_id: "bass-guitar",
is_active: false,
handoff_to: ["lead-vocal"],
handoff_from: []
},
{
role_id: "keys-right",
is_active: true,
handoff_to: ["lead-vocal"],
handoff_from: []
},
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: ["keys-right"]
}
]);

expect(firstHandoff(song)).toEqual({
sectionLabel: "chorus",
fromRole: "Keyboard 1 Right Hand",
toRole: "Lead Vocal"
});
});

it("skips blank, none, self, and unknown receivers", () => {
const song = withPartGraph(createDemoRehearsalSong(), [
{
role_id: "bass-guitar",
is_active: true,
handoff_to: ["", "none", "bass-guitar", "missing-role", "lead-vocal"],
handoff_from: []
}
]);

expect(firstHandoff(song)).toEqual({
sectionLabel: "chorus",
fromRole: "Bass Guitar",
toRole: "Lead Vocal"
});
});

it("limits the pass to the selected role as giver or receiver", () => {
expect(firstHandoff(withDestination(createDemoRehearsalSong()), "lead-vocal")).toEqual({
sectionLabel: "chorus",
fromRole: "Bass Guitar",
toRole: "Lead Vocal"
});
});

it("returns null when the selected role is not on a named pass", () => {
const song = withDestination(createDemoRehearsalSong());
expect(firstHandoff(song, "keys-right")).toBeNull();
expect(firstHandoff(song, "missing-role")).toBeNull();
});

it("skips inherited or malformed part-graph evidence", () => {
const inherited = Object.create({
role_id: "bass-guitar",
is_active: true,
handoff_to: ["lead-vocal"]
}) as RehearsalSong["sections"][number]["partGraph"][number];
const song = withPartGraph(createDemoRehearsalSong(), [
inherited,
{
role_id: "keys-right",
is_active: true,
handoff_to: ["lead-vocal"],
handoff_from: []
}
]);

expect(firstHandoff(song)).toEqual({
sectionLabel: "chorus",
fromRole: "Keyboard 1 Right Hand",
toRole: "Lead Vocal"
});
});

it("fails closed on malformed runtime roots", () => {
for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) {
expect(firstHandoff(malformed as unknown as RehearsalSong)).toBeNull();
}
});
});

describe("handoff copy filling", () => {
it("keeps rehearsal values literal", () => {
expect(
fillRangeCopy("{fromRole} hands off to {toRole} in {sectionLabel}.", {
fromRole: "Bass {toRole}",
toRole: "Lead Vocal",
sectionLabel: "verse"
})
).toBe("Bass {toRole} hands off to Lead Vocal in verse.");
});
});
Loading
Loading