Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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