Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fa27cdf
feat(workspace): name tonight's first drop plan on the map
seonghobae Aug 27, 2026
7f16388
fix(review): remove unused drop-plan constant
seonghobae Aug 27, 2026
620aed1
test(review): pin user drop guidance after open
seonghobae Aug 27, 2026
1c0fa8a
test(i18n): pin consistent Korean drop-plan guidance
seonghobae Aug 27, 2026
b340f15
fix(i18n): align Korean drop-plan guidance tone
seonghobae Aug 27, 2026
2664c8e
test(workspace): reject untranslated model drop guidance
seonghobae Aug 27, 2026
cf938af
fix(workspace): reject free-form model drop guidance
seonghobae Aug 27, 2026
a1c5a57
fix(ci): restore extractor formatter contract
seonghobae Aug 27, 2026
f36fa45
test(core): reject orphan drop-plan provenance
seonghobae Aug 27, 2026
be72fe2
test(i18n): align particle-safe drop copy expectation
seonghobae Aug 27, 2026
0aab1d6
test(i18n): align provenance guidance expectation
seonghobae Aug 27, 2026
0892572
test(analysis): preserve shared source across role swap
seonghobae Aug 27, 2026
86363f3
test(drop-plan): pin shared-source continuity
seonghobae Aug 27, 2026
fc0d3c8
fix(drop-plan): compare continuing sources
seonghobae Aug 27, 2026
72a3012
fix(drop-plan): preserve shared source continuity
seonghobae Aug 27, 2026
ea6c8d9
test(drop-plan): reject unknown native provenance
seonghobae Aug 27, 2026
431eae7
fix(drop-plan): validate native provenance
seonghobae Aug 27, 2026
ea3284a
style(drop-plan): apply pinned Ruff formatting
seonghobae Aug 27, 2026
3ed69f3
test(review): reject accompaniment drop-plan ownership
seonghobae Aug 27, 2026
e4d48df
fix(review): reject accompaniment drop-plan ownership
seonghobae Aug 27, 2026
af95ce4
fix(ci): apply pinned Ruff formatting
seonghobae Aug 27, 2026
bfc35d4
fix(ci): match pinned Ruff set-comprehension layout
seonghobae Aug 27, 2026
ed677cb
fix(ci): restore pinned Ruff drop-plan layout
seonghobae Aug 27, 2026
bfd8c02
style(ci): apply Ruff formatting to drop-plan extractor
seonghobae Aug 27, 2026
c043158
style(ci): make named drop entries formatter-stable
seonghobae Aug 27, 2026
21ec157
test(review): reset drop armed state on display identity changes
seonghobae Aug 27, 2026
0fe3cf7
fix(review): reset drop armed state on display identity changes
seonghobae Aug 27, 2026
db4d992
fix(ci): apply canonical Ruff formatting
seonghobae Aug 27, 2026
3c2da0c
fix(ci): preserve formatter-required final newline
seonghobae Aug 27, 2026
b0b425d
test(drop-plan): build accompaniment swap fixture safely
seonghobae Aug 27, 2026
be14490
test(drop-plan): reject blank sourced guidance
seonghobae Aug 27, 2026
0d5a454
test(drop-plan): reject blank native guidance
seonghobae Aug 27, 2026
86de898
test(drop-plan): cover multiline sourced guidance
seonghobae Aug 27, 2026
b8f7889
test(drop-plan): cover multiline native guidance
seonghobae Aug 27, 2026
ec12e75
fix(drop-plan): reject invalid sourced guidance
seonghobae Aug 28, 2026
5a2847b
fix(drop-plan): reject invalid native guidance
seonghobae Aug 28, 2026
3880092
test(native): reproduce shared role persistence mismatch
seonghobae Aug 28, 2026
5ee84d8
fix(native): preserve optional shared role fields
seonghobae Aug 28, 2026
59efc24
test(native): bound shared practice progress
seonghobae Aug 28, 2026
28a8579
fix(native): enforce shared practice progress range
seonghobae Aug 28, 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 drop plan with the owning part when an entering role is corroborated, the owned `dropPlan` 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, 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 drop plan on the mounted map when section-level stem activity shows a corroborated density fill (previous graph 1–2 distinct sources, current graph ≥3 sources, previous sources stay, new entrance), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-breakdown, 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 drop plan in the mounted rehearsal workspace so the part that enters after a thin texture can land the full-band arrival on the map; real analyzed songs now receive this guidance only when section-level stem activity shows the previous graph with one or two distinct sources and the current graph holding at least three sources with those previous sources staying and a new entrance, 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 drop 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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
57 changes: 55 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 DropPlanSourcePayload {
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>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
drop_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
drop_plan_source: Option<DropPlanSourcePayload>,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
}

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

fn validate_drop_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.drop_plan.as_ref().is_some_and(|drop_plan| {
drop_plan.trim().is_empty()
|| drop_plan.contains('\n')
|| drop_plan.contains('\r')
}) {
return Err("Invalid project file format".to_string());
}
if role.drop_plan.is_none() && role.drop_plan_source.is_some() {
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_drop_plan_provenance(parsed);
}

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

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

fn song_with_drop_plan() -> Value {
json!({
"id": "analyzed-song",
"title": "Late Night Set",
"sections": [
{
"id": "chorus-1",
"label": "chorus",
"groove": "Filled chorus downbeat",
"timeRange": { "start": 30, "end": 46 },
"confidence": {
"level": "high",
"source": "model",
"notes": "Stem activity corroborates the drop."
},
"roles": [
{
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": "vocal",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi landing",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Come in on the filled chorus."
},
"range": {
"lowestNote": "G#3",
"highestNote": "C#5"
},
"confidence": {
"level": "high",
"source": "model",
"notes": "Vocal enters when the texture fills."
},
"rehearsalPriority": "high",
"simplification": "Hold the landing syllable.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": [],
"dropPlan": "Hit this drop; come in together when the texture fills.",
"dropPlanSource": "model"
}
],
"partGraph": [
{
"role_id": "lead-vocal",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Land the chorus drop together.",
"focusSections": ["chorus-1"]
}
})
}

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

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

#[test]
fn project_contract_round_trips_optional_shared_role_fields() {
let mut payload = song_with_drop_plan();
let role = payload["sections"][0]["roles"][0]
.as_object_mut()
.expect("role fixture should be an object");
role.insert(
"harmonicExplanation".into(),
json!("The leading tone resolves into the chorus tonic."),
);
role.insert(
"transpositionPlan".into(),
json!("Move the line down a whole step if the vocal sits high."),
);
role.insert(
"transcription".into(),
json!([{
"pitch": "C#4",
"onset": 30.0,
"offset": 30.5,
"velocity": 96.0
}]),
);
role.insert("practiceProgress".into(), json!(75));
let content = serde_json::to_string(&payload).expect("fixture should serialize");

let parsed = project_payload_from_content(&content)
.expect("native project contract must accept optional shared role fields");
let serialized = serde_json::to_value(parsed).expect("native project contract should serialize");
let serialized_role = &serialized["sections"][0]["roles"][0];

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

#[test]
fn project_contract_rejects_practice_progress_outside_shared_range() {
let mut payload = song_with_drop_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 enforce the shared 0..=100 practice-progress range"
);
}

#[test]
fn project_contract_rejects_drop_plan_source_without_drop_plan() {
let mut payload = song_with_drop_plan();
payload["sections"][0]["roles"][0]
.as_object_mut()
.expect("role fixture should be an object")
.remove("dropPlan");
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_invalid_drop_plan_copy_with_source() {
for drop_plan in ["", " ", "land here\nthen hold", "land here\rthen hold"] {
let mut payload = song_with_drop_plan();
payload["sections"][0]["roles"][0]["dropPlan"] = json!(drop_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 drop-plan copy"
);
}
}

#[test]
fn project_contract_rejects_unknown_drop_plan_source() {
let mut payload = song_with_drop_plan();
payload["sections"][0]["roles"][0]["dropPlanSource"] = 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"
);
}
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 { FirstDropPlanCallout } from "./FirstDropPlanCallout";

const appendedSongStructureTargets = new Set<HTMLElement>();

function songWithCustomDropPlan(source: "model" | "user" | undefined, text: string) {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
verse.partGraph = verse.partGraph.map((node) => ({
...node,
is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right"
}));
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.dropPlan = text;
if (source) {
vocal.dropPlanSource = 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("FirstDropPlanCallout custom guidance", () => {
afterEach(() => {
for (const timeline of appendedSongStructureTargets) {
timeline.remove();
}
appendedSongStructureTargets.clear();
});

it("preserves user-authored drop guidance verbatim", () => {
render(
<FirstDropPlanCallout
song={songWithCustomDropPlan("user", "Come in on the snare; don't rush the last eighth.")}
/>
);
expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy();
});

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

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

expect(screen.getByText("Lead Vocal lands the chorus drop at 0:30.")).toBeTruthy();
expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy();
expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull();
});

it("preserves custom copy without model provenance instead of rewriting it", () => {
render(
<FirstDropPlanCallout
song={songWithCustomDropPlan(undefined, "Stack the last bar and land together.")}
/>
);
expect(screen.getByText("Stack the last bar and land together.")).toBeTruthy();
});
});
Loading
Loading