Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
923e0a2
feat(workspace): find tonight's first range on the timeline
seonghobae Sep 2, 2026
9e4c8fd
test(workspace): reproduce timeline focus regressions
seonghobae Sep 2, 2026
ce4fd45
fix(workspace): reject ambiguous timeline identities
seonghobae Sep 2, 2026
b95e906
fix(workspace): make timeline focus repeatable and scoped
seonghobae Sep 2, 2026
ce6c8fc
test(workspace): cover duplicate timeline identity UI
seonghobae Sep 2, 2026
f6b66a7
refactor(workspace): absorb roadmap target into first-range owner
seonghobae Sep 2, 2026
3ca205c
refactor(workspace): absorb roadmap focus target
seonghobae Sep 2, 2026
477fdc7
feat(workspace): consolidate timeline and roadmap first-range find
seonghobae Sep 2, 2026
5157f0a
feat(i18n): preserve roadmap find copy in first-range owner
seonghobae Sep 2, 2026
7509c60
feat(i18n): preserve Korean roadmap find copy
seonghobae Sep 2, 2026
5035e41
test(workspace): reproduce first-range identity and exact-target regr…
seonghobae Sep 2, 2026
efc54dc
fix(workspace): consolidate exact first-range navigation identity
seonghobae Sep 2, 2026
08673c2
test(workspace): preserve roadmap localization and empty-range evidence
seonghobae Sep 2, 2026
bc5ec05
test(workspace): reproduce saved-project stale navigation focus
seonghobae Sep 2, 2026
16c87e2
test(workspace): reproduce identity normalization and zero-span defects
seonghobae Sep 2, 2026
c66ec7c
test(workspace): reproduce stale same-source timeline focus
seonghobae Sep 2, 2026
adc3053
test(workspace): reproduce stale same-source roadmap focus
seonghobae Sep 2, 2026
3865438
fix(workspace): fail closed on noncanonical navigation identity
seonghobae Sep 2, 2026
1ead86f
fix(workspace): revalidate focus against current rehearsal identity
seonghobae Sep 2, 2026
70fe0d6
fix(workspace): remount navigation state on saved-project replacement
seonghobae Sep 2, 2026
a25254b
test(workspace): preserve range truth when navigation identity is unsafe
seonghobae Sep 2, 2026
335a190
test(workspace): keep range callout when Find must fail closed
seonghobae Sep 2, 2026
740ad3b
fix(workspace): decouple playable-range truth from navigation admission
seonghobae Sep 2, 2026
2279faa
test(workspace): reproduce project/song identity namespace collision
seonghobae Sep 2, 2026
47bcf7c
fix(workspace): namespace rehearsal source identity
seonghobae Sep 2, 2026
3c73f3a
test(workspace): align roadmap focus semantics
seonghobae Sep 2, 2026
70c0050
test(workspace): assert role aria-current contract
seonghobae Sep 2, 2026
08e727d
docs(workspace): consolidate range navigation surfaces
seonghobae Sep 2, 2026
d657b8d
docs(workspace): preserve roadmap navigation architecture
seonghobae Sep 2, 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. The ready workspace names tonight's first playable range and offers a next action that finds that section on the timeline.
- 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, offering a timeline find control, and naming the next instrument check
- 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,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 playable range on the ready rehearsal map, offer Find {section} at {clock} on the timeline and Find {section} for {role} on the section roadmap, 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, offers a control that finds that section on the timeline, and tells the player to check that span on their instrument. `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
52 changes: 52 additions & 0 deletions apps/desktop/src/App.projectNavigation.regression.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

const mockLoadProject = vi.fn();

vi.mock("./lib/analysis", async (importActual) => {
const actual = await importActual<typeof import("./lib/analysis")>();
return {
...actual,
loadProject: () => mockLoadProject(),
subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined)
};
});

describe("loaded-project rehearsal navigation identity", () => {
beforeEach(() => {
mockLoadProject.mockReset();
});

it("clears timeline and roadmap focus when a second saved analysis reuses analyzed-song", async () => {
const firstProject = { ...createDemoRehearsalSong(), id: "analyzed-song", title: "First saved analysis" };
const secondProject = { ...createDemoRehearsalSong(), id: "analyzed-song", title: "Second saved analysis" };
mockLoadProject.mockResolvedValueOnce(firstProject).mockResolvedValueOnce(secondProject);

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("heading", { name: "First saved analysis" })).toBeInTheDocument());

fireEvent.click(screen.getByRole("button", { name: /Find verse at 0:10–0:30 on the timeline/i }));
fireEvent.click(screen.getByRole("button", { name: /Find verse for Bass Guitar on the roadmap/i }));
expect(screen.getByTestId("song-structure-section-verse-1")).toHaveAttribute("aria-current", "location");
expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).toHaveAttribute("aria-current", "true");

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("heading", { name: "Second saved analysis" })).toBeInTheDocument());

expect(screen.getByTestId("song-structure-section-verse-1")).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("section-roadmap-section-verse-1")).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).not.toHaveAttribute("aria-current");
});
});
14 changes: 13 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export function App() {
const [jobStatus, setJobStatus] = useState<AnalysisJobStatus | null>(null);
const [jobResult, setJobResult] = useState<RehearsalSong | null>(null);
const [jobResultBootstrap, setJobResultBootstrap] = useState<ProjectBootstrapSummary | null>(null);
const [loadedProjectRevision, setLoadedProjectRevision] = useState(0);
const [jobError, setJobError] = useState<string | null>(null);
const [renderedProgressPercent, setRenderedProgressPercent] = useState<number | undefined>(undefined);
const [isStarting, setIsStarting] = useState(false);
Expand Down Expand Up @@ -474,6 +475,7 @@ export function App() {
const handleLoadProject = async () => {
try {
const song = await loadProject();
setLoadedProjectRevision((revision) => revision + 1);
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
Expand Down Expand Up @@ -512,7 +514,17 @@ export function App() {
return <LoadingState />;
}
if (jobResult) {
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
const workspaceInstanceKey = jobResultBootstrap?.projectId
? `analysis-project-${jobResultBootstrap.projectId}`
: `loaded-project-${loadedProjectRevision}`;
return (
<Workspace
key={workspaceInstanceKey}
song={jobResult}
sourceBootstrap={jobResultBootstrap}
onSongUpdate={handleSongUpdate}
/>
);
}
return <EmptyState />;
};
Expand Down
85 changes: 77 additions & 8 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { useId, useMemo } from "react";
import { useEffect, useId, useMemo, useRef } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
Expand All @@ -12,13 +12,49 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
focusSectionId?: string | null;
focusRoleId?: string | null;
focusRequestSequence?: number;
}

/** Encode the exact section/role pair without delimiter-collision ambiguity. */
function roadmapRoleFocusKey(sectionId: string, roleId: string): string {
return JSON.stringify([sectionId, roleId]);
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
export function SectionRoadmap({
song,
activeRole,
onSongUpdate,
focusSectionId = null,
focusRoleId = null,
focusRequestSequence = 0
}: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
const sectionCardRefs = useRef(new Map<string, HTMLDivElement>());
const roleCardRefs = useRef(new Map<string, HTMLDivElement>());

useEffect(() => {
if (!focusSectionId || focusRequestSequence < 1) {
return;
}
const focusTarget = focusRoleId
? roleCardRefs.current.get(roadmapRoleFocusKey(focusSectionId, focusRoleId))
: sectionCardRefs.current.get(focusSectionId);
if (focusTarget && typeof focusTarget.scrollIntoView === "function") {
const reducedMotionPreferred =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
focusTarget.scrollIntoView({
behavior: reducedMotionPreferred ? "auto" : "smooth",
inline: "center",
block: "nearest"
});
}
}, [focusRequestSequence, focusRoleId, focusSectionId]);

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
Expand Down Expand Up @@ -104,11 +140,29 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
{song.sections.map((section) => (
<Card
{song.sections.map((section) => {
const sectionFocused = focusSectionId === section.id;
return (
<div
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"
ref={(sectionNode) => {
if (sectionNode) {
sectionCardRefs.current.set(section.id, sectionNode);
} else {
sectionCardRefs.current.delete(section.id);
}
}}
data-testid={`section-roadmap-section-${section.id}`}
aria-current={sectionFocused ? "location" : undefined}
className="w-80 flex-none shrink-0 snap-start"
>
<Card
className={`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)] ${
sectionFocused
? "border-fuchsia-300/40 bg-fuchsia-950/40 ring-2 ring-inset ring-fuchsia-300"
: 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 All @@ -127,10 +181,23 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
.filter(role => !activeRole || role.id === activeRole)
.map(role => {
const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote);
const roleFocused = sectionFocused && focusRoleId === role.id;
const roleFocusKey = roadmapRoleFocusKey(section.id, role.id);
return (
<div
key={role.id}
className={`rounded-xl border-l-4 p-4 transition-all hover:translate-x-1 ${getPriorityColor(role.rehearsalPriority)}`}
ref={(roleNode) => {
if (roleNode) {
roleCardRefs.current.set(roleFocusKey, roleNode);
} else {
roleCardRefs.current.delete(roleFocusKey);
}
}}
data-testid={`section-roadmap-role-${section.id}-${role.id}`}
aria-current={roleFocused ? "true" : undefined}
className={`rounded-xl border-l-4 p-4 transition-all hover:translate-x-1 ${getPriorityColor(role.rehearsalPriority)}${
roleFocused ? " ring-2 ring-fuchsia-300" : ""
}`}
>
<div className="mb-3 flex items-start justify-between">
<div className="flex flex-col gap-1">
Expand Down Expand Up @@ -228,7 +295,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
})}
</CardContent>
</Card>
))}
</div>
);
})}
</div>
</div>
);
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,39 @@ describe("Workspace", () => {
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});

it("finds tonight's first range on the structure timeline", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("button", { name: "Find verse at 0:10–0:30 on the timeline" }));

expect(screen.getByTestId("song-structure-section-verse-1")).toHaveAttribute("aria-current", "location");
expect(screen.getByTestId("song-structure-grid").querySelector("[aria-current='location']")).toHaveTextContent(/verse · 0:10–0:30/i);
});

it("hides the timeline find control when the named section clock is unusable", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0].timeRange = {
start: Number.NaN,
end: Number.POSITIVE_INFINITY
};

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

expect(screen.queryByRole("button", { name: /Find .+ on the timeline/ })).toBeNull();
});

it("localizes the first-range timeline find control", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();

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

expect(screen.getByRole("button", { name: "타임라인에서 0:10–0:30 verse 찾기" })).toBeTruthy();
});

it("names tonight's first playable range and the next instrument check", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand All @@ -151,6 +184,7 @@ describe("Workspace", () => {
expect(callout).toHaveTextContent(
"Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."
);
expect(screen.getByRole("button", { name: "Find verse at 0:10–0:30 on the timeline" })).toBeTruthy();
});

it("asks for an ear check when the selected part has no named span", () => {
Expand All @@ -167,6 +201,7 @@ describe("Workspace", () => {
expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent(
"Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section."
);
expect(screen.queryByRole("button", { name: /Find .+ on the timeline/ })).toBeNull();
});

it("limits the range callout to the selected role", () => {
Expand Down
Loading
Loading