Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee3a22d
feat(workspace): name tonight's first accelerando plan on the map
seonghobae Aug 28, 2026
075352d
test(workspace): reject false accelerando model copy
seonghobae Aug 28, 2026
d289215
fix(workspace): validate accelerando tempo semantics
seonghobae Aug 28, 2026
5be5c31
test(analysis): prevent accelerando role leakage across sections
seonghobae Aug 28, 2026
fcfd761
fix(analysis): isolate accelerando stamps by section
seonghobae Aug 28, 2026
5d4d20d
fix(analysis): sort temporal exports
seonghobae Aug 28, 2026
9a0212e
fix(accelerando): reuse temporal analysis features
seonghobae Aug 28, 2026
ee34e81
fix(analysis): preserve typed temporal features
seonghobae Aug 28, 2026
bb0501b
fix(cache): invalidate stale analysis results
seonghobae Aug 28, 2026
50cbef3
fix(analysis): retain typed temporal feature cast
seonghobae Aug 28, 2026
1778dba
test(cache): cover current schema misses
seonghobae Aug 28, 2026
da317b2
fix(accelerando): validate inputs before analysis
seonghobae Aug 28, 2026
db7763c
fix(accelerando): avoid empty-grid redecoding
seonghobae Aug 28, 2026
c1e6c11
fix(cli): document deferred request validation
seonghobae Aug 28, 2026
fc0ffa8
fix(workspace): commit identity refs after render
seonghobae Aug 28, 2026
403e0dc
fix(accelerando): preserve workspace state and role fields
seonghobae Aug 28, 2026
3aa8690
fix(accelerando): handle external workspace replacement
seonghobae Aug 28, 2026
2803872
fix(accelerando): align plan newline validation
seonghobae Aug 28, 2026
90feef2
fix(contract): align accelerando plan validation
seonghobae Aug 29, 2026
e202f5d
fix(cache): reuse temporal analysis features
seonghobae Aug 29, 2026
e64bd97
style: format cache regression test
seonghobae Aug 29, 2026
2d13684
fix(accelerando): preserve detected change timing
seonghobae Aug 29, 2026
8f8b2da
style(accelerando): format boundary test
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 accelerando plan with the owning vocal or bass when existing tempo-stability reports a corroborated speeding, the owned `accelerandoPlan` 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, 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 accelerando plan on the mounted map when existing tempo-stability reports a sustained speeding (`to_bpm > from_bpm`) that is not a double-time (~1.9–2.1) or half-time (~0.5) feel flip, landing on the highest-priority active named vocal or bass in the section that contains the change. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from 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 accelerando plan in the mounted rehearsal workspace so the vocal or bass that lifts into a faster tempo can open that landing on the map; real analyzed songs now receive this guidance only when existing tempo-stability reports a sustained speeding that is not a double-time or half-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 accelerando 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, 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-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
125 changes: 123 additions & 2 deletions apps/desktop/core/src/lib.rs
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -176,21 +176,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,
}

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 = "lowercase")]
enum AccelerandoPlanSourcePayload {
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,
deserialize_with = "deserialize_practice_progress",
skip_serializing_if = "Option::is_none"
)]
practice_progress: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accelerando_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accelerando_plan_source: Option<AccelerandoPlanSourcePayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accelerando_plan_at_seconds: Option<f64>,
}
Comment thread
seonghobae marked this conversation as resolved.

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

const MAX_SECTION_TIME_SECONDS: f64 = 4_294_967_295.0;

fn validate_accelerando_plan_provenance(
payload: RehearsalSongPayload,
) -> Result<RehearsalSongPayload, String> {
for section in &payload.sections {
for role in &section.roles {
if role
.accelerando_plan
.as_deref()
.is_some_and(|accelerando_plan| !is_valid_accelerando_plan(accelerando_plan))
{
return Err("Invalid project file format".to_string());
}
if role.accelerando_plan.is_none() && role.accelerando_plan_source.is_some() {
return Err("Invalid project file format".to_string());
}
if role.accelerando_plan.is_some() && role.accelerando_plan_source.is_none() {
return Err("Invalid project file format".to_string());
}
if role.accelerando_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.accelerando_plan_at_seconds.is_some()
&& (role.accelerando_plan.is_none() || role.accelerando_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_accelerando_plan_provenance(parsed);
}

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

#[derive(Clone, Debug, Serialize)]
Expand Down
Loading
Loading