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 tutti cues after leftover last-dropout remaining last-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 last-dropout remaining last-return tutti after leftover last-dropout remaining last-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 last-dropout remaining last-return tutti on the ready rehearsal map and tell the player the band is all in after leftover last-dropout remaining last-return.
- 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 tutti after leftover last-dropout remaining last-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
220 changes: 220 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,226 @@ describe("Workspace", () => {
);
});

it("names tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-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.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
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "fine-1",
label: "fine",
timeRange: {
start: verse.timeRange.end + 120,
end: verse.timeRange.end + 140
}
}
];

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

const callout = screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti");
expect(callout).toHaveTextContent("Tonight's first leftover last-dropout remaining last-return tutti");
expect(callout).toHaveTextContent(
"Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Count the leftover last-dropout remaining last-return tutti in from the top of fine."
);
});

it("tells leftover last-dropout remaining last-return tutti to come in together after leftover last-dropout remaining last-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.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
},
partGraph: verse.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
},
{
...verse,
id: "fine-1",
label: "fine",
timeRange: {
start: verse.timeRange.end + 120,
end: verse.timeRange.end + 140
}
}
];

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

expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent(
"Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Come in together from the top of fine."
);
});

it("asks the player to verify the required not-yet-all-in then all-in order", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent(
"This cue needs a last-return section where someone is still out, followed by a later section where everyone is in. Before rehearsing, confirm that order in the map."
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
);
});

it("asks for an ear check when the selected part has no named span", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
33 changes: 33 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 { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti";
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,26 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const namedLeftoverLastDropoutRemainingLastReturnTutti = useMemo(
() => firstLeftoverLastDropoutRemainingLastReturnTutti(song, activeRole),
[activeRole, song]
);
const firstLeftoverLastDropoutRemainingLastReturnTuttiCopy = namedLeftoverLastDropoutRemainingLastReturnTutti
? fillRangeCopy(
t(
activeRole && activeRole === namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleId
? "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn"
: "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed"
),
{
remainingRoleName: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleName,
sectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.sectionLabel,
remainingSectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingSectionLabel,
remainingLastReturnSectionLabel:
namedLeftoverLastDropoutRemainingLastReturnTutti.remainingLastReturnSectionLabel
}
)
: t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +330,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-tutti"
aria-label={t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-rose-200">
{t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle")}
</p>
<p className="mt-2 text-sm leading-6 text-slate-100">
{firstLeftoverLastDropoutRemainingLastReturnTuttiCopy}
</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
Loading
Loading