Skip to content
Draft
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, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and the first tacet so the band does not fill a rest.
- 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, the next instrument check, and the first tacet so the band does not fill a rest
- 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 @@ -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 tacet on the ready rehearsal map and tell the player to stay out of that section until the next named 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 the first tacet so the band does not fill a rest. `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
49 changes: 49 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,54 @@ describe("Workspace", () => {
);
});

it("names tonight's first tacet and the next sit-out", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0] = {
...song.sections[0]!,
partGraph: song.sections[0]!.partGraph.map((node) =>
node.role_id === "keys-right" ? { ...node, is_active: false } : node
)
};

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

const callout = screen.getByTestId("first-tacet");
expect(callout).toHaveTextContent("Tonight's first tacet");
expect(callout).toHaveTextContent(
"Keyboard 1 Right Hand sits out of verse. Stay out until the next named section."
);
});

it("keeps the selected active part on tonight's first tacet", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0] = {
...song.sections[0]!,
partGraph: song.sections[0]!.partGraph.map((node) =>
node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
)
};

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

expect(screen.getByTestId("first-tacet")).toHaveTextContent(
"Lead Vocal sits out of verse. Stay out until the next named section."
);
});

it("asks the player to confirm the sit-out when every part is active", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

expect(screen.getByTestId("first-tacet")).toHaveTextContent(
"Tonight's first tacet still needs a sit-out. Confirm who sits before the first section."
);
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down Expand Up @@ -325,5 +373,6 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByText("오늘 먼저 쉴 파트")).toBeTruthy();
});
});
16 changes: 16 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 { firstTacet } from "./firstTacet";
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,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const namedTacet = useMemo(() => firstTacet(song, activeRole), [activeRole, song]);
const firstTacetCopy = namedTacet
? fillRangeCopy(t("workspaceFirstTacetNamed"), {
roleName: namedTacet.roleName,
sectionLabel: namedTacet.sectionLabel
})
Comment thread
seonghobae marked this conversation as resolved.
: t("workspaceFirstTacetMissing");

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -309,6 +317,14 @@ 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-orange-300/20 bg-orange-300/[0.07] p-4"
data-testid="first-tacet"
aria-label={t("workspaceFirstTacetTitle")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-orange-200">{t("workspaceFirstTacetTitle")}</p>
<p className="mt-2 text-sm leading-6 text-slate-100">{firstTacetCopy}</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
94 changes: 94 additions & 0 deletions apps/desktop/src/features/workspace/firstTacet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { fillRangeCopy } from "./firstRangeSqueeze";
import { firstTacet } from "./firstTacet";

function withSitOut(
song: RehearsalSong,
roleId: string,
isActive: boolean | undefined = false
): RehearsalSong {
return {
...song,
sections: song.sections.map((section, index) =>
index === 0
? {
...section,
partGraph: section.partGraph.map((node) => {
if (node.role_id !== roleId) {
return node;
}
if (isActive === undefined) {
const rest: Record<string, unknown> = {
role_id: node.role_id,
handoff_to: node.handoff_to,
handoff_from: node.handoff_from
};
return rest as RehearsalSong["sections"][number]["partGraph"][number];
}
return { ...node, is_active: isActive };
})
}
: section
)
};
}

describe("firstTacet", () => {
it("returns null on the demo song where every graph node is active", () => {
expect(firstTacet(createDemoRehearsalSong())).toBeNull();
});

it("names the first explicit sit-out from existing part-graph evidence", () => {
expect(firstTacet(withSitOut(createDemoRehearsalSong(), "keys-right"))).toEqual({
sectionLabel: "verse",
roleName: "Keyboard 1 Right Hand"
});
});

it("skips blank labels until a named sit-out exists", () => {
const song = withSitOut(createDemoRehearsalSong(), "keys-right");
song.sections[0] = { ...song.sections[0]!, label: "none" as RehearsalSong["sections"][number]["label"] };
expect(firstTacet(song)).toBeNull();
});

it("keeps the selected active part on tonight's first tacet", () => {
const song = withSitOut(createDemoRehearsalSong(), "lead-vocal");
expect(firstTacet(song, "lead-vocal")).toEqual({
sectionLabel: "verse",
roleName: "Lead Vocal"
});
expect(firstTacet(song, "bass-guitar")).toBeNull();
});

it("ignores inherited is_active evidence", () => {
const song = createDemoRehearsalSong();
const inherited = Object.create({ is_active: false, role_id: "keys-right" }) as RehearsalSong["sections"][number]["partGraph"][number];
song.sections[0] = {
...song.sections[0]!,
partGraph: [inherited, ...song.sections[0]!.partGraph]
};
expect(firstTacet(song)).toBeNull();
});

it("does not treat a missing is_active flag as a sit-out", () => {
expect(firstTacet(withSitOut(createDemoRehearsalSong(), "keys-right", undefined))).toBeNull();

Check failure on line 75 in apps/desktop/src/features/workspace/firstTacet.test.ts

View workflow job for this annotation

GitHub Actions / release-preflight

src/features/workspace/firstTacet.test.ts > firstTacet > does not treat a missing is_active flag as a sit-out

AssertionError: expected { sectionLabel: 'verse', …(1) } to be null - Expected: null + Received: { "roleName": "Keyboard 1 Right Hand", "sectionLabel": "verse", } ❯ src/features/workspace/firstTacet.test.ts:75:88

Check failure on line 75 in apps/desktop/src/features/workspace/firstTacet.test.ts

View workflow job for this annotation

GitHub Actions / ci / build-and-test

src/features/workspace/firstTacet.test.ts > firstTacet > does not treat a missing is_active flag as a sit-out

AssertionError: expected { sectionLabel: 'verse', …(1) } to be null - Expected: null + Received: { "roleName": "Keyboard 1 Right Hand", "sectionLabel": "verse", } ❯ src/features/workspace/firstTacet.test.ts:75:88
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

it("fails closed on malformed runtime roots", () => {
for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) {
expect(firstTacet(malformed as unknown as RehearsalSong)).toBeNull();
}
});
});

describe("tacet copy filling", () => {
it("keeps rehearsal values literal", () => {
expect(
fillRangeCopy("{roleName} sits out of {sectionLabel}.", {
roleName: "Bass {sectionLabel}",
sectionLabel: "verse"
})
).toBe("Bass {sectionLabel} sits out of verse.");
});
});
111 changes: 111 additions & 0 deletions apps/desktop/src/features/workspace/firstTacet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import { meaningfulRangeText } from "./firstRangeSqueeze";

/** Tonight's first named sit-out the selected part should take. */
export type FirstTacet = {
sectionLabel: string;
roleName: string;
};

/** Return whether an untrusted runtime value is a plain object record. */
function isRuntimeObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */
function ownActiveFlag(value: Record<string, unknown>): boolean | null {
if (!Object.prototype.hasOwnProperty.call(value, "is_active")) {
return null;
}
if (value.is_active === true) {
return true;
}
if (value.is_active === false) {
return false;
}
return null;
}

/**
* Resolve a named role from a section's own-property `roles` list.
*
* Blank, `none`, inherited, or non-string names are not rehearsal authority.
*/
function namedRoleOnSection(
sectionValue: Record<string, unknown>,
roleId: string
): string | undefined {
if (!Array.isArray(sectionValue.roles)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return undefined;
}
for (const roleValue of sectionValue.roles) {
if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) {
continue;
}
if (meaningfulRangeText(roleValue.id) !== roleId) {
continue;
}
return meaningfulRangeText(
Object.prototype.hasOwnProperty.call(roleValue, "name") ? roleValue.name : undefined
);
}
return undefined;
}

/**
* Pick the first explicit sit-out a player should honor before playing.
*
* Uses existing `partGraph` `is_active: false` authority already produced by
* analysis. This is a tacet: the part does not play the named section. It is
* not a dropout (leaving after playing), handoff, Fine, or first breath.
* Inherited `is_active`, missing graph nodes, blank labels, and malformed
* roots fail closed. When a role is selected, only that part's own-property
* sit-out is named.
*/
export function firstTacet(
song: RehearsalSong | unknown,
activeRole: string | null = null
): FirstTacet | null {
if (!isRuntimeObject(song) || !Array.isArray(song.sections)) {
return null;
}

for (const sectionValue of song.sections) {
if (!isRuntimeObject(sectionValue)) {
continue;
}
const sectionLabel = meaningfulRangeText(sectionValue.label);
if (!sectionLabel) {
continue;
}
if (!Array.isArray(sectionValue.partGraph)) {
continue;
}

for (const nodeValue of sectionValue.partGraph) {
if (
!isRuntimeObject(nodeValue) ||
!Object.prototype.hasOwnProperty.call(nodeValue, "role_id")
) {
continue;
}
const roleId = meaningfulRangeText(nodeValue.role_id);
if (!roleId) {
continue;
}
if (activeRole && roleId !== activeRole) {
continue;
}
if (ownActiveFlag(nodeValue) !== false) {
continue;
}
const roleName = namedRoleOnSection(sectionValue, roleId);
if (!roleName) {
continue;
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
}
Comment thread
seonghobae marked this conversation as resolved.
return { sectionLabel, roleName };
}
Comment thread
seonghobae marked this conversation as resolved.
}

return null;
}
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe("i18n", () => {
const t = createTranslator("ko");
expect(t("appTitle")).toBe("BandScope");
expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구");
expect(t("workspaceFirstTacetTitle")).toBe("오늘 먼저 쉴 파트");
});

it("falls back to English when a Korean translation is missing", () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
"workspaceFirstTacetTitle": "Tonight's first tacet",
"workspaceFirstTacetNamed": "{roleName} sits out of {sectionLabel}. Stay out until the next named section.",
"workspaceFirstTacetMissing": "Tonight's first tacet still needs a sit-out. Confirm who sits before the first section.",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
3 changes: 3 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
"workspaceFirstTacetTitle": "오늘 먼저 쉴 파트",
"workspaceFirstTacetNamed": "{roleName}은 {sectionLabel}에서 쉽니다. 다음 이름 있는 구간까지 들어오지 마세요.",
"workspaceFirstTacetMissing": "오늘 먼저 쉴 파트는 아직 없습니다. 첫 구간 전에 누가 쉬는지 확인해 보세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
1 change: 1 addition & 0 deletions docs/design-system/component-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
- `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`.
- `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy.
- `EmptyState` must remain an actionable state card, not a blank placeholder panel.
- Ready `Workspace` names tonight's first playable range and tonight's first tacet so the map enables the next rehearsal action without opening files or export paths.
- If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging.

## Pattern Backlog
Expand Down
17 changes: 17 additions & 0 deletions docs/doctoring/first-tacet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Tonight's first tacet

The ready rehearsal map names the first explicit sit-out from existing `partGraph` `is_active: false` evidence. This is the section a player should not play so the band does not fill a rest. It is not a dropout, handoff, Fine, or last-line breath.

## Next action

- Named: stay out of the named section until the next named section.
- Missing: confirm who sits before the first section.

## Security Notes

- Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project.
- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property `is_active: false` on a named section.
- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a tacet. Inherited `is_active` is isolated. When a role is selected, only that part's own-property sit-out is named.
- Safe failure: inherited flags, blank labels, missing names, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a sit-out.
- Logging/privacy: rejected or accepted sit-outs are not logged. Copy interpolation keeps rehearsal values literal.
- Tests: `firstTacet.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys sit-out, selected-role scoping, inherited flags, missing `is_active`, and literal copy filling.
Loading