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. After a named part is selected, the ready workspace names that part's first trusted simplification as the next first-pass take.
- 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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Last updated: 2026-03-11
- 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
- the selected part's first trusted simplification as the next first-pass take
- 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 @@ -4,6 +4,7 @@

### Added

- After a named part is selected, name that part's first trusted simplification and tell the player to play that simpler take before adding the rest.
- 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). The ready workspace names tonight's first playable range and the next instrument check. After a named part is selected, it also names that part's first trusted simplification as the next first-pass take. `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,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it } from "vitest";
import { Workspace } from "./Workspace";

const originalLanguage = navigator.language;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("Workspace selected-part first-pass take", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("hides the first-pass take until a named part is selected", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.queryByTestId("selected-part-first-pass")).toBeNull();
});

it("names the selected bass part's simpler take as the first pass", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-first-pass");
expect(callout).toHaveTextContent("Tonight's first-pass take");
expect(callout).toHaveTextContent(
"First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest."
);
});

it("names the selected vocal part's simpler take as the first pass", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
"First pass for Lead Vocal in verse: Keep the sustained note centered; skip the ad-lib on the first pass. Play that simpler take before adding the rest."
);
});

it("keeps Korean copy particle-safe for a Latin role name", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-first-pass");
expect(callout).toHaveTextContent("오늘 이 파트의 첫 간소화");
expect(callout).toHaveTextContent("Bass Guitar 파트");
expect(callout).not.toHaveTextContent("Bass Guitar으로");
expect(callout).toHaveTextContent(
"verse에서 Bass Guitar 파트의 첫 패스: Stay on roots if the chorus entrance gets muddy. 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요."
);
});

it("tells the player to confirm a missing first-pass take instead of hiding the next action", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
simplification: "none"
};

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

expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent(
"This part still needs a trusted first-pass take. Confirm the simpler version before the first run."
);
});
});
23 changes: 23 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 { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification";
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,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const selectedFirstPass = useMemo(
() => (activeRole ? firstPassSimplification(song, activeRole) : null),
[activeRole, song]
);
Comment on lines +167 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Loaded projects inherit stale part selection

Loading another project preserves activeRole from the previous song. firstPassSimplification then displays guidance for an unselected part or a nonexistent one.

Prompt for agents
Reset or reconcile Workspace's activeRole whenever the song identity changes. App.handleLoadProject replaces jobResult while rendering the same Workspace component, so local state survives across projects. Preserve activeRole for ordinary immutable updates to the same song, but clear it when song.id changes or when the selected role no longer exists in roleMap. Add a regression test that selects a role, rerenders Workspace with a different song, and verifies the first-pass callout stays hidden until a role in the new song is selected.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const selectedFirstPassCopy =
selectedFirstPass?.status === "ready"
? fillFirstPassCopy(t("workspaceSelectedFirstPassReady"), {
roleName: selectedFirstPass.roleName,
sectionLabel: selectedFirstPass.sectionLabel,
value: selectedFirstPass.value
})
: t("workspaceSelectedFirstPassUnavailable");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -372,6 +385,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/[0.06] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200">Stem Player</p>
<p className="mt-1 text-sm font-semibold text-slate-100">{activeRoleDetails?.name ?? activeRole}</p>
<section
className="mt-3 rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-3"
data-testid="selected-part-first-pass"
aria-label={t("workspaceSelectedFirstPassTitle")}
>
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em] text-indigo-100">
{t("workspaceSelectedFirstPassTitle")}
</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{selectedFirstPassCopy}</p>
</section>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
Expand Down
212 changes: 212 additions & 0 deletions apps/desktop/src/features/workspace/firstPassSimplification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification";

/** Return the demo song with one role replaced in every section it appears. */
function withRolePatch(
song: RehearsalSong,
roleId: string,
patch: (role: RehearsalRole) => RehearsalRole
): RehearsalSong {
return {
...song,
sections: song.sections.map((section) => ({
...section,
roles: section.roles.map((role) => (role.id === roleId ? patch(role) : role))
}))
};
}

describe("firstPassSimplification", () => {
it("names the selected bass part's first-pass take", () => {
expect(firstPassSimplification(createDemoRehearsalSong(), "bass-guitar")).toEqual({
status: "ready",
value: "Stay on roots if the chorus entrance gets muddy.",
sectionLabel: "verse",
roleName: "Bass Guitar"
});
});

it("names the selected keys part's first-pass take", () => {
expect(firstPassSimplification(createDemoRehearsalSong(), "keys-right")).toEqual({
status: "ready",
value: "Drop the top extension if the chorus turnaround still feels busy.",
sectionLabel: "verse",
roleName: "Keyboard 1 Right Hand"
});
});

it("names the selected vocal part's first-pass take", () => {
expect(firstPassSimplification(createDemoRehearsalSong(), "lead-vocal")).toEqual({
status: "ready",
value: "Keep the sustained note centered; skip the ad-lib on the first pass.",
sectionLabel: "verse",
roleName: "Lead Vocal"
});
});

it("fails closed without a selected named part", () => {
expect(firstPassSimplification(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" });
expect(firstPassSimplification(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" });
expect(firstPassSimplification(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" });
});

it("fails closed on blank, none, or missing simplification values", () => {
const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({
...role,
simplification: " "
}));
const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({
...role,
simplification: "none"
}));
const missing = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => {
const { simplification: _dropped, ...withoutSimplification } = role;
void _dropped;
return withoutSimplification as RehearsalRole;
});

expect(firstPassSimplification(blank, "bass-guitar")).toEqual({ status: "unavailable" });
expect(firstPassSimplification(none, "lead-vocal")).toEqual({ status: "unavailable" });
expect(firstPassSimplification(missing, "keys-right")).toEqual({ status: "unavailable" });
});

it("fails closed when simplification is inherited instead of owned", () => {
const song = createDemoRehearsalSong();
const role = { ...song.sections[0]!.roles[0]! };
const { simplification: _dropped, ...withoutSimplification } = role;
void _dropped;
Object.setPrototypeOf(withoutSimplification, { simplification: "sneaky roots only" });
song.sections[0] = {
...song.sections[0]!,
roles: [withoutSimplification as RehearsalRole, ...song.sections[0]!.roles.slice(1)]
};

expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed on a malformed song root", () => {
expect(firstPassSimplification(null, "bass-guitar")).toEqual({ status: "unavailable" });
expect(firstPassSimplification({ title: "no sections" }, "bass-guitar")).toEqual({
status: "unavailable"
});
});

it("fails closed on a malformed section member", () => {
const song = createDemoRehearsalSong();
song.sections = [null as unknown as RehearsalSong["sections"][number], ...song.sections];

expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed when a section omits roles or a role omits identity", () => {
const missingRoles = createDemoRehearsalSong();
const { roles: _droppedRoles, ...sectionWithoutRoles } = missingRoles.sections[0]!;
void _droppedRoles;
missingRoles.sections[0] = sectionWithoutRoles as RehearsalSong["sections"][number];

const missingRoleIdentity = createDemoRehearsalSong();
const { id: _droppedId, ...roleWithoutId } = missingRoleIdentity.sections[0]!.roles[0]!;
void _droppedId;
missingRoleIdentity.sections[0] = {
...missingRoleIdentity.sections[0]!,
roles: [roleWithoutId as RehearsalRole, ...missingRoleIdentity.sections[0]!.roles.slice(1)]
};

expect(firstPassSimplification(missingRoles, "bass-guitar")).toEqual({ status: "unavailable" });
expect(firstPassSimplification(missingRoleIdentity, "bass-guitar")).toEqual({
status: "unavailable"
});
});

it("fails closed on duplicate role ids in one section", () => {
const song = createDemoRehearsalSong();
song.sections[0] = {
...song.sections[0]!,
roles: [...song.sections[0]!.roles, { ...song.sections[0]!.roles[0]! }]
};

expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed when the same selected id uses two display names", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
verse,
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) =>
role.id === "bass-guitar" ? { ...role, name: "Electric Bass" } : role
)
}
];

expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("skips a non-canonical section label instead of showing it as the first pass", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{ ...verse, label: "drop-D intro" as RehearsalSong["sections"][number]["label"] },
{ ...verse, id: "chorus-1", label: "chorus" }
];

expect(firstPassSimplification(song, "bass-guitar")).toEqual({
status: "ready",
value: "Stay on roots if the chorus entrance gets muddy.",
sectionLabel: "chorus",
roleName: "Bass Guitar"
});
});

it("does not skip an untrusted first canonical take to a later section", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{
...verse,
roles: verse.roles.map((role) =>
role.id === "bass-guitar" ? { ...role, simplification: "none" } : role
)
},
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) =>
role.id === "bass-guitar"
? { ...role, simplification: "Hold roots through the chorus lift." }
: role
)
}
];

expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed for an unknown selected role", () => {
expect(firstPassSimplification(createDemoRehearsalSong(), "missing-role")).toEqual({
status: "unavailable"
});
});
});

describe("fillFirstPassCopy", () => {
it("fills owned tokens and leaves inherited members literal", () => {
expect(
fillFirstPassCopy("First pass for {roleName} in {sectionLabel}: {value} Play that simpler take before adding the rest.", {
roleName: "Bass Guitar",
sectionLabel: "verse",
value: "Stay on roots if the chorus entrance gets muddy."
})
).toBe(
"First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest."
);

expect(fillFirstPassCopy("keep {toString}", {})).toBe("keep {toString}");
});
});
Loading
Loading