Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
54 changes: 53 additions & 1 deletion 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 = {
rehearsalSongId: 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,6 +129,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 [roadmapFocusRequest, setRoadmapFocusRequest] = useState<RoadmapFocusRequest | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);

// Extract all unique roles from the song's sections
Expand Down Expand Up @@ -163,6 +172,35 @@ 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?.rehearsalSongId === song.id ? roadmapFocusRequest.sectionId : null;
const focusedRoleId =
roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.roleId : null;
const focusRequestSequence =
roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.requestSequence : 0;
Comment thread
seonghobae marked this conversation as resolved.
Outdated

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

@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: Repeated activation retriggers scrolling

Every click increments requestSequence. The roadmap effect observes that value, so an already-highlighted target scrolls again.

Devin Review

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


/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
Expand Down Expand Up @@ -308,6 +346,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 +555,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