Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2e01017
feat(workspace): name tonight's first pickup plan on the map
seonghobae Aug 26, 2026
3537f2f
test(workspace): exercise inherited pickup-plan guard
seonghobae Aug 26, 2026
b262875
test(workspace): preserve user pickup-plan provenance
seonghobae Aug 26, 2026
717b217
fix(workspace): preserve user pickup-plan copy semantics
seonghobae Aug 26, 2026
6cd55d0
test(workspace): use built-in suffix assertion
seonghobae Aug 26, 2026
04a7c61
test(workspace): require structured pickup provenance
seonghobae Aug 26, 2026
9218b2b
test(analysis): require structured pickup provenance
seonghobae Aug 26, 2026
5fa6f8b
test(analysis): keep pickup provenance contract scoped
seonghobae Aug 26, 2026
2bdafc1
test(workspace): derive pickup localization from graph
seonghobae Aug 26, 2026
6d988f8
fix(workspace): derive pickup localization from topology
seonghobae Aug 26, 2026
12d1b37
fix(workspace): stop parsing pickup display copy
seonghobae Aug 26, 2026
dc8ceea
test(pickup): reproduce accompaniment label drift
seonghobae Aug 26, 2026
b2ad8ab
test(workspace): align pickup target fixture with topology
seonghobae Aug 27, 2026
06ee753
fix(ci): document pickup localization contract
seonghobae Aug 27, 2026
26340b1
fix(pickup): align shared accompaniment label
seonghobae Aug 27, 2026
0377ad0
fix(pickup): preserve generated target semantics
seonghobae Aug 27, 2026
1d005bb
fix(pickup): derive model guidance from landing topology
seonghobae Aug 27, 2026
f418a02
test(pickup): mark bounded generated fixture as model-owned
seonghobae Aug 27, 2026
0854b7a
test(review): bound model pickup target copy
seonghobae Aug 27, 2026
f069b21
fix(review): bound localized pickup target copy
seonghobae Aug 27, 2026
6857708
test(review): align pickup localization fixtures with topology
seonghobae Aug 27, 2026
5668f63
test(review): align pickup accompaniment label
seonghobae Aug 27, 2026
0314af5
test(workspace): reproduce accompaniment pickup localization
seonghobae Aug 28, 2026
b95a4a6
fix(workspace): localize shared accompaniment pickup
seonghobae Aug 28, 2026
eb28a68
fix(roles): remove unreachable pickup branch
seonghobae Aug 28, 2026
3f618da
test(workspace): reject foreign pickup renderer fallback
seonghobae Aug 28, 2026
f6f82c5
fix(workspace): keep pickup navigation within owner
seonghobae Aug 28, 2026
f3628f2
test(workspace): mount pickup renderer in owner scope
seonghobae Aug 28, 2026
40b2904
fix(workspace): enforce pickup plan provenance
seonghobae Aug 28, 2026
354a96a
test(shared-types): reproduce pickup plan persistence mismatch
seonghobae Aug 28, 2026
98301b4
fix(contract): align Unicode pickup validation
seonghobae Aug 29, 2026
f651f8d
test(pickup): reject shared accompaniment false owner
seonghobae Aug 29, 2026
69958b5
fix(pickup): keep shared accompaniment owner unnamed
seonghobae Aug 29, 2026
deeb55e
style(analysis): format pickup partner selection
seonghobae Aug 29, 2026
6f2039a
style(analysis): restore formatter-compliant extractor newline
seonghobae Aug 30, 2026
ab2d88d
style(analysis): apply ruff pickup comprehension format
seonghobae Aug 30, 2026
e3405f5
style(analysis): match Ruff 100-column comprehension layout
seonghobae Aug 30, 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 @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Name tonight's first pickup plan with the owning part when an active role is corroborated, the owned `pickupPlan` 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, confirmed overrides, harmonic explanations, or confidence notes.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-27
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Brand source

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- The mounted workspace copy for tonight's first pickup plan must name the owning part when corroborated, the owned `pickupPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-turnaround-plan, first-cutoff-plan, first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-articulation-plan, and the labeled-pickup section guide.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.

## Security source
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 pickup plan in the mounted rehearsal workspace so a part that was resting can lead into the next downbeat on the map; real analyzed songs now receive this guidance only when section-level stem activity shows that part becoming active after an abutting rest, with at least one other distinct source already on the landing downbeat, 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 pickup 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-turnaround-plan, first-cutoff-plan, first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-articulation-plan, and the labeled-pickup section guide. `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
98 changes: 96 additions & 2 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,21 +176,49 @@ pub struct ManualOverridePayload {
source: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TranscriptionNotePayload {
pitch: String,
onset: f64,
offset: f64,
velocity: f64,
}

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

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RehearsalRolePayload {
id: String,
name: String,
role_type: String,
harmony: HarmonyPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
harmonic_explanation: Option<String>,
cue: CuePayload,
range: RangePayload,
confidence: ConfidencePayload,
rehearsal_priority: String,
simplification: String,
setup_note: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
transposition_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pickup_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pickup_plan_source: Option<PickupPlanSourcePayload>,
manual_overrides: Vec<ManualOverridePayload>,
overlap_warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
transcription: Option<Vec<TranscriptionNotePayload>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
practice_progress: Option<u8>,
}

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -529,7 +557,7 @@ pub fn is_youtube_video_id(value: &str) -> bool {

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_pickup_plan_provenance(parsed);
}

let payload = serde_json::from_str::<Value>(content)
Expand All @@ -547,7 +575,73 @@ 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_pickup_plan_provenance(parsed)
}

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_pickup_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
}

fn validate_pickup_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.practice_progress
.is_some_and(|progress| progress > 100)
{
return Err("Invalid project file format".to_string());
}
if role
.pickup_plan
.as_deref()
.is_some_and(|pickup_plan| !is_valid_pickup_plan(pickup_plan))
{
return Err("Invalid project file format".to_string());
}
if role.pickup_plan.is_none() && role.pickup_plan_source.is_some() {
return Err("Invalid project file format".to_string());
}
if role.pickup_plan.is_some() && role.pickup_plan_source.is_none() {
return Err("Invalid project file format".to_string());
}
}
}
Comment thread
seonghobae marked this conversation as resolved.
Ok(payload)
}

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

fn song_with_pickup_plan() -> Value {
json!({
"id": "analyzed-song",
"title": "Late Night Set",
"sections": [{
"id": "verse-1",
"label": "verse",
"groove": "Rest into the downbeat",
"timeRange": { "start": 0, "end": 30 },
"confidence": {
"level": "high",
"source": "model",
"notes": "The pickup is supported by the active-part transition."
},
"roles": [{
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": "instrument",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi pedal anchor",
"source": "model"
},
"harmonicExplanation": "The bass holds the tonal floor into the downbeat.",
"cue": {
"kind": "transition",
"value": "Hold through the pickup before the downbeat."
},
"range": {
"lowestNote": "C#2",
"highestNote": "E3"
},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Watch the slide into the turnaround."
},
"rehearsalPriority": "high",
"simplification": "Stay on roots if the entrance gets muddy.",
"setupNote": "Keep the attack short.",
"transpositionPlan": "Move the shape down a whole step if needed.",
"pickupPlan": "Play this pickup with Lead Vocal; land the downbeat together.",
"pickupPlanSource": "model",
"manualOverrides": [],
"overlapWarnings": [],
"transcription": [{
"pitch": "C#2",
"onset": 29.0,
"offset": 29.5,
"velocity": 0.8
}],
"practiceProgress": 50
}],
"partGraph": [{
"role_id": "bass-guitar",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}]
}],
"exportSummary": {
"format": "cue-sheet",
"headline": "Land the downbeat together.",
"focusSections": ["verse-1"]
}
})
}

#[test]
fn project_contract_round_trips_pickup_plan_and_shared_role_fields() {
let payload = song_with_pickup_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 pickup-plan fields");
let serialized =
serde_json::to_value(parsed).expect("native project contract should serialize");
let role = &serialized["sections"][0]["roles"][0];

for field in [
"harmonicExplanation",
"transpositionPlan",
"pickupPlan",
"pickupPlanSource",
"transcription",
"practiceProgress",
] {
assert_eq!(role[field], payload["sections"][0]["roles"][0][field]);
}
}

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

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

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

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

#[test]
fn project_contract_rejects_invalid_pickup_plan_copy() {
for pickup_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}",
"land here\nthen hold",
"land here\rthen hold",
"land here\u{0085}then hold",
"land here\u{2028}then hold",
"land here\u{2029}then hold",
] {
let mut payload = song_with_pickup_plan();
payload["sections"][0]["roles"][0]["pickupPlan"] = json!(pickup_plan);
let content = serde_json::to_string(&payload).expect("fixture should serialize");

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

#[test]
fn project_contract_accepts_unicode_padded_single_line_pickup_plan() {
let mut payload = song_with_pickup_plan();
payload["sections"][0]["roles"][0]["pickupPlan"] = json!("\u{FEFF} Land the downbeat \u{3000}");
let content = serde_json::to_string(&payload).expect("fixture should serialize");

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

#[test]
fn project_contract_rejects_unknown_pickup_plan_source() {
let mut payload = song_with_pickup_plan();
payload["sections"][0]["roles"][0]["pickupPlanSource"] = json!("legacy");
let content = serde_json::to_string(&payload).expect("fixture should serialize");

assert!(project_payload_from_content(&content).is_err());
}
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 { FirstPickupPlanCallout } from "./FirstPickupPlanCallout";

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

const callouts = screen.getAllByRole("complementary", {
name: "Tonight's first pickup 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);
});
Loading
Loading