Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 fermata plan with the owning vocal or bass when existing beat times report an isolated extra-duration hold that tempo-stability ignores as a single IBI outlier, the owned `fermataPlan` copy, the labeled section, and the time so the next action is Open on the map. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, accelerando plans, ritardando plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, double-time feel flips, half-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Heuristic demo topology stays unnamed.
- 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 fermata plan on the mounted map when existing beat times report an isolated extra-duration hold (about 1.75–3.5× the local median pulse, extra 0.25–8 s) that tempo-stability ignores as a single IBI outlier, landing on the highest-priority active named vocal or bass in the section that contains the hold. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-accelerando, first-ritardando, first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, first-cutoff, first-pickup, and first-turnaround. This is not a new MIR product.
- 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 fermata plan in the mounted rehearsal workspace so the vocal or bass that holds through an isolated extra beat can open that landing on the map; real analyzed songs now receive this guidance only when existing beat times report a single extra-duration hold that tempo-stability ignores as an IBI outlier, 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 fermata 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, accelerando plans, ritardando plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, double-time feel flips, half-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-accelerando, first-ritardando, first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, and first-cutoff. `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
51 changes: 49 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 FermataPlanSourcePayload {
Model,
User,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RehearsalRolePayload {
Expand All @@ -191,6 +198,12 @@ pub struct RehearsalRolePayload {
setup_note: String,
manual_overrides: Vec<ManualOverridePayload>,
overlap_warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fermata_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fermata_plan_source: Option<FermataPlanSourcePayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fermata_plan_at_seconds: Option<f64>,
}

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

fn validate_fermata_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role.fermata_plan.as_ref().is_some_and(|fermata_plan| {
fermata_plan.trim().is_empty()
|| fermata_plan.contains('\n')
|| fermata_plan.contains('\r')
}) {
return Err("Invalid project file format".to_string());
}
if role.fermata_plan.is_none() && role.fermata_plan_source.is_some() {
return Err("Invalid project file format".to_string());
}
if role.fermata_plan.is_some() && role.fermata_plan_source.is_none() {
return Err("Invalid project file format".to_string());
}
if role
.fermata_plan_at_seconds
.is_some_and(|at_seconds| !at_seconds.is_finite() || at_seconds < 0.0)
{
return Err("Invalid project file format".to_string());
}
if role.fermata_plan.is_none() && role.fermata_plan_at_seconds.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_fermata_plan_provenance(parsed);
}

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

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

fn song_with_fermata_plan() -> Value {
json!({
"id": "analyzed-song",
"title": "Late Night Set",
"sections": [
{
"id": "chorus-1",
"label": "chorus",
"groove": "Lifted chorus downbeat",
"timeRange": { "start": 0, "end": 16 },
"confidence": {
"level": "high",
"source": "model",
"notes": "An isolated beat-gap hold corroborates the fermata."
},
"roles": [
{
"id": "lead-vocal",
"name": "Lead Vocal",
"roleType": "vocal",
"harmony": {
"chord": "C#m7",
"functionLabel": "vi landing",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Wait for the cutoff before the next entrance."
},
"range": {
"lowestNote": "G#3",
"highestNote": "C#5"
},
"confidence": {
"level": "high",
"source": "model",
"notes": "Vocal stays through the held landing."
},
"rehearsalPriority": "high",
"simplification": "Lean into the landing syllable.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": [],
"fermataPlan": "Hold this part through the extra 1 s; wait for the cutoff before the next entrance.",
"fermataPlanSource": "model",
"fermataPlanAtSeconds": 11.25
}
],
"partGraph": [
{
"role_id": "lead-vocal",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Hold the chorus fermata until the cutoff.",
"focusSections": ["chorus-1"]
}
})
}

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

assert_eq!(
serialized["sections"][0]["roles"][0]["fermataPlan"],
payload["sections"][0]["roles"][0]["fermataPlan"]
);
assert_eq!(
serialized["sections"][0]["roles"][0]["fermataPlanSource"],
json!("model")
);
assert_eq!(
serialized["sections"][0]["roles"][0]["fermataPlanAtSeconds"],
json!(11.25)
);
}

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

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

#[test]
fn project_contract_rejects_invalid_fermata_plan_copy_with_source() {
for fermata_plan in ["", " ", "hold here\nthen cut", "hold here\rthen cut"] {
let mut payload = song_with_fermata_plan();
payload["sections"][0]["roles"][0]["fermataPlan"] = json!(fermata_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 fermata-plan copy"
);
}
}

#[test]
fn project_contract_rejects_unknown_fermata_plan_source() {
let mut payload = song_with_fermata_plan();
payload["sections"][0]["roles"][0]["fermataPlanSource"] = 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_rejects_invalid_fermata_plan_timestamp() {
let mut payload = song_with_fermata_plan();
payload["sections"][0]["roles"][0]["fermataPlanAtSeconds"] = json!(-1.0);
let content = serde_json::to_string(&payload).expect("fixture should serialize");

assert!(
project_payload_from_content(&content).is_err(),
"native persisted contract must reject negative fermata-plan timestamps"
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstFermataCallout } from "./FirstFermataCallout";

function songWithKoreanAccel() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
verse.roles = [
{
...verse.roles[0]!,
id: "piano-vocal",
name: "피아노",
roleType: "vocal",
rehearsalPriority: "high",
fermataPlan:
"Hold this part through the extra 1 s; wait for the cutoff before the next entrance.",
fermataPlanSource: "model",
fermataPlanAtSeconds: 11.25
}
];
verse.partGraph = [
{ role_id: "piano-vocal", is_active: true, handoff_to: [], handoff_from: [] }
];
return song;
}

describe("FirstFermataCallout Korean role copy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("keeps vowel-ending role names particle-safe before and after the fermata action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = songWithKoreanAccel();

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
grid.setAttribute("role", "region");
grid.setAttribute("aria-label", "Scrollable song structure timeline");
const target = document.createElement("div");
target.dataset.sectionIndex = "0";
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstFermataCallout song={song} />);

expect(screen.getByText("0:11 벌스에서 피아노 파트가 페르마타를 붙잡습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();
expect(screen.queryByText(/피아노가/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "0:11 피아노 페르마타 열기" }));

expect(
screen.getByText("0:11에서 피아노 파트로 함께 붙잡으세요. 끊을 신호까지 기다리세요.")
).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();
expect(screen.queryByText(/피아노을/)).toBeNull();
expect(screen.queryByText(/피아노를/)).toBeNull();

grid.remove();
});
});
Loading
Loading