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
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.
- Customer-facing workspace copy must name the next rehearsal action (open tonight's first lock-in, start a part, see first notes) instead of leaving priority or empty cards inert.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, 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 @@ -85,6 +85,7 @@ Last updated: 2026-03-11
- playable ranges and density or overlap warnings
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- a workspace priorities card that opens tonight's first lock-in section on the Section Roadmap
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form

## Confidence, edits, and provenance
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

- The rehearsal priorities card now opens tonight's first lock-in section on the Section Roadmap, with bilingual next-action copy.
- 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 @@ -47,7 +47,7 @@ uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --

## Architecture

BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track.
BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. The workspace priorities card must name and open tonight's first lock-in on the Section Roadmap. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track.

Three layers, decoupled through shared contracts:

Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,10 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy();
});
expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy();
const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy();
expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy();
expect(screen.queryByText(/Mock-board/i)).toBeNull();
const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(timelineRegion.className).toContain("overflow-x-auto");
expect(timelineRegion.getAttribute("tabindex")).toBe("0");
expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull();
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,15 @@ describe("SectionRoadmap", () => {

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

it("exposes focus targets for tonight's first lock-in section", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

render(<SectionRoadmap song={song} activeRole={null} focusedSectionId="verse-1" />);

const card = document.getElementById("workspace-section-verse-1");
expect(card).toBeTruthy();
expect(card?.className).toContain("ring-amber-300/70");
});
});
15 changes: 11 additions & 4 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
focusedSectionId?: string | null;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
/** Render the rehearsal section roadmap and highlight the card identified by `focusedSectionId` when provided. */
export function SectionRoadmap({ song, activeRole, onSongUpdate, focusedSectionId = null }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
Expand Down Expand Up @@ -106,8 +107,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{song.sections.map((section) => (
<Card
key={section.id}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] ${
section.confidence.level === "low" ? "border-rose-300/30 bg-rose-950/30" : "border-white/10 bg-slate-950/80"
id={`workspace-section-${section.id}`}
tabIndex={-1}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
focusedSectionId === section.id
? "border-amber-300/50 bg-amber-950/40 ring-2 ring-amber-300/70"
: section.confidence.level === "low"
? "border-rose-300/30 bg-rose-950/30"
: "border-white/10 bg-slate-950/80"
}`}
>
<CardHeader className="border-b border-white/10 bg-white/[0.04] p-5 pb-4">
Expand Down
79 changes: 77 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
Expand Down Expand Up @@ -82,7 +82,7 @@ describe("Workspace", () => {

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

expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});

it("enables bass transcription from selected role metadata rather than role id text", () => {
Expand Down Expand Up @@ -269,5 +269,80 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByRole("button", { name: "구간 흐름에서 verse 열기, 0:10부터 0:30까지" })).toBeTruthy();
});

it("opens tonight's first lock-in section on the roadmap", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
const focus = vi.fn();
const originalGetElementById = document.getElementById.bind(document);
vi.spyOn(document, "getElementById").mockImplementation((id: string) => {
const node = originalGetElementById(id);
if (node && id === "workspace-section-verse-1") {
Object.defineProperty(node, "scrollIntoView", { configurable: true, value: scrollIntoView });
Object.defineProperty(node, "focus", { configurable: true, value: focus });
}
return node;
});

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

expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open verse on the section roadmap from 0:10 to 0:30" }));

expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30. Count in on that card.")).toBeTruthy();
expect(document.getElementById("workspace-section-verse-1")).toBeTruthy();
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledTimes(1);
});

it("falls back to the first mapped section when focus labels do not match", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.exportSummary = {
...song.exportSummary,
focusSections: ["missing-bridge"]
};
song.sections[0] = {
...song.sections[0]!,
id: "intro-1",
label: "intro",
timeRange: { start: 0, end: 8 }
};

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

const card = document.getElementById("workspace-section-intro-1");
const scrollIntoView = vi.fn();
expect(card).toBeTruthy();
Object.defineProperty(card!, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});

fireEvent.click(screen.getByRole("button", { name: "Open intro on the section roadmap from 0:00 to 0:08" }));

expect(screen.getByText("Tonight's first lock-in is intro · 0:00–0:08. Count in on that card.")).toBeTruthy();
expect(document.getElementById("workspace-section-intro-1")).toBeTruthy();
Comment thread
seonghobae marked this conversation as resolved.
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});

it("keeps the priorities action closed when no sections are mapped", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: []
};

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

const locked = screen.getByRole("button", { name: "No lock-in section yet" });
expect(locked.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(locked);
expect(screen.queryByText(/Count in on that card/i)).toBeNull();
});
});
99 changes: 96 additions & 3 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,39 @@ function nonBlankText(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}

/** Fill rehearsal copy with named placeholders. */
function fillCopy(template: string, values: Record<string, string>): string {
return Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{${key}}`, value),
template
);
}

/** Return tonight's first lock-in section from export focus, then the first mapped section. */
function firstFocusSection(song: RehearsalSong): RehearsalSong["sections"][number] | undefined {
const requested = song.exportSummary?.focusSections?.[0]?.trim();
if (requested) {
const match = song.sections.find(
(section) => section.label === requested || section.id === requested
);
if (match) {
return match;
}
}

return song.sections[0];
}

/** Scroll and focus the matching section card on the rehearsal roadmap. */
function focusWorkspaceSection(sectionId: string): void {
const node = document.getElementById(`workspace-section-${sectionId}`);
if (!(node instanceof HTMLElement)) {
return;
}
node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
node.focus();
}

/** Documented. */
function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null {
if (!value) {
Expand Down Expand Up @@ -120,6 +153,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);
const [openedFocusSectionId, setOpenedFocusSectionId] = useState<string | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);

// Extract all unique roles from the song's sections
Expand Down Expand Up @@ -212,6 +246,37 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
const focusSection = firstFocusSection(song);
const openedFocusSection =
song.sections.find((section) => section.id === openedFocusSectionId) ?? null;
const focusCopyValues = focusSection
? {
label: focusSection.label,
start: formatTimelineTime(focusSection.timeRange.start),
end: formatTimelineTime(focusSection.timeRange.end)
}
: null;
const focusActionLabel = focusCopyValues
? fillCopy(t("workspaceOpenFocusAction"), focusCopyValues)
: t("workspaceOpenFocusUnavailable");
const focusAriaLabel = focusCopyValues
? fillCopy(t("workspaceOpenFocusAria"), focusCopyValues)
: t("workspaceOpenFocusUnavailable");
const focusSummary = focusCopyValues
? fillCopy(t("workspaceOpenFocusSummary"), focusCopyValues)
: t("workspaceOpenFocusUnavailable");
const focusStatus = focusCopyValues
? fillCopy(t("workspaceOpenFocusArmed"), focusCopyValues)
: t("workspaceOpenFocusUnavailable");

/** Open tonight's first lock-in on the section roadmap without inventing playback. */
const openTonightFocus = (): void => {
if (!focusSection) {
return;
}
setOpenedFocusSectionId(focusSection.id);
focusWorkspaceSection(focusSection.id);
};

/** Documented. */
const handleExportCueSheet = () => {
Expand Down Expand Up @@ -325,9 +390,36 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

<section className="rounded-2xl border border-amber-300/20 bg-amber-300/[0.07] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-amber-200">{t("workspaceRehearsalPrioritiesLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">
Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}.
</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{focusSummary}</p>
{focusSection ? (
<Button
type="button"
title={focusAriaLabel}
aria-label={focusAriaLabel}
onClick={openTonightFocus}
variant="outline"
className="mt-3 min-h-11 border-amber-300/30 bg-amber-300/10 font-semibold text-amber-50 hover:bg-amber-300/20 hover:text-white"
>
{focusActionLabel}
</Button>
) : (
<Button
type="button"
aria-disabled={true}
aria-label={t("workspaceOpenFocusUnavailable")}
title={t("workspaceOpenFocusUnavailable")}
onClick={preventUnavailableAction}
variant="outline"
className="mt-3 min-h-11 cursor-not-allowed border-white/10 bg-white/5 font-semibold text-slate-500 opacity-70"
>
{t("workspaceOpenFocusUnavailable")}
</Button>
)}
{openedFocusSection ? (
<p className="mt-3 text-sm font-semibold text-amber-100" role="status" aria-live="polite">
{focusStatus}
</p>
) : null}
</section>
</div>

Expand Down Expand Up @@ -484,6 +576,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
song={song}
activeRole={activeRole}
onSongUpdate={onSongUpdate}
focusedSectionId={openedFocusSectionId}
/>
</section>
</CardContent>
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"workspaceTranspositionLabel": "Transpose / simplify",
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceOpenFocusAction": "Open {label} · {start}–{end}",
"workspaceOpenFocusAria": "Open {label} on the section roadmap from {start} to {end}",
"workspaceOpenFocusSummary": "Tonight's first lock-in is {label} · {start}–{end}.",
"workspaceOpenFocusArmed": "Tonight's first lock-in is {label} · {start}–{end}. Count in on that card.",
"workspaceOpenFocusUnavailable": "No lock-in section yet",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
"sectionRoadmapTitle": "Section Roadmap",
"sectionRoadmapScrollHint": "Scroll for more sections →",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"workspaceTranspositionLabel": "전조 / 단순화",
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceOpenFocusAction": "{label} 열기 · {start}–{end}",
"workspaceOpenFocusAria": "구간 흐름에서 {label} 열기, {start}부터 {end}까지",
"workspaceOpenFocusSummary": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다.",
"workspaceOpenFocusArmed": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다. 그 카드에서 카운트인하세요.",
"workspaceOpenFocusUnavailable": "아직 잠글 구간이 없습니다",
"workspaceRolesHarmonyLabel": "역할과 화성",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ if (typeof window !== "undefined" && !window.matchMedia) {
dispatchEvent: vi.fn(),
}));
}

// jsdom also omits the browser scrolling API. Keep the test environment at
// the same DOM capability boundary as supported desktop WebViews so focus
// interactions exercise application behavior instead of throwing in jsdom.
if (typeof HTMLElement !== "undefined" && !HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = vi.fn();
}
Loading
Loading