Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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 offers a download of tonight's first-action cue sheet, so the export starts with what to play first.
- 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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ 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 a download of tonight's first-action cue sheet
- 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
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form, with the cue sheet leading with tonight's first playable-range action

## Confidence, edits, and provenance

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

- Lead the cue sheet with tonight's first playable-range action and name that download on the first-range card.
- 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, the next instrument check, and a download of tonight's first-action cue sheet. `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
68 changes: 68 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ describe("Workspace", () => {
expect(callout).toHaveTextContent(
"Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."
);
expect(callout).toContainElement(
screen.getByRole("button", { name: "Download tonight's first-action sheet" })
);
});

it("asks for an ear check when the selected part has no named span", () => {
Expand Down Expand Up @@ -196,6 +199,70 @@ describe("Workspace", () => {
);
});

it("names tonight's first-action download and leads the cue sheet with that row", async () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const createObjectUrl = vi.fn(() => "blob:cuesheet");
const revokeObjectUrl = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectUrl
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectUrl
});

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

const callout = screen.getByTestId("first-range-squeeze");
const download = screen.getByRole("button", { name: "Download tonight's first-action sheet" });
expect(callout).toContainElement(download);

fireEvent.click(download);

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const csv = await blob.text();
const lines = csv.split("\n");
expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes");
expect(lines[1]).toBe(
"Tonight first,Straight eighths with a late snare feel,Bass Guitar,C#m7,Hold through the pickup before the downbeat.,high,Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."
);
expect(lines[2]).toContain("verse,Straight eighths with a late snare feel,Bass Guitar");
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:cuesheet");
});

it("does not invent a first-action lead when the first range still needs an ear check", async () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
...role,
range: { lowestNote: "", highestNote: "none" },
overlapWarnings: []
}));
const createObjectUrl = vi.fn(() => "blob:cuesheet-missing");
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectUrl
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-action sheet" }));

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const lines = (await blob.text()).split("\n");
expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes");
expect(lines[1]).toMatch(/^verse,/);
expect(lines.some((line) => line.startsWith("Tonight first,"))).toBe(false);
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down Expand Up @@ -325,5 +392,6 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByRole("button", { name: "오늘 먼저 할 일 시트 받기" })).toBeTruthy();
});
});
14 changes: 13 additions & 1 deletion 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 { firstCueSheetLead } from "./firstCueSheetLead";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -228,7 +229,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

/** Documented. */
const handleExportCueSheet = () => {
const csv = generateCueSheetCsv(song);
const csv = generateCueSheetCsv(song, { leadRow: firstCueSheetLead(song, activeRole, t) });

@devin-ai-integration devin-ai-integration Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Stale role desynchronizes action card

When a replacement song lacks the selected role, currentSongRoleFilter clears it only for export. The firstRange card still reports no playable range.

Devin Review

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

downloadTextFile(csv, "text/csv;charset=utf-8;", `${sanitizeFilename(song.title)}_cuesheet.csv`);
};

Expand Down Expand Up @@ -308,6 +309,17 @@ 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>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExportCueSheet}
aria-label={t("workspaceFirstRangeDownloadSheet")}
className="mt-3 min-h-10 border-fuchsia-300/30 bg-fuchsia-300/10 font-semibold text-fuchsia-50 hover:bg-fuchsia-300/20 hover:text-white"
>
<Download className="mr-2 size-4 text-fuchsia-200" aria-hidden="true" />
{t("workspaceFirstRangeDownloadSheet")}
</Button>
</section>

<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
Expand Down
140 changes: 140 additions & 0 deletions apps/desktop/src/features/workspace/firstCueSheetLead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { createTranslator } from "../../i18n";
import { firstCueSheetLead } from "./firstCueSheetLead";

describe("firstCueSheetLead", () => {
const t = createTranslator("en");

it("leads with the first clashing span and the instrument-check next action", () => {
expect(firstCueSheetLead(createDemoRehearsalSong(), null, t)).toEqual({
section: "Tonight first",
groove: "Straight eighths with a late snare feel",
role: "Bass Guitar",
harmony: "C#m7",
cue: "Hold through the pickup before the downbeat.",
priority: "high",
notes: "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."
});
});

it("limits the lead row to the selected part", () => {
const lead = firstCueSheetLead(createDemoRehearsalSong(), "lead-vocal", t);

expect(lead?.role).toBe("Lead Vocal");
expect(lead?.harmony).toBe("C#m7");
expect(lead?.notes).toBe(
"Lead Vocal sits G#3–C#5 in verse. Hear that clash on your instrument before the verse."
);
});

it("uses the check copy when the first span has no clash", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
...role,
overlapWarnings: []
}));

expect(firstCueSheetLead(song, null, t)?.notes).toBe(
"Bass Guitar sits C#2–E3 in verse. Check that span on your instrument before the verse."
);
});

it("keeps the lead row on the exact repeated section selected by the squeeze", () => {
const song = createDemoRehearsalSong();
const section = song.sections[0]!;
const bass = section.roles[0]!;

song.sections = [
{
...section,
id: "repeat-verse-earlier",
label: "verse",
groove: "Earlier groove",
roles: [
{
...bass,
harmony: { ...bass.harmony, chord: "Am7" },
cue: { ...bass.cue, value: "Earlier cue" },
rehearsalPriority: "low",
overlapWarnings: []
}
]
},
{
...section,
id: "repeat-verse-later",
label: "verse",
groove: "Later clash groove",
roles: [
{
...bass,
harmony: { ...bass.harmony, chord: "D7" },
cue: { ...bass.cue, value: "Later cue" },
rehearsalPriority: "high",
overlapWarnings: ["Register clash"]
}
]
}
];

expect(firstCueSheetLead(song, "bass-guitar", t)).toMatchObject({
groove: "Later clash groove",
harmony: "D7",
cue: "Later cue",
priority: "high"
});
});

it("preserves literal none values from the selected source row", () => {
const song = createDemoRehearsalSong();
const section = song.sections[0]!;
const bass = section.roles[0]!;

song.sections[0] = {
...section,
groove: "none",
roles: [
{
...bass,
harmony: { ...bass.harmony, chord: "none" },
cue: { ...bass.cue, value: "none" },
rehearsalPriority: "none"
},
...section.roles.slice(1)
]
};

expect(firstCueSheetLead(song, "bass-guitar", t)).toMatchObject({
groove: "none",
harmony: "none",
cue: "none",
priority: "none"
});
});

it("returns null when no named span exists", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
...role,
range: { lowestNote: "", highestNote: "none" },
overlapWarnings: []
}));

expect(firstCueSheetLead(song, null, t)).toBeNull();
});

it("fails closed on malformed runtime collections after a squeeze match is impossible", () => {
expect(firstCueSheetLead(null as unknown as RehearsalSong, null, t)).toBeNull();
});

it("keeps formula-shaped harmony literal so CSV escaping can neutralize it later", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
harmony: { chord: "=Cmaj7", functionLabel: "vi pedal anchor", source: "model" }
};

expect(firstCueSheetLead(song, "bass-guitar", t)?.harmony).toBe("=Cmaj7");
});
});
93 changes: 93 additions & 0 deletions apps/desktop/src/features/workspace/firstCueSheetLead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import type { TranslationKey } from "../../i18n";
import type { CueSheetLeadRow } from "../../lib/export";
import { fillRangeCopy, firstRangeSqueezeTarget, meaningfulRangeText } from "./firstRangeSqueeze";

/** Documented. */
type Translator = (key: TranslationKey) => string;

/** Documented. */
function isRuntimeObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Preserve literal source-row text while trimming surrounding whitespace. */
function runtimeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/**
* Build the cue-sheet lead row for tonight's first playable-range action.
*
* Retains the exact section/role target chosen by the range selector instead
* of rematching display labels, so repeated form labels cannot attach an
* earlier section's groove, harmony, cue, or priority to a later clash.
*/
export function firstCueSheetLead(
song: RehearsalSong,
activeRole: string | null,
t: Translator
): CueSheetLeadRow | null {
const target = firstRangeSqueezeTarget(song, activeRole);
if (!target) {
return null;
}
const { squeeze } = target;

const runtimeSong: unknown = song;
if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
return null;
}

const sectionValue = runtimeSong.sections[target.sectionIndex];
if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) {
return null;
}
if (meaningfulRangeText(sectionValue.label) !== squeeze.sectionLabel) {
return null;
}
if (target.sectionId && meaningfulRangeText(sectionValue.id) !== target.sectionId) {
return null;
}

const roleValue = sectionValue.roles[target.roleIndex];
if (!isRuntimeObject(roleValue) || !isRuntimeObject(roleValue.range)) {
return null;
}
if (meaningfulRangeText(roleValue.id) !== target.roleId) {
return null;
}
if (activeRole && target.roleId !== activeRole) {
return null;
}
if (meaningfulRangeText(roleValue.name) !== squeeze.roleName) {
return null;
}
if (
meaningfulRangeText(roleValue.range.lowestNote) !== squeeze.lowestNote
|| meaningfulRangeText(roleValue.range.highestNote) !== squeeze.highestNote
) {
return null;
Comment thread
seonghobae marked this conversation as resolved.
}

const harmony = isRuntimeObject(roleValue.harmony) ? runtimeText(roleValue.harmony.chord) : "";
const cue = isRuntimeObject(roleValue.cue) ? runtimeText(roleValue.cue.value) : "";

return {
section: t("workspaceCueSheetFirstActionSection"),
groove: runtimeText(sectionValue.groove),
role: squeeze.roleName,
harmony,
cue,
priority: runtimeText(roleValue.rehearsalPriority),
notes: fillRangeCopy(
t(squeeze.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"),
{
roleName: squeeze.roleName,
lowestNote: squeeze.lowestNote,
highestNote: squeeze.highestNote,
sectionLabel: squeeze.sectionLabel
}
)
};
}
Loading
Loading