Skip to content
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 last-dropout remaining last-return cues after leftover last-dropout remaining, 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 last-dropout remaining last-return after leftover last-dropout remaining
- 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 last-dropout remaining last-return on the ready rehearsal map and tell the player who comes back last after leftover last-dropout remaining.
- 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 last-dropout remaining last-return after leftover last-dropout remaining. `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
196 changes: 196 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,202 @@ describe("Workspace", () => {
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});

it("names tonight's first leftover last-dropout remaining last-return after leftover last-dropout remaining", () => {
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.role_id === "lead-vocal"
? { ...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.role_id === "lead-vocal"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "bridge-1",
label: "bridge",
timeRange: {
start: verse.timeRange.end + 20,
end: verse.timeRange.end + 40
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "outro-1",
label: "outro",
timeRange: {
start: verse.timeRange.end + 40,
end: verse.timeRange.end + 60
}
},
{
...verse,
id: "tag-1",
label: "tag",
timeRange: {
start: verse.timeRange.end + 60,
end: verse.timeRange.end + 80
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "keys-right" || node.role_id === "bass-guitar"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "coda-1",
label: "stop",
timeRange: {
start: verse.timeRange.end + 80,
end: verse.timeRange.end + 100
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "bass-guitar" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "ending-1",
label: "ending",
timeRange: {
start: verse.timeRange.end + 100,
end: verse.timeRange.end + 120
}
}
];

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

const callout = screen.getByTestId("first-leftover-last-dropout-remaining-last-return");
expect(callout).toHaveTextContent("Tonight's first final re-entry");
expect(callout).toHaveTextContent(
"Bass Guitar is the last part back at ending; the others started returning at stop. Count Bass Guitar in from the top of ending."
);
});

it("tells leftover last-dropout remaining last-return to come in after leftover last-dropout remaining", () => {
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.role_id === "lead-vocal"
? { ...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.role_id === "lead-vocal"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "bridge-1",
label: "bridge",
timeRange: {
start: verse.timeRange.end + 20,
end: verse.timeRange.end + 40
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "outro-1",
label: "outro",
timeRange: {
start: verse.timeRange.end + 40,
end: verse.timeRange.end + 60
}
},
{
...verse,
id: "tag-1",
label: "tag",
timeRange: {
start: verse.timeRange.end + 60,
end: verse.timeRange.end + 80
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "keys-right" || node.role_id === "bass-guitar"
? { ...node, is_active: false }
: node
)
},
{
...verse,
id: "coda-1",
label: "stop",
timeRange: {
start: verse.timeRange.end + 80,
end: verse.timeRange.end + 100
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "bass-guitar" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "ending-1",
label: "ending",
timeRange: {
start: verse.timeRange.end + 100,
end: verse.timeRange.end + 120
}
}
];

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

expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return")).toHaveTextContent(
"Bass Guitar is the last part back at ending; the others started returning at stop. Come in from the top of ending."
);
});

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

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

expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return")).toHaveTextContent(
"No final re-entry is confirmed yet. Before the first section, check which part returns last after the band begins coming back in."
);
});

it("names tonight's first playable range and the next instrument check", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
31 changes: 31 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 { firstLeftoverLastDropoutRemainingLastReturn } from "./firstLeftoverLastDropoutRemainingLastReturn";
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,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const namedLeftoverLastDropoutRemainingLastReturn = useMemo(
() => firstLeftoverLastDropoutRemainingLastReturn(song, activeRole),
[activeRole, song]
);
const firstLeftoverLastDropoutRemainingLastReturnCopy = namedLeftoverLastDropoutRemainingLastReturn
? fillRangeCopy(
t(
activeRole && activeRole === namedLeftoverLastDropoutRemainingLastReturn.remainingRoleId
? "workspaceFirstLeftoverLastDropoutRemainingLastReturnComeIn"
: "workspaceFirstLeftoverLastDropoutRemainingLastReturnNamed"
),
{
remainingRoleName: namedLeftoverLastDropoutRemainingLastReturn.remainingRoleName,
sectionLabel: namedLeftoverLastDropoutRemainingLastReturn.sectionLabel,
remainingSectionLabel: namedLeftoverLastDropoutRemainingLastReturn.remainingSectionLabel
}
)
: t("workspaceFirstLeftoverLastDropoutRemainingLastReturnMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +328,18 @@ 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-rose-300/20 bg-rose-300/[0.07] p-4"
data-testid="first-leftover-last-dropout-remaining-last-return"
aria-label={t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-rose-200">
{t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTitle")}
</p>
<p className="mt-2 text-sm leading-6 text-slate-100">
{firstLeftoverLastDropoutRemainingLastReturnCopy}
</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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { firstLeftoverLastDropoutRemainingLastReturn } from "./firstLeftoverLastDropoutRemainingLastReturn";

function sectionWithInactiveRoles(
template: RehearsalSong["sections"][number],
id: string,
label: string,
start: number,
inactiveRoleIds: readonly string[]
): RehearsalSong["sections"][number] {
const inactive = new Set(inactiveRoleIds);
return {
...template,
id,
label: label as RehearsalSong["sections"][number]["label"],
timeRange: { start, end: start + 20 },
partGraph: template.partGraph.map((node) => ({
...node,
is_active: !inactive.has(node.role_id)
}))
};
}

describe("firstLeftoverLastDropoutRemainingLastReturn concurrent dropout", () => {
it("rejects a last-return transition when another role drops out in the same section", () => {
const seed = createDemoRehearsalSong();
const template = seed.sections[0]!;
const song: RehearsalSong = {
...seed,
sections: [
sectionWithInactiveRoles(template, "verse-1", "verse", 0, [
"bass-guitar",
"keys-right",
"lead-vocal"
]),
sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [
"keys-right",
"lead-vocal"
]),
sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]),
// The tracked final leftover returns here, but bass drops out at the same time.
// That concurrent dropout invalidates this chain as a completed leftover last-return.
sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar"]),
sectionWithInactiveRoles(template, "tag-1", "tag", 80, [
"bass-guitar",
"keys-right"
]),
sectionWithInactiveRoles(template, "coda-1", "coda", 100, ["bass-guitar"]),
sectionWithInactiveRoles(template, "ending-1", "ending", 120, [])
]
};

expect(firstLeftoverLastDropoutRemainingLastReturn(song)).toBeNull();
});
});
Loading
Loading