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
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 part on the section roadmap.
- 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 roadmap 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} for {role} on the roadmap so the player can locate that part, 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 part on the section roadmap, 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
69 changes: 61 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,41 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
focusSectionId?: string | null;
focusRoleId?: string | null;
focusRequestSequence?: number;
}

/** 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>());

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

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
Expand Down Expand Up @@ -104,11 +132,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 +173,15 @@ 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;
return (
<div
key={role.id}
className={`rounded-xl border-l-4 p-4 transition-all hover:translate-x-1 ${getPriorityColor(role.rehearsalPriority)}`}
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 +279,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
})}
</CardContent>
</Card>
))}
</div>
);
})}
</div>
</div>
);
Expand Down
36 changes: 36 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,40 @@ describe("Workspace", () => {
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});

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

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" }));

expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute("aria-current", "location");
expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).toHaveAttribute("aria-current", "true");
});

it("hides the roadmap find control when the named section identity is duplicated", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections.push({
...song.sections[0]!,
id: "verse-2",
label: "verse"
});

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

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

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

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

expect(screen.getByRole("button", { name: "로드맵에서 Bass Guitar verse 찾기" })).toBeTruthy();
});

it("names tonight's first playable range and the next instrument check", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand All @@ -151,6 +185,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 for Bass Guitar on the roadmap" })).toBeTruthy();
});

it("asks for an ear check when the selected part has no named span", () => {
Expand All @@ -167,6 +202,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 roadmap/ })).toBeNull();
});

it("limits the range callout to the selected role", () => {
Expand Down
66 changes: 64 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { fillRangeCopy, firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
Expand All @@ -17,6 +17,14 @@ interface WorkspaceProps {
onSongUpdate?: (song: RehearsalSong) => void;
}

/** Request identity for a user-initiated section-roadmap focus action. */
type RoadmapFocusRequest = {
rehearsalSourceIdentity: string;
sectionId: string;
roleId: string;
requestSequence: number;
};

/** Documented. */
function formatTimelineTime(totalSeconds: number): string {
const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0;
Expand Down Expand Up @@ -121,7 +129,13 @@ 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 [roadmapFocusRequest, setRoadmapFocusRequest] = useState<RoadmapFocusRequest | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const parsedSourceBootstrap = useMemo(
() => safeProjectBootstrapSummary(sourceBootstrap),
[sourceBootstrap]
);
const rehearsalSourceIdentity = parsedSourceBootstrap?.projectId ?? song.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Loaded songs inherit stale roadmap focus

When a loaded song ID equals the prior project ID, rehearsalSourceIdentity treats both sources as identical. The new song retains the old roadmap highlight.

Suggested change
const rehearsalSourceIdentity = parsedSourceBootstrap?.projectId ?? song.id;
const rehearsalSourceIdentity = parsedSourceBootstrap
? `project:${parsedSourceBootstrap.projectId}`
: `song:${song.id}`;
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
Expand Down Expand Up @@ -163,6 +177,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
const firstRangeBoard = firstRangeRoadmap(song, firstRange);
Comment on lines 177 to +180

@devin-ai-integration devin-ai-integration Bot Sep 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Focused roles remain visible

firstRangeSqueeze follows the active role filter. The resulting focused role remains rendered when Find runs.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const firstRangeFindCopy = firstRangeBoard
? fillRangeCopy(t("workspaceFirstRangeFindRoadmap"), {
sectionLabel: firstRangeBoard.sectionLabel,
roleName: firstRangeBoard.roleName
})
: null;
const focusedSectionId =
roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity
? roadmapFocusRequest.sectionId
: null;
const focusedRoleId =
roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity
? roadmapFocusRequest.roleId
: null;
const focusRequestSequence =
roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity
? roadmapFocusRequest.requestSequence
: 0;

/** Request the first-range roadmap cell on every activation, even when it is already highlighted. */
const handleFindFirstRangeRoadmap = () => {
if (!firstRangeBoard) {
return;
}
setRoadmapFocusRequest((previousFocusRequest) => ({
rehearsalSourceIdentity,
sectionId: firstRangeBoard.sectionId,
roleId: firstRangeBoard.roleId,
requestSequence:
previousFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity
? previousFocusRequest.requestSequence + 1
: 1
}));
};

/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -240,7 +289,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

/** Documented. */
const handleExportHandoff = () => {
const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap);
const json = generateMetadataHandoffJson(song, {
sourceBootstrap: parsedSourceBootstrap,
workspaceId: song.id,
Expand Down Expand Up @@ -308,6 +356,17 @@ 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>
{firstRangeBoard && firstRangeFindCopy ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 min-h-10 border-fuchsia-300/30 bg-fuchsia-300/10 font-semibold text-fuchsia-50 hover:bg-fuchsia-300/20 hover:text-white"
onClick={handleFindFirstRangeRoadmap}
>
{firstRangeFindCopy}
</Button>
) : null}
</section>

<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
Expand Down Expand Up @@ -506,6 +565,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
song={song}
activeRole={activeRole}
onSongUpdate={onSongUpdate}
focusSectionId={focusedSectionId}
focusRoleId={focusedRoleId}
focusRequestSequence={focusRequestSequence}
/>
</section>
</CardContent>
Expand Down
Loading