Skip to content
Draft
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, leftover sit-out cues after a partial return, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
- 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 and tonight's first leftover sit-out after a partial return
- 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

- Name tonight's first leftover sit-out on the ready rehearsal map and tell returning parts to play without waiting, or the leftover part to stay out.
- 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 tonight's first leftover sit-out after a partial return. `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
76 changes: 76 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,82 @@ describe("Workspace", () => {
);
});

it("names tonight's first leftover sit-out after a partial return", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{
...verse,
partGraph: verse.partGraph.map((node) =>
node.role_id === "bass-guitar" || node.role_id === "keys-right"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "chorus-1",
label: "chorus",
timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 },
partGraph: verse.partGraph.map((node) =>
node.role_id === "keys-right" ? { ...node, is_active: false } : node
)
}
];

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

const callout = screen.getByTestId("first-leftover");
expect(callout).toHaveTextContent("Tonight's first leftover sit-out");
expect(callout).toHaveTextContent(
"Keyboard 1 Right Hand stays tacet in chorus after verse. Play chorus without waiting for Keyboard 1 Right Hand."
);
});

it("tells the leftover part to stay out of the partial return", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{
...verse,
partGraph: verse.partGraph.map((node) =>
node.role_id === "bass-guitar" || node.role_id === "keys-right"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "chorus-1",
label: "chorus",
timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 },
partGraph: verse.partGraph.map((node) =>
node.role_id === "keys-right" ? { ...node, is_active: false } : node
)
}
];

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

expect(screen.getByTestId("first-leftover")).toHaveTextContent(
"Keyboard 1 Right Hand stays tacet in chorus after verse. Stay out of chorus."
);
});

it("asks the player to confirm the leftover sit-out when every part stays active", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

expect(screen.getByTestId("first-leftover")).toHaveTextContent(
"Tonight's first leftover sit-out still needs a named part that stays out after others return. Confirm who stays tacet before the first section."
);
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
24 changes: 24 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 { firstLeftover } from "./firstLeftover";
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,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const namedLeftover = useMemo(() => firstLeftover(song, activeRole), [activeRole, song]);
const firstLeftoverCopy = namedLeftover
? fillRangeCopy(
t(
activeRole && activeRole === namedLeftover.leftoverRoleId
? "workspaceFirstLeftoverStayOut"
: "workspaceFirstLeftoverNamed"
),
{
leftoverRoleName: namedLeftover.leftoverRoleName,
sectionLabel: namedLeftover.sectionLabel,
fromSectionLabel: namedLeftover.fromSectionLabel
}
)
: t("workspaceFirstLeftoverMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +325,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-amber-300/20 bg-amber-300/[0.07] p-4"
data-testid="first-leftover"
aria-label={t("workspaceFirstLeftoverTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-amber-200">{t("workspaceFirstLeftoverTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstLeftoverCopy}</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
204 changes: 204 additions & 0 deletions apps/desktop/src/features/workspace/firstLeftover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillRangeCopy } from "./firstRangeSqueeze";
import { firstLeftover } from "./firstLeftover";

function withChorus(song: RehearsalSong): RehearsalSong {
const verse = song.sections[0]!;
const chorus = {
...verse,
id: "chorus-1",
label: "chorus" as RehearsalSong["sections"][number]["label"],
timeRange: {
start: verse.timeRange.end,
end: verse.timeRange.end + 20
}
};
return { ...song, sections: [verse, chorus] };
}

function withPartialReturn(
song: RehearsalSong,
returningRoleId: string,
leftoverRoleId: string,
leftoverActive: boolean | "omit" = false
): RehearsalSong {
const twoSection = withChorus(song);
return {
...twoSection,
sections: twoSection.sections.map((section, index) => ({
...section,
partGraph: section.partGraph.map((node) => {
if (index === 0) {
if (node.role_id === returningRoleId || node.role_id === leftoverRoleId) {
return { ...node, is_active: false };
}
return node;
}
if (node.role_id === leftoverRoleId) {
if (leftoverActive === "omit") {
const rest: Record<string, unknown> = {
role_id: node.role_id,
handoff_to: node.handoff_to,
handoff_from: node.handoff_from
};
return rest as RehearsalSong["sections"][number]["partGraph"][number];
}
return { ...node, is_active: leftoverActive };
}
return node;
})
}))
};
}

describe("firstLeftover", () => {
it("returns null on the demo song where every graph node is active", () => {
expect(firstLeftover(createDemoRehearsalSong())).toBeNull();
expect(firstLeftover(withChorus(createDemoRehearsalSong()))).toBeNull();
});

it("names the leftover sit-out after a partial return", () => {
expect(
firstLeftover(withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right"))
).toEqual({
sectionLabel: "chorus",
fromSectionLabel: "verse",
leftoverRoleId: "keys-right",
leftoverRoleName: "Keyboard 1 Right Hand"
});
});

it("skips blank leftover labels until a named partial return exists", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right");
song.sections[1] = {
...song.sections[1]!,
label: "none" as RehearsalSong["sections"][number]["label"]
};
expect(firstLeftover(song)).toBeNull();
});

it("keeps the selected active part on tonight's first leftover", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right");
expect(firstLeftover(song, "keys-right")).toEqual({
sectionLabel: "chorus",
fromSectionLabel: "verse",
leftoverRoleId: "keys-right",
leftoverRoleName: "Keyboard 1 Right Hand"
});
expect(firstLeftover(song, "bass-guitar")).toEqual({
sectionLabel: "chorus",
fromSectionLabel: "verse",
leftoverRoleId: "keys-right",
leftoverRoleName: "Keyboard 1 Right Hand"
});
expect(firstLeftover(song, "missing-role")).toBeNull();
});

it("ignores inherited is_active evidence", () => {
const song = withChorus(createDemoRehearsalSong());
const inherited = Object.create({
is_active: false,
role_id: "keys-right"
}) as RehearsalSong["sections"][number]["partGraph"][number];
song.sections[0] = {
...song.sections[0]!,
partGraph: [inherited, ...song.sections[0]!.partGraph]
};
expect(firstLeftover(song)).toBeNull();
});

it("does not treat a missing is_active flag as a leftover", () => {
expect(
firstLeftover(withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right", "omit"))
).toBeNull();
});

it("does not treat a continued sit-out with nobody returning as a leftover", () => {
const song = withChorus(createDemoRehearsalSong());
song.sections = song.sections.map((section) => ({
...section,
partGraph: section.partGraph.map((node) =>
node.role_id === "keys-right" ? { ...node, is_active: false } : node
)
}));
expect(firstLeftover(song)).toBeNull();
});

it("does not treat a full-band return as a leftover", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right", true);
expect(firstLeftover(song)).toBeNull();
});

it("does not treat a new dropout after every original sit-out returns as a leftover", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right", true);
song.sections[1] = {
...song.sections[1]!,
partGraph: song.sections[1]!.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
};
expect(firstLeftover(song)).toBeNull();
});

it("fails closed when the later section has no named leftover role", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right");
song.sections[1] = {
...song.sections[1]!,
roles: song.sections[1]!.roles.map((role) => ({ ...role, name: " " }))
};
expect(firstLeftover(song)).toBeNull();
});

it("fails closed when the later section has no named graph", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right");
song.sections[1] = {
...song.sections[1]!,
partGraph: []
};
expect(firstLeftover(song)).toBeNull();
});

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

it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => {
const song = withPartialReturn(createDemoRehearsalSong(), "bass-guitar", "keys-right");
song.sections[0] = {
...song.sections[0]!,
partGraph: [
{ role_id: " ", is_active: false, handoff_to: [], handoff_from: [] },
{ role_id: "ghost", is_active: false, handoff_to: [], handoff_from: [] },
{ role_id: "keys-right", is_active: "no" as unknown as boolean, handoff_to: [], handoff_from: [] },
...song.sections[0]!.partGraph
]
};
const inheritedId = Object.create({ id: "keys-right", name: "Inherited Keys" });
song.sections[0] = {
...song.sections[0]!,
roles: [inheritedId as RehearsalSong["sections"][number]["roles"][number], ...song.sections[0]!.roles]
};
expect(
firstLeftover({
...song,
sections: [song.sections[0]!, { ...song.sections[1]!, roles: "broken" as unknown as RehearsalSong["sections"][number]["roles"] }]
})
).toBeNull();
expect(firstLeftover({ ...song, sections: [song.sections[0]!, { ...song.sections[1]!, partGraph: null }] })).toBeNull();
});
});

describe("leftover copy filling", () => {
it("keeps rehearsal values literal", () => {
expect(
fillRangeCopy("{leftoverRoleName} stays tacet in {sectionLabel} after {fromSectionLabel}.", {
leftoverRoleName: "Keyboard 1 Right Hand {sectionLabel}",
sectionLabel: "chorus",
fromSectionLabel: "verse"
})
).toBe("Keyboard 1 Right Hand {sectionLabel} stays tacet in chorus after verse.");
});
});
Loading
Loading