Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
abfb399
feat(workspace): name the selected part's first entrance cue
seonghobae Sep 3, 2026
3a09ae1
feat(workspace): consolidate selected-part first-pass domain
seonghobae Sep 3, 2026
5c9d96f
test(workspace): preserve selected-part first-pass mount
seonghobae Sep 3, 2026
baddc60
test(workspace): preserve selected-part first-pass regressions
seonghobae Sep 3, 2026
ff9260e
docs(workspace): preserve selected-part first-pass evidence
seonghobae Sep 3, 2026
39f7fae
feat(workspace): mount consolidated selected-part first-pass guidance
seonghobae Sep 3, 2026
e583873
feat(i18n): consolidate selected-part first-pass copy
seonghobae Sep 3, 2026
424821b
feat(i18n): consolidate Korean first-pass copy
seonghobae Sep 3, 2026
7037ce7
feat(workspace): consolidate selected-part confirmed chord
seonghobae Sep 3, 2026
9375cf1
test(workspace): preserve confirmed-chord mount
seonghobae Sep 3, 2026
7ad3924
test(workspace): preserve confirmed-chord regressions
seonghobae Sep 3, 2026
a5d0750
docs(workspace): preserve confirmed-chord evidence
seonghobae Sep 3, 2026
9a8b30e
feat(workspace): mount consolidated confirmed-chord guidance
seonghobae Sep 3, 2026
958b096
feat(i18n): consolidate confirmed-chord copy
seonghobae Sep 3, 2026
d662173
feat(i18n): consolidate Korean confirmed-chord copy
seonghobae Sep 3, 2026
8581afb
refactor(workspace): share canonical section labels
seonghobae Sep 3, 2026
8821ae1
docs(ui): consolidate selected-part guidance contracts
seonghobae Sep 3, 2026
7d03dc9
test(workspace): reject stale selected roles
seonghobae Sep 3, 2026
c264458
test(workspace): hide stale selected-part guidance
seonghobae Sep 3, 2026
10d9ed0
test(i18n): require particle-safe confirmed chord copy
seonghobae Sep 3, 2026
141d8e9
fix(workspace): fail closed on chord override conflicts
seonghobae Sep 3, 2026
c5ce688
test(workspace): cover conflicting chord overrides
seonghobae Sep 3, 2026
81d022c
fix(i18n): make confirmed chord copy particle-safe
seonghobae Sep 3, 2026
44b1233
fix(workspace): clear roles missing from replacement project
seonghobae Sep 3, 2026
84443ce
docs(workspace): record selected-part ordering invariants
seonghobae Sep 3, 2026
f8a6c2b
docs(workspace): record confirmed chord conflict policy
seonghobae Sep 3, 2026
1f78675
Merge protected develop into selected-part guidance owner
seonghobae Sep 4, 2026
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. After a named part is selected, the ready workspace names that part's first trusted lyric, count, or transition cue as the next entrance.
- 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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Last updated: 2026-03-11
- 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
- the selected part's first trusted lyric, count, or transition cue as the next entrance
- 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

- After a named part is selected, name that part's first trusted lyric, count, or transition cue and tell the player to catch it before the entrance.
- 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 the next instrument check. After a named part is selected, it also names that part's first trusted lyric, count, or transition cue as 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it } from "vitest";
import { Workspace } from "./Workspace";

const originalLanguage = navigator.language;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("Workspace selected-part entrance cue", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("hides the entrance cue until a named part is selected", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);

expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull();
});

it("names the selected bass part's transition as the next entrance", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-entrance-cue");
expect(callout).toHaveTextContent("Tonight's entrance cue");
expect(callout).toHaveTextContent(
"Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat."
);
});

it("names the selected vocal lyric as the next entrance", () => {
setNavigatorLanguage("en-US");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));

expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
'Listen for "city lights" in verse, then Lead Vocal enters.'
);
});

it("keeps Korean copy particle-safe for a Latin role name", () => {
setNavigatorLanguage("ko-KR");
render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const callout = screen.getByTestId("selected-part-entrance-cue");
expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐");
expect(callout).toHaveTextContent("Bass Guitar 파트");
expect(callout).not.toHaveTextContent("Bass Guitar으로");
expect(callout).toHaveTextContent(
"verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat."
);
});

it("tells the player to confirm a missing cue instead of hiding the next action", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
cue: { kind: "transition", value: "none" }
};

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

expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent(
"This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance."
);
});
});
36 changes: 35 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { fillEntranceCueCopy, firstEntranceCue } from "./firstEntranceCue";
import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
Expand Down Expand Up @@ -118,6 +119,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
);
});

/** Documented. */
function entranceCueCopyKey(kind: "lyric" | "count" | "transition"): TranslationKey {
if (kind === "lyric") {
return "workspaceSelectedEntranceCueLyric";
}
if (kind === "count") {
return "workspaceSelectedEntranceCueCount";
}
return "workspaceSelectedEntranceCueTransition";
}

/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);
Expand Down Expand Up @@ -163,6 +175,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const selectedEntranceCue = useMemo(
() => (activeRole ? firstEntranceCue(song, activeRole) : null),
[activeRole, song]
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
);
const selectedEntranceCueCopy =
selectedEntranceCue?.status === "ready"
? fillEntranceCueCopy(t(entranceCueCopyKey(selectedEntranceCue.kind)), {
roleName: selectedEntranceCue.roleName,
sectionLabel: selectedEntranceCue.sectionLabel,
value: selectedEntranceCue.value
})
: t("workspaceSelectedEntranceCueUnavailable");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -372,6 +396,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
<div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/[0.06] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200">Stem Player</p>
<p className="mt-1 text-sm font-semibold text-slate-100">{activeRoleDetails?.name ?? activeRole}</p>
<section
className="mt-3 rounded-xl border border-amber-300/20 bg-amber-300/[0.08] p-3"
data-testid="selected-part-entrance-cue"
aria-label={t("workspaceSelectedEntranceCueTitle")}
>
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em] text-amber-100">
{t("workspaceSelectedEntranceCueTitle")}
</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{selectedEntranceCueCopy}</p>
</section>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
Expand Down
209 changes: 209 additions & 0 deletions apps/desktop/src/features/workspace/firstEntranceCue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillEntranceCueCopy, firstEntranceCue, isAdmittedCueKind } from "./firstEntranceCue";

/** Return the demo song with one role replaced in every section it appears. */
function withRolePatch(
song: RehearsalSong,
roleId: string,
patch: (role: RehearsalRole) => RehearsalRole
): RehearsalSong {
return {
...song,
sections: song.sections.map((section) => ({
...section,
roles: section.roles.map((role) => (role.id === roleId ? patch(role) : role))
}))
};
}

describe("isAdmittedCueKind", () => {
it("admits only lyric, count, and transition", () => {
expect(isAdmittedCueKind("lyric")).toBe(true);
expect(isAdmittedCueKind("count")).toBe(true);
expect(isAdmittedCueKind("transition")).toBe(true);
expect(isAdmittedCueKind("groove")).toBe(false);
expect(isAdmittedCueKind("")).toBe(false);
expect(isAdmittedCueKind(null)).toBe(false);
});
});

describe("firstEntranceCue", () => {
it("names the selected bass part's first transition cue", () => {
expect(firstEntranceCue(createDemoRehearsalSong(), "bass-guitar")).toEqual({
status: "ready",
kind: "transition",
value: "Hold through the pickup before the downbeat.",
sectionLabel: "verse",
roleName: "Bass Guitar"
});
});

it("names the selected keys part's first count cue", () => {
expect(firstEntranceCue(createDemoRehearsalSong(), "keys-right")).toEqual({
status: "ready",
kind: "count",
value: "Enter on beat 2 after the pickup.",
sectionLabel: "verse",
roleName: "Keyboard 1 Right Hand"
});
});

it("names the selected vocal part's first lyric cue", () => {
expect(firstEntranceCue(createDemoRehearsalSong(), "lead-vocal")).toEqual({
status: "ready",
kind: "lyric",
value: "city lights",
sectionLabel: "verse",
roleName: "Lead Vocal"
});
});

it("fails closed without a selected named part", () => {
expect(firstEntranceCue(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" });
expect(firstEntranceCue(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" });
expect(firstEntranceCue(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" });
});

it("fails closed on blank, none, or unknown cue values and kinds", () => {
const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({
...role,
cue: { kind: "transition", value: " " }
}));
const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({
...role,
cue: { kind: "lyric", value: "none" }
}));
const unknownKind = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => ({
...role,
cue: { kind: "groove" as RehearsalRole["cue"]["kind"], value: "on the one" }
}));

expect(firstEntranceCue(blank, "bass-guitar")).toEqual({ status: "unavailable" });
expect(firstEntranceCue(none, "lead-vocal")).toEqual({ status: "unavailable" });
expect(firstEntranceCue(unknownKind, "keys-right")).toEqual({ status: "unavailable" });
});

it("fails closed when cue is inherited instead of owned", () => {
const song = createDemoRehearsalSong();
const role = { ...song.sections[0]!.roles[0]! };
const { cue: _dropped, ...withoutCue } = role;
void _dropped;
Object.setPrototypeOf(withoutCue, { cue: { kind: "lyric", value: "sneaky lyric" } });
song.sections[0] = {
...song.sections[0]!,
roles: [withoutCue as RehearsalRole, ...song.sections[0]!.roles.slice(1)]
};

expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed when cue kind is inherited", () => {
const song = createDemoRehearsalSong();
const inheritedKind = Object.create({ kind: "lyric" }) as RehearsalRole["cue"];
inheritedKind.value = "city lights";
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
cue: inheritedKind
};

expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed on a malformed song root", () => {
expect(firstEntranceCue(null, "bass-guitar")).toEqual({ status: "unavailable" });
expect(firstEntranceCue({ title: "no sections" }, "bass-guitar")).toEqual({
status: "unavailable"
});
});

it("fails closed on duplicate role ids in one section", () => {
const song = createDemoRehearsalSong();
song.sections[0] = {
...song.sections[0]!,
roles: [...song.sections[0]!.roles, { ...song.sections[0]!.roles[0]! }]
};

expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed when the same selected id uses two display names", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
verse,
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) =>
role.id === "bass-guitar" ? { ...role, name: "Electric Bass" } : role
)
}
];

expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("skips a non-canonical section label instead of showing it as the entrance", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{ ...verse, label: "drop-D intro" as RehearsalSong["sections"][number]["label"] },
{ ...verse, id: "chorus-1", label: "chorus" }
];

expect(firstEntranceCue(song, "bass-guitar")).toEqual({
status: "ready",
kind: "transition",
value: "Hold through the pickup before the downbeat.",
sectionLabel: "chorus",
roleName: "Bass Guitar"
});
});

it("does not skip an untrusted first canonical cue to a later section", () => {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{
...verse,
roles: verse.roles.map((role) =>
role.id === "bass-guitar" ? { ...role, cue: { kind: "transition", value: "none" } } : role
)
},
{
...verse,
id: "chorus-1",
label: "chorus",
roles: verse.roles.map((role) =>
role.id === "bass-guitar"
? { ...role, cue: { kind: "transition", value: "Catch the chorus lift." } }
: role
)
}
];

expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" });
});

it("fails closed for an unknown selected role", () => {
expect(firstEntranceCue(createDemoRehearsalSong(), "missing-role")).toEqual({
status: "unavailable"
});
});
});

describe("fillEntranceCueCopy", () => {
it("fills owned tokens and leaves inherited members literal", () => {
expect(
fillEntranceCueCopy("Listen for \"{value}\" in {sectionLabel}, then {roleName} enters.", {
value: "city lights",
sectionLabel: "verse",
roleName: "Lead Vocal"
})
).toBe('Listen for "city lights" in verse, then Lead Vocal enters.');

expect(fillEntranceCueCopy("keep {toString}", {})).toBe("keep {toString}");
});
});
Loading
Loading