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, stop-time cutoffs, 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 @@ -80,7 +80,7 @@ Last updated: 2026-03-11

- Core rehearsal artifacts should include:
- likely harmony by section and by role
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs, with the ready workspace naming tonight's first stop and the next entrance
- 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
- simplification, transposition, capo, tuning, or setup cues where applicable
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- 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.
- Name tonight's first stop on the ready rehearsal map and tell the band to cut together before the next entrance.
- 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, and names tonight's first stop so the room can cut together before the next entrance. `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
36 changes: 36 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,40 @@ describe("SectionRoadmap", () => {

expect(onSongUpdate).not.toHaveBeenCalled();
});

it("names the stop next action only on the destination card", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{ ...verse, id: "verse-1", label: "verse" },
{
...verse,
id: "stop-1",
label: "stop",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` }))
},
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
}
];

render(<SectionRoadmap song={song} activeRole={null} />);

expect(screen.getByTestId("first-stop-action-stop-1")).toHaveTextContent(
"Cut together here, then come back in on chorus."
);
expect(screen.queryByTestId("first-stop-action-verse-1")).toBeNull();
expect(screen.queryByTestId("first-stop-action-chorus-1")).toBeNull();
});

it("omits the stop next action when no stop is named", () => {
setNavigatorLanguage("en-US");
render(<SectionRoadmap song={createDemoRehearsalSong()} activeRole={null} />);

expect(screen.queryByText(/Cut together here/i)).toBeNull();
});
});
13 changes: 13 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useId, useMemo } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
Expand All @@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
const tonightStop = useMemo(() => firstStop(song), [song]);

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
Expand Down Expand Up @@ -120,6 +122,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
<span className="mr-2 text-[0.65rem] font-bold uppercase tracking-wider text-slate-400">{t("sectionGrooveLabel")}</span>
{section.groove}
</div>
{tonightStop && isStopTarget(tonightStop, section.id) ? (
<p
className="mt-2 text-xs font-medium leading-5 text-orange-100"
data-testid={`first-stop-action-${section.id}`}
>
{fillStopCopy(
t(tonightStop.nextSectionLabel ? "sectionStopNextAction" : "sectionStopNextActionBare"),
stopCopyValues(tonightStop)
)}
</p>
) : null}
</CardHeader>

<CardContent className="p-4 space-y-4">
Expand Down
47 changes: 47 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,53 @@ describe("Workspace", () => {
);
});

it("asks for an ear check when the map has no named stop", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.getByTestId("first-stop")).toHaveTextContent("Tonight's first stop");
expect(screen.getByTestId("first-stop")).toHaveTextContent(
"Tonight's first stop still needs an ear check. Listen for the place everyone cuts out together before the next section."
);
});

it("names tonight's first stop and the next entrance", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{ ...verse, id: "verse-1", label: "verse" },
{
...verse,
id: "stop-1",
label: "stop",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-stop` }))
},
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
}
];

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

expect(screen.getByTestId("first-stop")).toHaveTextContent(
"Cut together in stop after verse, then come back in on chorus."
);
});

it("localizes the missing stop callout", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.getByTestId("first-stop")).toHaveTextContent("오늘 먼저 맞출 스톱");
expect(screen.getByTestId("first-stop")).toHaveTextContent(
"오늘 먼저 맞출 스톱은 아직 귀로 확인이 필요합니다. 다음 구간 전에 다 같이 끊는 자리를 들어 보세요."
);
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
25 changes: 25 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 { fillStopCopy, firstStop, stopCopyValues } from "./firstStop";
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 tonightStop = useMemo(() => firstStop(song), [song]);
const firstStopCopy = tonightStop
? fillStopCopy(
t(
tonightStop.previousSectionLabel && tonightStop.nextSectionLabel
? "workspaceFirstStopCheck"
: tonightStop.nextSectionLabel
? "workspaceFirstStopCheckNoPrevious"
: tonightStop.previousSectionLabel
? "workspaceFirstStopCheckNoNext"
: "workspaceFirstStopCheckBare"
),
stopCopyValues(tonightStop)
)
: t("workspaceFirstStopMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -310,6 +326,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<p className="mt-2 text-sm leading-6 text-slate-100">{firstRangeCopy}</p>
</section>

<section
className="rounded-2xl border border-orange-300/20 bg-orange-300/[0.07] p-4"
data-testid="first-stop"
aria-label={t("workspaceFirstStopTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-orange-200">{t("workspaceFirstStopTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstStopCopy}</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">
<p className="text-xs font-black uppercase tracking-[0.24em] text-cyan-300">{t("workspaceSongTimelineLabel")}</p>
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/features/workspace/firstStop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { createDemoRehearsalSong, type RehearsalSong, type RehearsalSection } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillStopCopy, firstStop, isStopTarget, stopCopyValues } from "./firstStop";

function cloneSong(song: RehearsalSong): RehearsalSong {
return {
...song,
sections: song.sections.map((section) => ({
...section,
roles: section.roles.map((role) => ({ ...role }))
}))
};
}

function withSections(song: RehearsalSong, sections: RehearsalSection[]): RehearsalSong {
return { ...song, sections };
}

function namedSection(base: RehearsalSection, id: string, label: RehearsalSection["label"]): RehearsalSection {
return {
...base,
id,
label,
roles: base.roles.map((role) => ({ ...role, id: `${role.id}-${id}` }))
};
}

describe("firstStop", () => {
it("returns null on the demo song so the map asks for an ear check", () => {
expect(firstStop(createDemoRehearsalSong())).toBeNull();
});

it("names the first form-labeled stop and the sections around it", () => {
const verse = createDemoRehearsalSong().sections[0]!;
const song = withSections(createDemoRehearsalSong(), [
namedSection(verse, "verse-1", "verse"),
namedSection(verse, "stop-1", "stop"),
namedSection(verse, "chorus-1", "chorus")
]);

expect(firstStop(song)).toEqual({
sectionId: "stop-1",
sectionLabel: "stop",
previousSectionLabel: "verse",
nextSectionLabel: "chorus"
});
});

it("skips earlier non-stop sections and keeps the first stop only", () => {
const verse = createDemoRehearsalSong().sections[0]!;
const song = withSections(createDemoRehearsalSong(), [
namedSection(verse, "intro-1", "intro"),
namedSection(verse, "verse-1", "verse"),
namedSection(verse, "stop-1", "stop"),
namedSection(verse, "stop-2", "stop"),
namedSection(verse, "chorus-1", "chorus")
]);

expect(firstStop(song)?.sectionId).toBe("stop-1");
});

it("omits previous or next labels when those neighbors are unnamed", () => {
const verse = createDemoRehearsalSong().sections[0]!;
const song = withSections(createDemoRehearsalSong(), [
{ ...namedSection(verse, "verse-1", "verse"), label: "verse" },
namedSection(verse, "stop-1", "stop")
]);
song.sections[0] = { ...song.sections[0]!, label: "none" as RehearsalSection["label"] };

expect(firstStop(song)).toEqual({
sectionId: "stop-1",
sectionLabel: "stop",
previousSectionLabel: undefined,
nextSectionLabel: undefined
});
});

it("does not invent a stop from groove or cue wording", () => {
const song = cloneSong(createDemoRehearsalSong());
song.sections[0] = {
...song.sections[0]!,
groove: "Stop-time hits on beat 4",
roles: song.sections[0]!.roles.map((role) => ({
...role,
cue: { kind: "transition", value: "Cut together on the stop." }
}))
};

expect(firstStop(song)).toBeNull();
});

it("fails closed on malformed runtime roots and members", () => {
expect(firstStop(null as unknown as RehearsalSong)).toBeNull();
expect(firstStop({ sections: "nope" } as unknown as RehearsalSong)).toBeNull();
expect(
firstStop({
...createDemoRehearsalSong(),
sections: [null, "skip", { label: "stop" }]
} as unknown as RehearsalSong)
).toBeNull();
});

it("fails closed when a section identity is missing", () => {
const song = cloneSong(createDemoRehearsalSong());
song.sections[0] = { ...song.sections[0]!, id: " " };

expect(firstStop(song)).toBeNull();
});

it("fails closed when repeated section ids cannot identify one destination card", () => {
const verse = createDemoRehearsalSong().sections[0]!;
const song = withSections(createDemoRehearsalSong(), [
namedSection(verse, "duplicate", "verse"),
namedSection(verse, "duplicate", "stop")
]);

expect(firstStop(song)).toBeNull();
});

it("trims labels before matching the stop form", () => {
const verse = createDemoRehearsalSong().sections[0]!;
const song = withSections(createDemoRehearsalSong(), [
namedSection(verse, "verse-1", "verse"),
{ ...namedSection(verse, "stop-1", "stop"), label: " stop " as RehearsalSection["label"] },
namedSection(verse, "chorus-1", "chorus")
]);

expect(firstStop(song)).toEqual({
sectionId: "stop-1",
sectionLabel: "stop",
previousSectionLabel: "verse",
nextSectionLabel: "chorus"
});
});
});

describe("fillStopCopy", () => {
it("fills own-property tokens once and leaves unknown tokens literal", () => {
expect(
fillStopCopy("Cut together in {sectionLabel} after {previousSectionLabel}.", {
sectionLabel: "stop",
previousSectionLabel: "verse"
})
).toBe("Cut together in stop after verse.");
expect(fillStopCopy("Keep {toString}", { sectionLabel: "stop" })).toBe("Keep {toString}");
});
});

describe("isStopTarget", () => {
it("matches only the named stop identity", () => {
const stop = {
sectionId: "stop-1",
sectionLabel: "stop",
previousSectionLabel: "verse",
nextSectionLabel: "chorus"
};

expect(isStopTarget(stop, "stop-1")).toBe(true);
expect(isStopTarget(stop, " verse-1 ")).toBe(false);
expect(isStopTarget(stop, " ")).toBe(false);
});
});

describe("stopCopyValues", () => {
it("exposes empty neighbor tokens when those labels are missing", () => {
expect(
stopCopyValues({
sectionId: "stop-1",
sectionLabel: "stop"
})
).toEqual({
sectionLabel: "stop",
previousSectionLabel: "",
nextSectionLabel: ""
});
});
});
Loading
Loading