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
3eb3676
feat(workspace): name tonight's first swell plan on the map
seonghobae Aug 28, 2026
3e5d775
fix(ci): wrap swell regression assertion
seonghobae Aug 28, 2026
f41b5e0
style(analysis): apply pinned ruff formatting
seonghobae Aug 28, 2026
8ea3b6c
style(analysis): format extractor source
seonghobae Aug 28, 2026
7d6db4b
fix(coverage): remove unreachable swell branches
seonghobae Aug 28, 2026
ec9f3bf
test(review): preserve document body in swell cleanup
seonghobae Aug 28, 2026
46f772d
test(review): disarm swell after navigation failure
seonghobae Aug 28, 2026
6c4edeb
fix(review): disarm swell on navigation failure
seonghobae Aug 28, 2026
db73b7a
test(review): reject swell copy without provenance
seonghobae Aug 28, 2026
6c0fafc
test(review): reject native swell copy without provenance
seonghobae Aug 28, 2026
9bff468
test(review): keep drum source changes unnamed
seonghobae Aug 28, 2026
88c2c74
fix(contract): require provenance for swell plan copy
seonghobae Aug 28, 2026
f2542ed
fix(analysis): preserve raw source continuity for swells
seonghobae Aug 28, 2026
e4ad759
style(analysis): format swell source continuity
seonghobae Aug 28, 2026
ac5631e
style(analysis): normalize source-set comprehension
seonghobae Aug 28, 2026
5acb401
style(analysis): match pinned Ruff line fit
seonghobae Aug 28, 2026
cfa4c91
test(analysis): reject non-finite swell energy
seonghobae Aug 28, 2026
8c4827e
fix(analysis): fail closed on non-finite swell energy
seonghobae Aug 28, 2026
bad930f
test(roles): prevent cross-section role aliasing
seonghobae Aug 28, 2026
f50dc24
fix(analysis): isolate section role payloads
seonghobae Aug 28, 2026
ee7e5ae
fix(workspace): require swell plan provenance
seonghobae Aug 28, 2026
cd4c955
refactor(analysis): remove redundant role copy
seonghobae Aug 28, 2026
cfbffa3
fix(cache): invalidate stale swell results
seonghobae Aug 28, 2026
aeede66
test(cache): cover current schema misses
seonghobae Aug 28, 2026
284d0a3
test(cache): preserve stems across result schema bumps
seonghobae Aug 28, 2026
2c3e19e
fix(cache): keep feature paths across result schema bumps
seonghobae Aug 28, 2026
995e3fc
fix(api): type swell role payload fields
seonghobae Aug 28, 2026
0b03fb7
fix(contract): align swell plan validation
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 swell plan with the owning part when a staying named role is corroborated, the owned `swellPlan` 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, 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 swell plan on the mounted map when section-level stem energy shows a corroborated intensity rise (same distinct source set stays, named vocals or bass RMS ≥1.8× after already-audible previous), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from 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 swell plan in the mounted rehearsal workspace so the part that grows in place can lift into 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 grows by at least 1.8× after an already-audible previous section, 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 swell 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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
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 SwellPlanSourcePayload {
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")]
swell_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
swell_plan_source: Option<SwellPlanSourcePayload>,
}
Comment thread
seonghobae marked this conversation as resolved.

#[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_swell_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_swell_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.swell_plan
.as_deref()
.is_some_and(|swell_plan| !is_valid_swell_plan(swell_plan))
{
return Err("Invalid project file format".to_string());
}
if role.swell_plan.is_none() && role.swell_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.swell_plan.is_some() && role.swell_plan_source.is_none() {
return Err("Invalid project file format".to_string());
}
}
}
Ok(payload)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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_swell_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_swell_plan_provenance(parsed)
}

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

fn song_with_swell_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 swell."
},
"roles": [
{
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": "vocal",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi landing",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Grow into the next downbeat."
},
"range": {
"lowestNote": "G#3",
"highestNote": "C#5"
},
"confidence": {
"level": "high",
"source": "model",
"notes": "Vocal stays while the lift grows."
},
"rehearsalPriority": "high",
"simplification": "Hold the landing syllable.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": [],
"swellPlan": "Swell this part; grow into the next downbeat.",
"swellPlanSource": "model"
}
],
"partGraph": [
{
"role_id": "lead-vocal",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Grow the chorus swell together.",
"focusSections": ["chorus-1"]
}
})
}

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

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

#[test]
fn project_contract_rejects_swell_plan_source_without_swell_plan() {
let mut payload = song_with_swell_plan();
payload["sections"][0]["roles"][0]
.as_object_mut()
.expect("role fixture should be an object")
.remove("swellPlan");
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_swell_plan_without_source() {
let mut payload = song_with_swell_plan();
payload["sections"][0]["roles"][0]
.as_object_mut()
.expect("role fixture should be an object")
.remove("swellPlanSource");
let content = serde_json::to_string(&payload).expect("fixture should serialize");

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

#[test]
fn project_contract_rejects_invalid_swell_plan_copy_with_source() {
for swell_plan in [
"",
" ",
"\u{00A0}\u{2003}\u{3000}",
"swell here\nthen hold",
"swell here\rthen hold",
"swell here\u{0085}then hold",
"swell here\u{2028}then hold",
"swell here\u{2029}then hold",
] {
let mut payload = song_with_swell_plan();
payload["sections"][0]["roles"][0]["swellPlan"] = json!(swell_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 swell-plan copy"
);
}
}

#[test]
fn project_contract_rejects_unknown_swell_plan_source() {
let mut payload = song_with_swell_plan();
payload["sections"][0]["roles"][0]["swellPlanSource"] = 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_swell_copy() {
let mut payload = song_with_swell_plan();
payload["sections"][0]["roles"][0]["swellPlan"] = json!(" Grow together. \u{00A0}");
payload["sections"][0]["roles"][0]["swellPlanSource"] = 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]["swellPlan"],
payload["sections"][0]["roles"][0]["swellPlan"]
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstSwellPlanCallout } from "./FirstSwellPlanCallout";

const appendedSongStructureTargets = new Set<HTMLElement>();

function songWithCustomSwellPlan(source: "model" | "user" | undefined, text: string) {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true }));
const chorus = structuredClone(verse);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 };
chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true }));
const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!;
vocal.swellPlan = text;
if (source) {
vocal.swellPlanSource = source;
}
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);
appendedSongStructureTargets.add(timeline);
}

describe("FirstSwellPlanCallout custom guidance", () => {
afterEach(() => {
for (const timeline of appendedSongStructureTargets) {
timeline.remove();
}
appendedSongStructureTargets.clear();
});

it("preserves user-authored swell guidance verbatim", () => {
render(
<FirstSwellPlanCallout
song={songWithCustomSwellPlan("user", "Grow on the snare; don't rush the last eighth.")}
/>
);
expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy();
});

it("keeps user-authored guidance in the plain body after opening the swell", () => {
appendSongStructureTarget();
render(
<FirstSwellPlanCallout
song={songWithCustomSwellPlan("user", "Grow on the snare; don't rush the last eighth.")}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" }));

expect(screen.getByText("Lead Vocal swells the chorus at 0:30.")).toBeTruthy();
expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy();
expect(screen.queryByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeNull();
});

it("fails closed for custom copy without provenance", () => {
render(
<FirstSwellPlanCallout
song={songWithCustomSwellPlan(undefined, "Stack the last bar and grow together.")}
/>
);
expect(
screen.getByText(
"No swell plan is available. Stay on tonight's map for the next rehearsal cue."
)
).toBeTruthy();
expect(screen.queryByText("Stack the last bar and grow together.")).toBeNull();
});
});
Loading
Loading