Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f5c9bee
feat(workspace): name tonight's first fade plan on the map
seonghobae Aug 28, 2026
4c6dd70
fix(ci): wrap fade regression assertion
seonghobae Aug 28, 2026
4ac3d81
test(review): reproduce fade state reset on chord edit
seonghobae Aug 28, 2026
ad921ee
style(analysis): apply pinned ruff formatting
seonghobae Aug 28, 2026
c5193a3
style(analysis): format extractor source
seonghobae Aug 28, 2026
83997d1
fix(workspace): preserve fade state across chord edits
seonghobae Aug 28, 2026
2cba674
fix(coverage): cover fade energy edge cases
seonghobae Aug 28, 2026
030521b
test(review): reject negative fade RMS ranges
seonghobae Aug 28, 2026
5bbe3e7
fix(review): clamp negative stem RMS boundaries
seonghobae Aug 28, 2026
53c5163
test(review): keep read-only workspace chord editor disabled
seonghobae Aug 28, 2026
a7b7458
fix(review): keep read-only chord editor unavailable
seonghobae Aug 28, 2026
f135109
style(review): apply Ruff formatting to fade regression
seonghobae Aug 28, 2026
ecfe905
style(ci): apply exact Ruff comprehension layout
seonghobae Aug 28, 2026
4d06285
test(workspace): assert read-only chord editor is disabled
seonghobae Aug 28, 2026
0082483
style(analysis): format fade plan tests
seonghobae Aug 28, 2026
7224c45
test(review): reject fade copy without provenance
seonghobae Aug 28, 2026
c42407d
test(review): reject native fade copy without provenance
seonghobae Aug 28, 2026
c8b5e39
fix(contract): require provenance for fade plan copy
seonghobae Aug 28, 2026
43331fb
fix(workspace): preserve fade plan contract
seonghobae Aug 28, 2026
f46c177
test(core): reject out-of-range practice progress
seonghobae Aug 28, 2026
4064405
fix(contract): bound practice progress
seonghobae Aug 28, 2026
d50977e
fix(fade): preserve source continuity and project provenance
seonghobae Aug 28, 2026
5c65599
fix(fade): reset identity on external song replacement
seonghobae Aug 28, 2026
08adac5
fix(contract): align fade plan validation
seonghobae Aug 29, 2026
c133146
fix(contract): reject control line breaks
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 fade plan with the owning part when a staying named role is corroborated, the owned `fadePlan` 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, breakdown plans, drop plans, swell 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 fade plan on the mounted map when section-level stem energy shows a corroborated intensity fall (same distinct source set stays, named vocals or bass previous RMS ≥1.8× current after already-audible previous, current still audible), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-swell, first-drop, first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. Accompaniment other never owns.
- 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 fade plan in the mounted rehearsal workspace so the part that quiets in place can land the next downbeat on the map; real analyzed songs now receive this guidance only when section-level stem energy shows the same distinct source set staying while named vocals or bass RMS falls by at least 1.8× after an already-audible previous section and the current section stays audible, 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 fade 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, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-swell, first-drop, first-breakdown, 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
111 changes: 109 additions & 2 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,21 +176,68 @@ 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 FadePlanSourcePayload {
Model,
User,
}

fn deserialize_practice_progress<'de, D>(deserializer: D) -> Result<Option<u8>, D::Error>
where
D: Deserializer<'de>,
{
let progress = Option::<u8>::deserialize(deserializer)?;
if let Some(value) = progress {
if value > 100 {
return Err(serde::de::Error::custom(
"practiceProgress must be between 0 and 100",
));
}
}
Ok(progress)
}

#[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>,
manual_overrides: Vec<ManualOverridePayload>,
overlap_warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
transcription: Option<Vec<TranscriptionNotePayload>>,
#[serde(
default,
deserialize_with = "deserialize_practice_progress",
skip_serializing_if = "Option::is_none"
)]
practice_progress: Option<u8>,
Comment thread
seonghobae marked this conversation as resolved.
#[serde(default, skip_serializing_if = "Option::is_none")]
fade_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fade_plan_source: Option<FadePlanSourcePayload>,
}
Comment thread
seonghobae marked this conversation as resolved.

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -527,9 +574,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_fade_plan(value: &str) -> bool {
let mut has_non_whitespace = false;
for character in value.chars() {
if matches!(
character,
'\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
) {
return false;
}
if !is_plan_whitespace(character) {
has_non_whitespace = true;
}
}
has_non_whitespace
}

pub fn validate_fade_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.fade_plan
.as_deref()
.is_some_and(|fade_plan| !is_valid_fade_plan(fade_plan))
{
return Err("Invalid project file format".to_string());
}
if role.fade_plan.is_none() && role.fade_plan_source.is_some() {
return Err("Invalid project file format".to_string());
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if role.fade_plan.is_some() && role.fade_plan_source.is_none() {
return Err("Invalid project file format".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_fade_plan_provenance(parsed);
}

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

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

fn song_with_fade_plan() -> Value {
json!({
"id": "analyzed-song",
"title": "Late Night Set",
"sections": [
{
"id": "chorus-1",
"label": "chorus",
"groove": "Lifted chorus downbeat",
"timeRange": { "start": 30, "end": 46 },
"confidence": {
"level": "high",
"source": "model",
"notes": "Stem energy corroborates the fade."
},
"roles": [
{
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": "vocal",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi landing",
"source": "model"
},
"harmonicExplanation": "The landing keeps the tonal floor clear.",
"cue": {
"kind": "transition",
"value": "Let the next downbeat land quieter."
},
"range": {
"lowestNote": "G#3",
"highestNote": "C#5"
},
"confidence": {
"level": "high",
"source": "model",
"notes": "Vocal stays while the level comes down."
},
"rehearsalPriority": "high",
"simplification": "Hold the landing syllable.",
"setupNote": "Keep the attack short.",
"transpositionPlan": "Keep the landing shape a whole step lower if needed.",
"manualOverrides": [],
"overlapWarnings": [],
"transcription": [{
"pitch": "C#4",
"onset": 1.0,
"offset": 1.5,
"velocity": 0.8
}],
"practiceProgress": 50,
"fadePlan": "Fade this part; let the next downbeat land quieter.",
"fadePlanSource": "model"
}
],
"partGraph": [
{
"role_id": "lead-vocal",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Let the chorus fade together.",
"focusSections": ["chorus-1"]
}
})
}

#[test]
fn project_contract_round_trips_fade_plan_provenance() {
let payload = song_with_fade_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 fade-plan fields");
let serialized =
serde_json::to_value(parsed).expect("native project contract should serialize");

assert_eq!(
serialized["sections"][0]["roles"][0]["fadePlan"],
payload["sections"][0]["roles"][0]["fadePlan"]
);
assert_eq!(
serialized["sections"][0]["roles"][0]["fadePlanSource"],
json!("model")
);
}

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

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject provenance without the value it describes"
);
}

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

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject fade-plan copy without provenance"
);
}

#[test]
fn project_contract_rejects_invalid_fade_plan_copy_with_source() {
for fade_plan in [
"",
" ",
"\u{00A0}\u{2003}\u{3000}",
"fade here\nthen hold",
"fade here\rthen hold",
"fade here\u{000B}then hold",
"fade here\u{000C}then hold",
"fade here\u{0085}then hold",
"fade here\u{2028}then hold",
"fade here\u{2029}then hold",
] {
let mut payload = song_with_fade_plan();
payload["sections"][0]["roles"][0]["fadePlan"] = json!(fade_plan);
let content = serde_json::to_string(&payload).expect("fixture should serialize");

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject blank or multiline sourced fade-plan copy"
);
}
}

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

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject provenance outside model/user"
);
}

#[test]
fn project_contract_preserves_padded_single_line_fade_copy() {
let mut payload = song_with_fade_plan();
payload["sections"][0]["roles"][0]["fadePlan"] = json!(" Fade together. \u{00A0}");
payload["sections"][0]["roles"][0]["fadePlanSource"] = json!("user");
let content = serde_json::to_string(&payload).expect("fixture should serialize");

let parsed = project_payload_from_content(&content)
.expect("native persisted contract must preserve padded single-line copy");
let serialized =
serde_json::to_value(parsed).expect("native project contract should serialize");

assert_eq!(
serialized["sections"][0]["roles"][0]["fadePlan"],
payload["sections"][0]["roles"][0]["fadePlan"]
);
}

#[test]
fn project_contract_rejects_practice_progress_above_shared_bound() {
let mut payload = song_with_fade_plan();
payload["sections"][0]["roles"][0]["practiceProgress"] = json!(101);
let content = serde_json::to_string(&payload).expect("fixture should serialize");

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject practiceProgress above the shared 0..=100 bound"
);
}
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_fade_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
Loading
Loading