Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
224b268
fix(workspace): enforce breakdown plan provenance
seonghobae Aug 28, 2026
b3a7099
fix(contract): align breakdown provenance validation
seonghobae Aug 28, 2026
d53d635
fix(contract): align breakdown plan validation
seonghobae Aug 29, 2026
f8c051a
fix(workspace): preserve user breakdown guidance
seonghobae Aug 29, 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
75 changes: 73 additions & 2 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ pub struct ManualOverridePayload {
source: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
enum BreakdownPlanSource {
Model,
User,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RehearsalRolePayload {
Expand All @@ -191,6 +198,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<BreakdownPlanSource>,
}

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -527,9 +538,67 @@ pub fn is_youtube_video_id(value: &str) -> bool {
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
}

fn is_plan_whitespace(value: char) -> bool {
matches!(
value,
'\u{0009}'..='\u{000D}'
| '\u{0020}'
| '\u{0085}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2000}'..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}

/// Mirrors shared-types plan validation without normalizing persisted text.
fn is_valid_breakdown_plan(value: &str) -> bool {
let mut has_non_whitespace = false;
for character in value.chars() {
if matches!(
character,
'\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}'
) {
return false;
}
if !is_plan_whitespace(character) {
has_non_whitespace = true;
}
}
has_non_whitespace
}

pub fn validate_breakdown_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.breakdown_plan
.as_deref()
.is_some_and(|breakdown_plan| !is_valid_breakdown_plan(breakdown_plan))
{
return Err("Invalid project file format".to_string());
}
if role.breakdown_plan_source.is_some() && role.breakdown_plan.is_none() {
return Err("Invalid breakdown plan provenance".to_string());
}
if role.breakdown_plan.is_some() && role.breakdown_plan_source.is_none() {
return Err("Invalid breakdown plan provenance".to_string());
}
}
}
Ok(payload)
}

pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayload, String> {
if let Ok(parsed) = serde_json::from_str::<RehearsalSongPayload>(content) {
return Ok(parsed);
return validate_breakdown_plan_provenance(parsed);
}

let payload = serde_json::from_str::<Value>(content)
Expand All @@ -547,7 +616,9 @@ pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayloa
}
}

serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string())
let parsed =
serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string())?;
validate_breakdown_plan_provenance(parsed)
}

#[derive(Clone, Debug, Serialize)]
Expand Down
161 changes: 161 additions & 0 deletions apps/desktop/core/tests/breakdown_plan_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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")
);
}

#[test]
fn project_contract_rejects_invalid_breakdown_plan_provenance() {
let mut payload = song_with_breakdown_plan();
payload["sections"][0]["roles"][0]["breakdownPlanSource"] = json!("inferred");
let content = serde_json::to_string(&payload).expect("payload should serialize");

assert!(project_payload_from_content(&content).is_err());

let mut missing_copy = song_with_breakdown_plan();
missing_copy["sections"][0]["roles"][0]
.as_object_mut()
.expect("role should be an object")
.remove("breakdownPlan");
let content = serde_json::to_string(&missing_copy).expect("payload should serialize");

assert!(project_payload_from_content(&content).is_err());
}

#[test]
fn project_contract_rejects_breakdown_plan_without_source() {
let mut payload = song_with_breakdown_plan();
payload["sections"][0]["roles"][0]
.as_object_mut()
.expect("role fixture should be an object")
.remove("breakdownPlanSource");
let content = serde_json::to_string(&payload).expect("payload should serialize");

assert!(project_payload_from_content(&content).is_err());
}

#[test]
fn project_contract_rejects_invalid_breakdown_plan_copy() {
for breakdown_plan in [
"",
" ",
"\u{0009}",
"\u{000B}",
"\u{000C}",
"\u{000D}",
"\u{0085}",
"\u{00A0}",
"\u{1680}",
"\u{2000}",
"\u{200A}",
"\u{2028}",
"\u{2029}",
"\u{202F}",
"\u{205F}",
"\u{3000}",
"\u{FEFF}",
"hold\nthen drop",
"hold\rthen drop",
"hold\u{0085}then drop",
"hold\u{2028}then drop",
"hold\u{2029}then drop",
] {
let mut payload = song_with_breakdown_plan();
payload["sections"][0]["roles"][0]["breakdownPlan"] = json!(breakdown_plan);
let content = serde_json::to_string(&payload).expect("payload should serialize");

assert!(project_payload_from_content(&content).is_err());
}
}

#[test]
fn project_contract_accepts_unicode_padded_single_line_breakdown_plan() {
let mut payload = song_with_breakdown_plan();
payload["sections"][0]["roles"][0]["breakdownPlan"] =
json!("\u{FEFF} Hold this breakdown; keep it sparse until the drop.\u{3000}");
let content = serde_json::to_string(&payload).expect("payload should serialize");

assert!(project_payload_from_content(&content).is_ok());
}
7 changes: 5 additions & 2 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,11 @@ async fn import_youtube_url(

#[tauri::command]
fn save_project(payload: Value) -> Result<(), String> {
let parsed = serde_json::from_value::<RehearsalSongPayload>(payload)
.map_err(|_| "Invalid project payload".to_string())?;
let parsed = validate_breakdown_plan_provenance(
serde_json::from_value::<RehearsalSongPayload>(payload)
.map_err(|_| "Invalid project payload".to_string())?,
)
.map_err(|_| "Invalid project payload".to_string())?;

let path = FileDialog::new()
.add_filter("BandScope Project", &["bscope", "json"])
Expand Down
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();
});
});
Loading
Loading