Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4172ae1
feat(workspace): name tonight's first groove change on the map
seonghobae Sep 1, 2026
159cecb
test(groove): reject repeated-label roadmap ambiguity
seonghobae Sep 1, 2026
9590836
test(groove): pin repeated-label destination card
seonghobae Sep 1, 2026
2b686e3
fix(groove): target roadmap actions by section identity
seonghobae Sep 1, 2026
4c9252b
fix(workspace): validate groove target identities before derivation
seonghobae Sep 1, 2026
e5dd8fb
fix(workspace): target groove roadmap card by section id
seonghobae Sep 1, 2026
184d231
test(workspace): cover ambiguous groove section identities
seonghobae Sep 1, 2026
d40bd45
refactor(workspace): preserve sibling change analyzers
seonghobae Sep 1, 2026
2c80396
fix(workspace): preserve measured duration authority
seonghobae Sep 1, 2026
741cbb4
feat(project): retain legacy timing provenance
seonghobae Sep 1, 2026
d716b9f
test(workspace): preserve duration provenance regressions
seonghobae Sep 1, 2026
583734e
fix(project): keep migration timing provenance through load
seonghobae Sep 1, 2026
7f7ecc1
test(project): preserve timing provenance at load boundary
seonghobae Sep 1, 2026
443fb3d
refactor(workspace): consolidate first-change guidance
seonghobae Sep 1, 2026
b722e8f
feat(i18n): unify rehearsal change guidance
seonghobae Sep 1, 2026
995f8fd
feat(i18n): unify Korean rehearsal change guidance
seonghobae Sep 1, 2026
dd48e75
refactor(workspace): unify roadmap next actions
seonghobae Sep 1, 2026
b750ad3
test(workspace): preserve consolidated first-change behavior
seonghobae Sep 1, 2026
3ae8807
test(workspace): fail closed on unknown groove adjacency
seonghobae Sep 1, 2026
8be1d95
fix(workspace): preserve groove adjacency authority
seonghobae Sep 1, 2026
4eabf55
docs(workspace): preserve consolidated first-change contract
seonghobae Sep 2, 2026
fdaaf25
test(workspace): reproduce synthetic timing persistence leak
seonghobae Sep 2, 2026
5dad252
fix(workspace): preserve timing provenance across analysis adapters
seonghobae Sep 2, 2026
e1b3c4f
fix(workspace): fail closed on synthetic timing persistence
seonghobae Sep 2, 2026
0c96d3c
test(workspace): reproduce sparse-form guidance claims
seonghobae Sep 2, 2026
39ac80a
fix(workspace): remove nonexistent-section rehearsal claims
seonghobae Sep 2, 2026
f973cb7
fix(workspace): keep missing guidance truthful in Korean
seonghobae Sep 2, 2026
21f38bf
test(workspace): align first-change missing evidence contract
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 (including tonight's first feel and section-length changes with their next count-ins), stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence (including tonight's first confidence change and the next ear check), and rehearsal priorities.
- 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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ Last updated: 2026-03-11
- Core rehearsal artifacts should include:
- 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
- groove and timing cues relevant to locking the band together, with the ready workspace naming tonight's first feel change and first section-length change plus their next count-ins
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- role-specific rehearsal priorities and confidence flags, with the ready workspace naming tonight's first confidence change and the next ear check
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form

## Confidence, edits, and provenance
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### 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 groove change on the ready rehearsal map and tell the player to count the new feel in before that section.
- Name tonight's first section-length change on the ready rehearsal map and tell the player to count the new length in before that section.
- Name tonight's first confidence change on the ready rehearsal map and tell the player to confirm that section by ear before counting in.
- 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 and the next instrument check, plus tonight's first groove and section-length changes with their next count-ins and tonight's first confidence change with the next ear 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
41 changes: 41 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ describe("SectionRoadmap", () => {
expect(screen.getAllByText("음역").length).toBeGreaterThan(0);
expect(screen.getByText("C#2 — E3")).toBeTruthy();
expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0);
expect(screen.getByText("verse 들어가기 전에 이 그루브를 세어 보세요.")).toBeTruthy();
});

it("names the next count-in on the section where the feel changes", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
verse,
{
...verse,
id: "chorus-1",
label: "chorus",
groove: "Half-time snare with open hats",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
}
];

render(<SectionRoadmap song={song} activeRole={null} />);

expect(screen.queryByTestId("groove-next-action-verse-1")).toBeNull();
expect(screen.getByTestId("groove-next-action-chorus-1")).toHaveTextContent(
"Count this new groove in before chorus."
);
});

it("marks only the destination card when consecutive sections share a label", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
{ ...verse, id: "verse-a", label: "verse", groove: "Straight eighths" },
{ ...verse, id: "verse-b", label: "verse", groove: "Half-time" }
];

render(<SectionRoadmap song={song} activeRole={null} />);

expect(screen.queryByTestId("groove-next-action-verse-a")).toBeNull();
expect(screen.getByTestId("groove-next-action-verse-b")).toHaveTextContent(
"Count this new groove in before verse."
);
});

it("omits the range row when both notes are unnamed", () => {
Expand Down
48 changes: 46 additions & 2 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { useId, useMemo } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
import { fillGrooveCopy, firstGrooveChange, isGrooveChangeTarget } from "./firstGrooveChange";
import { fillDurationCopy, firstDurationChange, isDurationChangeTarget } from "./firstDurationChange";
import { confidenceWordKey, fillConfidenceCopy, firstConfidenceChange, isConfidenceChangeTarget } from "./firstConfidenceChange";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
Expand All @@ -19,6 +22,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
const grooveChange = useMemo(() => firstGrooveChange(song), [song]);
const durationChange = useMemo(() => firstDurationChange(song), [song]);
const confidenceChange = useMemo(() => firstConfidenceChange(song), [song]);

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
Expand Down Expand Up @@ -104,7 +110,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
{song.sections.map((section) => (
{song.sections.map((section) => {
const hasGrooveAction = Boolean(grooveChange && isGrooveChangeTarget(grooveChange, section.id));
const hasDurationAction = Boolean(durationChange && isDurationChangeTarget(durationChange, section.id));
const hasConfidenceAction = Boolean(confidenceChange && isConfidenceChangeTarget(confidenceChange, section.id));
const hasNextAction = hasGrooveAction || hasDurationAction || hasConfidenceAction;

return (
<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)] ${
Expand All @@ -120,6 +132,37 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
<span className="mr-2 text-[0.65rem] font-bold uppercase tracking-wider text-slate-400">{t("sectionGrooveLabel")}</span>
{section.groove}
</div>
{hasNextAction ? (
<div className="mt-2 space-y-1 border-t border-white/10 pt-2" aria-label={t("workspaceFirstChangesTitle")}>
{grooveChange && hasGrooveAction ? (
<p className="text-xs font-medium text-teal-200" data-testid={`groove-next-action-${section.id}`}>
{fillGrooveCopy(
t(grooveChange.kind === "change" ? "sectionGrooveNextActionChange" : "sectionGrooveNextActionHold"),
{ sectionLabel: section.label }
)}
</p>
) : null}
{durationChange && hasDurationAction ? (
<p className="text-xs font-medium text-sky-200" data-testid={`duration-next-action-${section.id}`}>
{fillDurationCopy(
t(durationChange.kind === "change" ? "sectionDurationNextActionChange" : "sectionDurationNextActionHold"),
{ sectionLabel: section.label }
)}
</p>
) : null}
{confidenceChange && hasConfidenceAction ? (
<p className="text-xs font-medium text-amber-200" data-testid={`confidence-next-action-${section.id}`}>
{fillConfidenceCopy(
t(confidenceChange.kind === "change" ? "sectionConfidenceNextActionChange" : "sectionConfidenceNextActionHold"),
{
sectionLabel: section.label,
level: t(confidenceWordKey(confidenceChange.toLevel))
}
)}
</p>
) : null}
</div>
) : null}
</CardHeader>

<CardContent className="p-4 space-y-4">
Expand Down Expand Up @@ -228,7 +271,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
})}
</CardContent>
</Card>
))}
);
})}
</div>
</div>
);
Expand Down
60 changes: 59 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ describe("Workspace", () => {
expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60);
});

it("keeps the song-structure grid valid when a project has no sections", () => {
it("keeps the song-structure grid and missing guidance truthful when a project has no sections", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];

Expand All @@ -70,6 +71,15 @@ describe("Workspace", () => {

expect(grid.style.gridTemplateColumns).not.toContain("repeat(0");
expect(grid.style.gridTemplateColumns).toContain("repeat(1");
expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
"Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal."
);
expect(screen.getByTestId("first-duration-change")).toHaveTextContent(
"Section timing isn't confirmed yet. Analyze section timing before using a count-in."
);
expect(screen.getByTestId("first-confidence-change")).toHaveTextContent(
"Section confidence isn't confirmed yet. Analyze section confidence before rehearsal."
);
});

it("falls back to safe timeline text for malformed section times", () => {
Expand Down Expand Up @@ -153,6 +163,41 @@ describe("Workspace", () => {
);
});

it("names tonight's held feel and the next count-in when the form does not change groove", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

const callout = screen.getByTestId("first-groove-change");
expect(callout).toHaveTextContent("Tonight's first feel change");
expect(callout).toHaveTextContent(
"Tonight's feel stays Straight eighths with a late snare feel through the form. Count that groove in before the verse."
);
});

it("names tonight's first feel change and the next count-in", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
song.sections = [
verse,
{
...verse,
id: "chorus-1",
label: "chorus",
groove: "Half-time snare with open hats",
roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` }))
}
];

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

expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
"The feel changes at chorus: Half-time snare with open hats, after verse's Straight eighths with a late snare feel. Count the new groove in before the chorus."
);
});

it("asks for an ear check when the selected part has no named span", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down Expand Up @@ -325,5 +370,18 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByText("오늘 먼저 바뀌는 그루브")).toBeTruthy();
});

it("asks for an ear check when no named groove exists", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0] = { ...song.sections[0]!, label: " ", groove: "none" };

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

expect(screen.getByTestId("first-groove-change")).toHaveTextContent(
"Groove change isn't confirmed yet. Analyze section groove evidence before rehearsal."
);
});
});
Loading
Loading