Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Name tonight's countable tempo, first-entrance chord shape, and transpose setup in the cockpit so the next rehearsal action is obvious. Do not present a pending metric as a detected song-wide key.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- After analysis, the mounted cockpit Tempo, Key, and Transpose metrics must name a countable BPM, the first-entrance chord shape, and the first-entrance transpose setup when those owned fields exist. Keep missing values pending. Do not invent a song-wide key detector; the Key metric is the starting shape the room can check. Cockpit Open/Lock actions scroll renderer-owned workspace surfaces only.

## Security source

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- 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.
- Name tonight's countable tempo, first-entrance chord shape, and transpose setup in the cockpit so the next rehearsal action is Open/Lock on the matching workspace surface. Missing values stay pending; the Key metric is a starting shape, not a claimed song-wide key.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Changed
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). After analysis, the cockpit names tonight's countable tempo, first-entrance chord shape, and transpose setup and opens the matching workspace surface. Do not treat the Key metric as a song-wide key detector. The ready workspace also 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/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
101 changes: 101 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { MAX_YOUTUBE_URL_LENGTH } from "./lib/analysis";
import { createDemoRehearsalSong } from "@bandscope/shared-types";

// The Score view pulls in ScoreViewer -> pdfjs-dist, which needs DOMMatrix
// (absent in jsdom). Stub the pdf.js bridge so App can mount the real
Expand Down Expand Up @@ -340,6 +341,86 @@ describe("App", () => {
expect(screen.getAllByText(/Pending/i).length).toBeGreaterThanOrEqual(2);
});

it("names tonight's tempo, first-entrance chord, and transpose setup after analysis", async () => {
mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong());
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

await waitFor(() => {
expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy();
});
expect(screen.getAllByText("120 BPM").length).toBeGreaterThan(0);
expect(screen.getByText("Count 120 in before the first entrance.")).toBeTruthy();
expect(screen.getAllByText("C#m7").length).toBeGreaterThan(0);
expect(screen.getByText("Bass Guitar starts in this shape. Check the first entrance.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Open tonight's first chord" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Lock Bass Guitar's setup" })).toBeTruthy();
expect(screen.queryByText(/^Pending$/)).toBeNull();
});

it("opens the matching workspace surface from the cockpit next action", async () => {
mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong());
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy();
});

const tempoTarget = document.getElementById("workspace-surface-tempo");
const harmonyTarget = document.getElementById("workspace-surface-harmony");
expect(tempoTarget).toBeTruthy();
expect(harmonyTarget).toBeTruthy();
const tempoScroll = vi.fn();
const harmonyScroll = vi.fn();
Object.defineProperty(tempoTarget!, "scrollIntoView", { configurable: true, value: tempoScroll });
Object.defineProperty(harmonyTarget!, "scrollIntoView", { configurable: true, value: harmonyScroll });

fireEvent.click(screen.getByRole("button", { name: "Open tonight's count-in" }));
expect(tempoScroll).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
expect(screen.getByText("Count 120 together. Start from the map.")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Open tonight's first chord" }));
expect(harmonyScroll).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
expect(screen.getByText("Check C#m7 at the first entrance before the room starts.")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Lock Bass Guitar's setup" }));
expect(harmonyScroll).toHaveBeenCalledTimes(2);
expect(screen.getByText("Lock Bass Guitar's setup, then count in from the map.")).toBeTruthy();
});

it("keeps a cockpit next action incomplete when the workspace surface is missing", async () => {
mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong());
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy();
});
document.getElementById("workspace-surface-tempo")?.remove();

fireEvent.click(screen.getByRole("button", { name: "Open tonight's count-in" }));
expect(screen.getByText("Count 120 in before the first entrance.")).toBeTruthy();
expect(screen.queryByText("Count 120 together. Start from the map.")).toBeNull();
});

it("localizes ready cockpit metric copy for Korean rehearsal", async () => {
const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong());

try {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /프로젝트 열기/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "오늘 카운트 위치 열기" })).toBeTruthy();
});
expect(screen.getByText("첫 시작 전에 120으로 카운트하세요.")).toBeTruthy();
expect(screen.getByRole("button", { name: "오늘 첫 코드 열기" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Bass Guitar 세팅 잠그기" })).toBeTruthy();
} finally {
languageSpy.mockRestore();
}
});

it("summarizes confidence from the lowest-confidence loaded section", async () => {
const loadedProject = succeededResult().result;
loadedProject.sections.push({
Expand Down Expand Up @@ -1595,6 +1676,26 @@ describe("App", () => {
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});

it("hides cockpit next actions in the Score view and restores them back in the Workspace", async () => {
mockLoadProject.mockResolvedValueOnce(createDemoRehearsalSong());
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy();
});

fireEvent.click(screen.getAllByRole("button", { name: /^Score$/i })[0]);
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open tonight's count-in" })).toBeNull();
expect(screen.queryByRole("button", { name: "Open tonight's first chord" })).toBeNull();

fireEvent.click(screen.getAllByRole("button", { name: /^Workspace$/i })[0]);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open tonight's count-in" })).toBeTruthy();
});
});

it("switches to the Score view from the compact mobile navigation", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);
Expand Down
142 changes: 138 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ import { createTranslator, detectPreferredLocale, type TranslationKey } from "./
import { ScoreView } from "./features/score/ScoreView";
import { Workspace } from "./features/workspace/Workspace";
import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates";
import {
formatMetricCopy,
resolveTonightStartingChord,
resolveTonightTempo,
resolveTonightTransposePlan,
scrollToWorkspaceSurface,
WORKSPACE_SURFACE_HARMONY,
WORKSPACE_SURFACE_TEMPO,
WORKSPACE_SURFACE_TRANSPOSE
} from "./features/workspace/rehearsalMetrics";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
Expand Down Expand Up @@ -172,13 +182,17 @@ function MetricCard({
label,
value,
detail,
accent = "text-cyan-300"
accent = "text-cyan-300",
actionLabel,
onAction
}: {
icon: ReactNode;
label: string;
value: string;
detail: string;
accent?: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<article className="group relative overflow-hidden rounded-lg border border-[color:var(--bandscope-border)] bg-[var(--bandscope-surface)] p-4 shadow-[0_18px_60px_rgba(0,0,0,0.24)] backdrop-blur-xl transition hover:-translate-y-0.5 hover:border-cyan-300/40">
Expand All @@ -189,6 +203,15 @@ function MetricCard({
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.22em] text-slate-400">{label}</p>
<p className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</p>
<p className="mt-1 text-sm text-slate-400">{detail}</p>
{actionLabel && onAction ? (
<Button
type="button"
className="relative mt-3 min-h-11 bg-gradient-to-r from-cyan-400 to-violet-500 font-black text-slate-950"
onClick={onAction}
>
{actionLabel}
</Button>
) : null}
</div>
</div>
</article>
Expand Down Expand Up @@ -264,6 +287,12 @@ export function App() {
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [activeView, setActiveView] = useState<RehearsalView>("workspace");
const [openedCockpitMetrics, setOpenedCockpitMetrics] = useState<{
songId: string;
tempo: boolean;
key: boolean;
transpose: boolean;
} | null>(null);
const activeJobIdRef = useRef<string | null>(null);
const youtubeInputRef = useRef<HTMLInputElement | null>(null);

Expand Down Expand Up @@ -518,6 +547,33 @@ export function App() {
};

const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
// Cockpit next actions scroll to surfaces that only exist inside the mounted
// Workspace, so the affordances are suppressed in the Score view instead of
// rendering visible buttons whose click would be a silent no-op.
const cockpitActionsAvailable = currentView === "workspace";
const readySongId = typeof jobResult?.id === "string" ? jobResult.id : "";
const tonightTempo = resolveTonightTempo(jobResult);
const tonightChord = resolveTonightStartingChord(jobResult);
const tonightTranspose = resolveTonightTransposePlan(jobResult);
const openedMetrics =
openedCockpitMetrics?.songId === readySongId
? openedCockpitMetrics
: { songId: readySongId, tempo: false, key: false, transpose: false };
Comment thread
seonghobae marked this conversation as resolved.

/** Mark a cockpit next action complete only after the workspace surface actually moved. */
const handleCockpitMetricAction = (
metric: "tempo" | "key" | "transpose",
surfaceIds: readonly string[]
) => {
if (!scrollToWorkspaceSurface(surfaceIds) || readySongId.length === 0) {
return;
}
Comment thread
seonghobae marked this conversation as resolved.
setOpenedCockpitMetrics({
...openedMetrics,
songId: readySongId,
[metric]: true
});
};

/** Resolve label, enablement, and active state for one sidebar item. */
const navButtonState = (item: (typeof NAV_ITEMS)[number]) => {
Expand Down Expand Up @@ -834,9 +890,87 @@ export function App() {
</section>

<header aria-label={t("analysisSummaryAriaLabel")} className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<MetricCard icon={<Clock3 className="size-5" aria-hidden="true" />} label={t("metricTempoLabel")} value={t("metricPendingValue")} detail={t("metricTempoPendingDetail")} accent="text-sky-300" />
<MetricCard icon={<KeyRound className="size-5" aria-hidden="true" />} label={t("metricKeyLabel")} value={t("metricPendingValue")} detail={t("metricKeyPendingDetail")} accent="text-cyan-300" />
<MetricCard icon={<Wand2 className="size-5" aria-hidden="true" />} label={t("metricTransposeLabel")} value={t("metricPendingValue")} detail={t("metricTransposePendingDetail")} accent="text-blue-300" />
<MetricCard
icon={<Clock3 className="size-5" aria-hidden="true" />}
label={t("metricTempoLabel")}
value={
tonightTempo
? formatMetricCopy(t("metricTempoReadyValue"), { bpm: String(tonightTempo.bpm) })
: t("metricPendingValue")
}
detail={
tonightTempo
? formatMetricCopy(
t(openedMetrics.tempo ? "metricTempoArmed" : "metricTempoReadyDetail"),
{ bpm: String(tonightTempo.bpm) }
)
: t("metricTempoPendingDetail")
}
actionLabel={
tonightTempo && cockpitActionsAvailable ? formatMetricCopy(t("metricTempoAction"), { bpm: String(tonightTempo.bpm) }) : undefined
}
onAction={
tonightTempo && cockpitActionsAvailable
? () => handleCockpitMetricAction("tempo", [WORKSPACE_SURFACE_TEMPO])
: undefined
}
accent="text-sky-300"
/>
<MetricCard
icon={<KeyRound className="size-5" aria-hidden="true" />}
label={t("metricKeyLabel")}
value={tonightChord ? tonightChord.chord : t("metricPendingValue")}
detail={
tonightChord
? formatMetricCopy(
t(openedMetrics.key ? "metricKeyArmed" : "metricKeyReadyDetail"),
{ chord: tonightChord.chord, role: tonightChord.roleName }
)
: t("metricKeyPendingDetail")
}
actionLabel={
tonightChord && cockpitActionsAvailable
? formatMetricCopy(t("metricKeyAction"), {
chord: tonightChord.chord,
role: tonightChord.roleName
})
: undefined
}
onAction={
tonightChord && cockpitActionsAvailable
? () => handleCockpitMetricAction("key", [WORKSPACE_SURFACE_HARMONY])
: undefined
}
accent="text-cyan-300"
/>
<MetricCard
icon={<Wand2 className="size-5" aria-hidden="true" />}
label={t("metricTransposeLabel")}
value={tonightTranspose ? tonightTranspose.roleName : t("metricPendingValue")}
detail={
tonightTranspose
? formatMetricCopy(
t(openedMetrics.transpose ? "metricTransposeArmed" : "metricTransposeReadyDetail"),
{ role: tonightTranspose.roleName, plan: tonightTranspose.plan }
)
: t("metricTransposePendingDetail")
}
actionLabel={
tonightTranspose && cockpitActionsAvailable
? formatMetricCopy(t("metricTransposeAction"), { role: tonightTranspose.roleName })
: undefined
}
onAction={
tonightTranspose && cockpitActionsAvailable
? () =>
handleCockpitMetricAction("transpose", [
WORKSPACE_SURFACE_TRANSPOSE,
WORKSPACE_SURFACE_HARMONY
])
: undefined
}
Comment thread
seonghobae marked this conversation as resolved.
accent="text-blue-300"
/>
Comment thread
seonghobae marked this conversation as resolved.
<ConfidenceMetric song={jobResult} t={t} />
<MetricCard icon={<Star className="size-5 fill-amber-300 text-amber-300" aria-hidden="true" />} label={t("metricPriorityLabel")} value={priorityLabel(jobResult, t)} detail={jobResult?.exportSummary?.headline ?? t("metricPriorityPendingDetail")} accent="text-amber-300" />
</header>
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.tempo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Workspace } from "./Workspace";

function songWithTempo(tempo: number) {
const song = createDemoRehearsalSong();
song.tempo = tempo;
return song;
}

describe("Workspace tempo readiness", () => {
it.each([90.5, 500])("does not present an uncountable %s BPM as a ready tempo", (tempo) => {
render(<Workspace song={songWithTempo(tempo)} />);

expect(screen.queryByText(`${tempo} BPM`, { exact: false })).toBeNull();
});

it("keeps the badge for a countable integer tempo", () => {
render(<Workspace song={songWithTempo(120)} />);

expect(screen.getByText("120 BPM", { exact: false })).toBeTruthy();
});
});
Loading
Loading