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
8a37c79
feat(workspace): name tonight's first ritardando plan on the map
seonghobae Aug 28, 2026
02b6287
fix(contract): require provenance for ritardando plan copy
seonghobae Aug 28, 2026
de41cba
test(workspace): require rit open confirmation for user plans
seonghobae Aug 28, 2026
0dc37b9
fix(workspace): confirm opened user rit plans
seonghobae Aug 28, 2026
bac0d90
fix(analysis): satisfy ritardando type contract
seonghobae Aug 28, 2026
ae42617
test(workspace): reject false ritardando model copy
seonghobae Aug 28, 2026
1ec80b1
fix(workspace): validate ritardando tempo semantics
seonghobae Aug 28, 2026
a2098f6
test(analysis): prevent ritardando role leakage across sections
seonghobae Aug 28, 2026
38aacf3
fix(analysis): isolate ritardando stamps by section
seonghobae Aug 28, 2026
be9f10f
test(analysis): cover ritardando mutation drift guards
seonghobae Aug 28, 2026
963bc8e
fix(analysis): connect ritardando to source temporal features
seonghobae Aug 28, 2026
02b58dd
test(cache): persist source temporal grid
seonghobae Aug 28, 2026
c1341ba
fix(cache): retain source temporal grid through separation
seonghobae Aug 28, 2026
3f41790
test(cache): satisfy temporal grid lint
seonghobae Aug 28, 2026
db93a2e
fix(ritardando): bound temporal resolver inputs
seonghobae Aug 28, 2026
2be5695
fix(workspace): commit identity refs after render
seonghobae Aug 28, 2026
31b10a4
fix(contract): bound practice progress
seonghobae Aug 28, 2026
11b8848
fix(ritardando): preserve state and bound generated copy
seonghobae Aug 28, 2026
bc63d8a
test(analysis): cover ritardando cache contracts
seonghobae Aug 28, 2026
53ed411
fix(contract): align ritardando plan validation
seonghobae Aug 29, 2026
877ee60
docs: document ritardando vocal tie-breaker
seonghobae Aug 29, 2026
8c846ad
fix(ritardando): preserve detected change timing
seonghobae Aug 29, 2026
76caa77
fix(ritardando): validate model tempo semantics
seonghobae Aug 29, 2026
8fc1a19
fix(contract): align ritardando and tempo validation
seonghobae Aug 29, 2026
f2879ed
fix(contract): reject null analysis tempo
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 ritardando plan with the owning vocal or bass when existing tempo-stability reports a corroborated slowing, the owned `ritardandoPlan` copy, the labeled section, and the time so the next action is Open on the map; prefer vocal over bass when their priorities tie. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-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 ritardando plan on the mounted map when existing tempo-stability reports a sustained slowing (`to_bpm < from_bpm`) that is not a half-time (~0.45–0.55) or double-time (~2.0) feel flip, landing on the highest-priority active named vocal or bass in the section that contains the change; vocal wins a priority tie. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from 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 ritardando plan in the mounted rehearsal workspace so the vocal or bass that eases into a slower tempo can open that landing on the map; real analyzed songs now receive this guidance only when existing tempo-stability reports a sustained slowing that is not a half-time or double-time feel flip, 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 ritardando 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, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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
244 changes: 242 additions & 2 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,34 @@ pub enum AnalysisCacheStatus {
pub struct RehearsalSongPayload {
id: String,
title: String,
#[serde(
default,
deserialize_with = "deserialize_tempo",
skip_serializing_if = "Option::is_none"
)]
tempo: Option<f64>,
Comment thread
seonghobae marked this conversation as resolved.
sections: Vec<RehearsalSectionPayload>,
export_summary: ExportSummaryPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
score_attachments: Option<Vec<ScoreAttachmentMetadataPayload>>,
}

fn deserialize_tempo<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let Some(tempo) = value.as_f64() else {
return Err(serde::de::Error::custom("tempo must be a positive number"));
};
if !tempo.is_finite() || tempo <= 0.0 {
return Err(serde::de::Error::custom(
"tempo must be positive and finite",
));
}
Ok(Some(tempo))
Comment thread
seonghobae marked this conversation as resolved.
}

/// Score attachment metadata persisted inside the song payload. Only the
/// locally minted score id and the display file name cross the IPC boundary;
/// the PDF bytes stay in the app-owned scores directory keyed by that id.
Expand Down Expand Up @@ -176,21 +198,70 @@ 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 RitardandoPlanSourcePayload {
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ritardando_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ritardando_plan_source: Option<RitardandoPlanSourcePayload>,
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
#[serde(default, skip_serializing_if = "Option::is_none")]
ritardando_plan_at_seconds: Option<f64>,
}

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -527,9 +598,134 @@ 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}'
)
}

/// Validates persisted plan text without normalizing user-authored content.
fn is_valid_ritardando_plan(value: &str, source: Option<&RitardandoPlanSourcePayload>) -> 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
&& (!matches!(source, Some(RitardandoPlanSourcePayload::Model))
|| is_valid_model_ritardando_plan(value.trim()))
Comment thread
seonghobae marked this conversation as resolved.
}

const MAX_SECTION_TIME_SECONDS: f64 = 4_294_967_295.0;
const MAX_RITARDANDO_PLAN_CHARACTERS: usize = 180;
const RITARDANDO_PLAN_PREFIX: &str = "Ease this part from ";
const RITARDANDO_PLAN_MIDDLE: &str = " BPM into ";
const RITARDANDO_PLAN_SUFFIX: &str = " BPM; let the next downbeat land later.";
const HALF_TIME_RATIO_MIN: f64 = 0.45;
const HALF_TIME_RATIO_MAX: f64 = 0.55;

fn is_decimal_bpm_token(value: &str) -> bool {
let mut has_digit = false;
let mut decimal_points = 0;
for character in value.chars() {
if character.is_ascii_digit() {
has_digit = true;
} else if character == '.' {
decimal_points += 1;
} else {
return false;
}
}
has_digit && decimal_points <= 1 && !value.starts_with('.') && !value.ends_with('.')
}

fn is_valid_model_ritardando_plan(value: &str) -> bool {
if value.len() > MAX_RITARDANDO_PLAN_CHARACTERS
|| !value.starts_with(RITARDANDO_PLAN_PREFIX)
|| !value.ends_with(RITARDANDO_PLAN_SUFFIX)
{
return false;
}
let inner = &value[RITARDANDO_PLAN_PREFIX.len()..value.len() - RITARDANDO_PLAN_SUFFIX.len()];
let Some((from_bpm, to_bpm)) = inner.split_once(RITARDANDO_PLAN_MIDDLE) else {
return false;
};
if !is_decimal_bpm_token(from_bpm) || !is_decimal_bpm_token(to_bpm) {
return false;
}
let Ok(from_bpm) = from_bpm.parse::<f64>() else {
return false;
};
let Ok(to_bpm) = to_bpm.parse::<f64>() else {
return false;
};
if !from_bpm.is_finite() || !to_bpm.is_finite() || from_bpm <= 0.0 || to_bpm <= 0.0 {
return false;
}
if to_bpm >= from_bpm {
return false;
}
let ratio = to_bpm / from_bpm;
!(HALF_TIME_RATIO_MIN..=HALF_TIME_RATIO_MAX).contains(&ratio)
Comment thread
seonghobae marked this conversation as resolved.
}

fn validate_ritardando_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.ritardando_plan
.as_deref()
.is_some_and(|ritardando_plan| {
!is_valid_ritardando_plan(ritardando_plan, role.ritardando_plan_source.as_ref())
})
{
return Err("Invalid project file format".to_string());
}
if role.ritardando_plan.is_none() && role.ritardando_plan_source.is_some() {
return Err("Invalid project file format".to_string());
}
if role.ritardando_plan.is_some() && role.ritardando_plan_source.is_none() {
return Err("Invalid project file format".to_string());
}
if role.ritardando_plan_at_seconds.is_some_and(|time| {
!time.is_finite() || time < 0.0 || time > MAX_SECTION_TIME_SECONDS
}) {
return Err("Invalid project file format".to_string());
}
if role.ritardando_plan_at_seconds.is_some()
&& (role.ritardando_plan.is_none() || role.ritardando_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_ritardando_plan_provenance(parsed);
}

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

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -786,6 +984,48 @@ mod tests {
assert_eq!(parsed.sections[0].id, "verse-1");
}

#[test]
fn rehearsal_song_payload_round_trips_tempo() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = json!(128.5);

let parsed = serde_json::from_value::<RehearsalSongPayload>(payload)
.expect("optional tempo should deserialize in Tauri");
let serialized =
serde_json::to_value(&parsed).expect("tempo-bearing song should serialize back");

assert_eq!(serialized["tempo"], json!(128.5));
}

#[test]
fn rehearsal_song_payload_rejects_invalid_tempo_values() {
for tempo in [json!(null), json!(0), json!(-1), json!("128")] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = tempo;

assert!(serde_json::from_value::<RehearsalSongPayload>(payload).is_err());
}
}

#[test]
fn analysis_job_status_round_trips_tempo_in_result() {
let mut result = shared_contract_payload(json!({ "start": 10, "end": 30 }));
result["tempo"] = json!(128.5);
let status = json!({
"jobId": "job-1",
"state": "succeeded",
"requestedAt": "2026-03-12T00:00:00Z",
"updatedAt": "2026-03-12T00:00:01Z",
"result": result
});

let parsed = serde_json::from_value::<AnalysisJobStatus>(status)
.expect("analysis status with tempo should deserialize");
let serialized = serde_json::to_value(parsed).expect("analysis status should serialize");

assert_eq!(serialized["result"]["tempo"], json!(128.5));
}

#[test]
fn rehearsal_song_payload_round_trips_score_attachments() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
Expand Down
Loading
Loading