Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
18acb45
feat(workspace): name tonight's first breakdown plan on the map
seonghobae Aug 27, 2026
d272759
test(workspace): reproduce custom breakdown guidance overwrite
seonghobae Aug 27, 2026
0103b25
fix(workspace): preserve custom breakdown guidance after open
seonghobae Aug 27, 2026
12d4b05
test(core): reproduce breakdown plan contract rejection
seonghobae Aug 27, 2026
19193b1
test(analysis): reproduce ambiguous other breakdown ownership
seonghobae Aug 27, 2026
d89a9c1
fix(analysis): keep ambiguous other stem source-owned
seonghobae Aug 27, 2026
76c455e
fix(core): accept breakdown plan role contract
seonghobae Aug 27, 2026
24ba78c
test(workspace): reproduce analyzed-song state leakage
seonghobae Aug 27, 2026
0759f16
fix(workspace): reset breakdown state per loaded song instance
seonghobae Aug 27, 2026
b9fae3f
test(analysis): exercise unnamed named-partner breakdown branch
seonghobae Aug 27, 2026
d374d68
test(workspace): mark Korean breakdown fixture model-owned
seonghobae Aug 27, 2026
b6a63b9
test(workspace): preserve breakdown state within a loaded workspace
seonghobae Aug 27, 2026
5582809
fix(workspace): scope breakdown state to loaded workspace
seonghobae Aug 27, 2026
0fcdc51
test(workspace): reproduce breakdown reset after local edit
seonghobae Aug 27, 2026
4a98852
fix(workspace): preserve local edit instance authority
seonghobae Aug 27, 2026
7f6a375
fix(analysis): type optional breakdown partner name
seonghobae Aug 27, 2026
cd0d5b0
test(workspace): exercise successful breakdown navigation
seonghobae Aug 27, 2026
c35f6b9
test(review): reproduce breakdown gap suppression
seonghobae Aug 27, 2026
01a3fe7
fix(review): align breakdown resolver with engine topology
seonghobae Aug 27, 2026
21bc4d5
fix(review): restore contiguous breakdown authority
seonghobae Aug 27, 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 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.
- Name tonight's first breakdown plan with the owning part when an active role is corroborated, the owned `breakdownPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, confirmed overrides, harmonic explanations, or confidence notes.
- 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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Last updated: 2026-03-11
- 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
- tonight's first breakdown plan on the mounted map when section-level stem activity shows a corroborated density drop (previous graph ≥3 distinct sources, current graph 1–2 staying sources, no new entrance), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround.
- 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
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

- Name tonight's first breakdown plan in the mounted rehearsal workspace so the part that stays in after a density drop can hold the sparse texture on the map; real analyzed songs now receive this guidance only when section-level stem activity shows the previous graph with at least three distinct sources and the current graph holding one or two of those same sources, while heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority.
- 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.
- 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 mounted workspace names tonight's first breakdown plan and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. `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
4 changes: 4 additions & 0 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ pub struct RehearsalRolePayload {
setup_note: String,
manual_overrides: Vec<ManualOverridePayload>,
overlap_warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
breakdown_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
breakdown_plan_source: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop/core/tests/breakdown_plan_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use bandscope_desktop_core::project_payload_from_content;
use serde_json::{json, Value};

fn song_with_breakdown_plan() -> Value {
json!({
"id": "analyzed-song",
"title": "Late Night Set",
"sections": [
{
"id": "chorus-1",
"label": "chorus",
"groove": "Sparse half-time chorus",
"timeRange": { "start": 30, "end": 46 },
"confidence": {
"level": "high",
"source": "model",
"notes": "Stem activity corroborates the breakdown."
},
"roles": [
{
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": "instrument",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi pedal anchor",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Hold into the sparse chorus."
},
"range": {
"lowestNote": "C#2",
"highestNote": "E3"
},
"confidence": {
"level": "high",
"source": "model",
"notes": "Bass remains active after the density drop."
},
"rehearsalPriority": "high",
"simplification": "Stay on roots.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": [],
"breakdownPlan": "Hold this breakdown with Lead Vocal; keep it sparse until the drop.",
"breakdownPlanSource": "model"
}
],
"partGraph": [
{
"role_id": "bass-guitar",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Lock the sparse chorus texture.",
"focusSections": ["chorus-1"]
}
})
}

#[test]
fn project_contract_round_trips_breakdown_plan_provenance() {
let payload = song_with_breakdown_plan();
let content = serde_json::to_string(&payload).expect("fixture should serialize");

let parsed = project_payload_from_content(&content)
.expect("native project contract must accept shared breakdown-plan fields");
let serialized = serde_json::to_value(parsed).expect("native project contract should serialize");

assert_eq!(
serialized["sections"][0]["roles"][0]["breakdownPlan"],
payload["sections"][0]["roles"][0]["breakdownPlan"]
);
assert_eq!(
serialized["sections"][0]["roles"][0]["breakdownPlanSource"],
json!("model")
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout";

const mountedTargets = new Set<HTMLElement>();

function songWithCustomBreakdownPlan() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const chorus = structuredClone(verse);
chorus.id = "chorus-custom-breakdown";
chorus.label = "chorus";
chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 };
const bass = chorus.roles.find((role) => role.id === "bass-guitar")!;
bass.breakdownPlan = "Mute for four bars.";
bass.breakdownPlanSource = "user";
chorus.partGraph = chorus.partGraph.map((node) => ({
...node,
is_active: node.role_id === "bass-guitar"
}));
song.sections = [verse, chorus];
return song;
}

function appendSongStructureTarget() {
const timeline = document.createElement("div");
const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const target = document.createElement("div");
target.dataset.sectionIndex = "1";
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
timeline.appendChild(grid);
document.body.appendChild(timeline);
mountedTargets.add(timeline);
}

describe("FirstBreakdownPlanCallout custom guidance", () => {
afterEach(() => {
for (const target of mountedTargets) {
target.remove();
}
mountedTargets.clear();
vi.unstubAllGlobals();
});

it("does not replace user-owned guidance with model-specific sparse instructions after Open", () => {
appendSongStructureTarget();
render(<FirstBreakdownPlanCallout song={songWithCustomBreakdownPlan()} />);

expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy();
expect(screen.getByText("Mute for four bars.")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" }));

expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy();
expect(screen.getByText("Mute for four bars.")).toBeTruthy();
expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { expect, it } from "vitest";
import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout";

it("gives co-mounted breakdown-plan callouts distinct DOM identities", () => {
render(
<>
<FirstBreakdownPlanCallout song={createDemoRehearsalSong()} />
<FirstBreakdownPlanCallout song={createDemoRehearsalSong()} />
</>
);

const callouts = screen.getAllByRole("complementary", {
name: "Tonight's first breakdown plan"
});
const ids = callouts.map((callout) => callout.id);

expect(ids.every((id) => id.length > 0)).toBe(true);
expect(new Set(ids).size).toBe(callouts.length);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout";

const mountedTargets = new Set<HTMLElement>();

function analyzedSongWithBreakdownPlan() {
const song = createDemoRehearsalSong();
song.id = "analyzed-song";
const verse = song.sections[0]!;
const chorus = structuredClone(verse);
chorus.id = "chorus-loaded-instance";
chorus.label = "chorus";
chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 };
const bass = chorus.roles.find((role) => role.id === "bass-guitar")!;
bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop.";
bass.breakdownPlanSource = "model";
chorus.partGraph = chorus.partGraph.map((node) => ({
...node,
is_active: node.role_id === "bass-guitar"
}));
song.sections = [verse, chorus];
return song;
}

function appendSongStructureTarget() {
const timeline = document.createElement("div");
const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const target = document.createElement("div");
target.dataset.sectionIndex = "1";
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
timeline.appendChild(grid);
document.body.appendChild(timeline);
mountedTargets.add(timeline);
}

describe("FirstBreakdownPlanCallout loaded song identity", () => {
afterEach(() => {
for (const target of mountedTargets) {
target.remove();
}
mountedTargets.clear();
vi.unstubAllGlobals();
});

it("resets opened state when a distinct workspace instance reuses the engine id", () => {
const firstSong = analyzedSongWithBreakdownPlan();
const nextSong = analyzedSongWithBreakdownPlan();
appendSongStructureTarget();
const { rerender } = render(
<FirstBreakdownPlanCallout song={firstSong} workspaceInstanceKey="project-a" />
);

fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" }));
expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy();

rerender(<FirstBreakdownPlanCallout song={nextSong} workspaceInstanceKey="project-b" />);

expect(screen.getByText("Bass Guitar holds the chorus breakdown at 0:30.")).toBeTruthy();
expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeNull();
});

it("preserves opened state across immutable edits inside one workspace instance", () => {
const firstSong = analyzedSongWithBreakdownPlan();
const updatedSong = structuredClone(firstSong);
updatedSong.sections[1]!.roles[0]!.practiceProgress = 25;
appendSongStructureTarget();
const { rerender } = render(
<FirstBreakdownPlanCallout song={firstSong} workspaceInstanceKey="project-a" />
);

fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" }));
expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy();

rerender(<FirstBreakdownPlanCallout song={updatedSong} workspaceInstanceKey="project-a" />);

expect(screen.getByText(/Keep Bass Guitar sparse at 0:30 until the drop\./)).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout";

function songWithBreakdownPlan() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const chorus = structuredClone(verse);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 };
const bass = chorus.roles.find((role) => role.id === "bass-guitar")!;
bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop.";
chorus.partGraph = chorus.partGraph.map((node) => ({
...node,
is_active: node.role_id === "bass-guitar"
}));
song.sections = [verse, chorus];
return song;
}

describe("FirstBreakdownPlanCallout resolver reuse", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("does not rescan role metadata when a parent rerenders the same song object", () => {
const song = songWithBreakdownPlan();
const role = song.sections[1]!.roles.find((candidate) => candidate.id === "bass-guitar")!;
const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor");

const { rerender } = render(<FirstBreakdownPlanCallout song={song} />);
const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;
expect(firstScanCount).toBeGreaterThan(0);

rerender(<FirstBreakdownPlanCallout song={song} />);
const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;

expect(secondScanCount).toBe(firstScanCount);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { FirstBreakdownPlanCallout } from "./FirstBreakdownPlanCallout";

function songWithBreakdownPlan() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const chorus = structuredClone(verse);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 };
const bass = chorus.roles.find((role) => role.id === "bass-guitar")!;
bass.breakdownPlan = "Hold this breakdown; keep it sparse until the drop.";
chorus.partGraph = chorus.partGraph.map((node) => ({
...node,
is_active: node.role_id === "bass-guitar"
}));
song.sections = [verse, chorus];
return song;
}

describe("FirstBreakdownPlanCallout navigation failure", () => {
it("tells the user when the named breakdown cannot be opened on the rendered map", () => {
render(<FirstBreakdownPlanCallout song={songWithBreakdownPlan()} />);

fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar breakdown at 0:30" }));

expect(
screen.getByText(
"Could not open this breakdown on the song map. Use the map below to find the section."
)
).toBeTruthy();
expect(screen.queryByText(/Keep Bass Guitar sparse at 0:30 until the drop./)).toBeNull();
});
});
Loading
Loading