Skip to content
Merged
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 ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Last updated: 2026-03-11
- Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals.
- Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas.
- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands.
- Project writes currently use an independent v1 JSON envelope around the validated rehearsal song; legacy raw song files remain readable, unknown envelope fields fail closed, and unsupported versions return an explicit error. Typed source, derived, decision, handoff, preference, and volatile runtime sections remain follow-up work under #962.
- Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener.
- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase.
- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
- Write project files through the versioned `projectFormatVersion: 1` envelope and retain validated tempo values across save/load, with explicit legacy and unsupported-version handling.

### Changed

Expand Down
154 changes: 151 additions & 3 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,49 @@ pub enum AnalysisCacheStatus {
pub struct RehearsalSongPayload {
id: String,
title: String,
#[serde(
default,
deserialize_with = "deserialize_project_tempo",
skip_serializing_if = "Option::is_none"
)]
tempo: Option<f64>,
sections: Vec<RehearsalSectionPayload>,
export_summary: ExportSummaryPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
score_attachments: Option<Vec<ScoreAttachmentMetadataPayload>>,
}

fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::Number(number) => match number.as_f64() {
Some(tempo) if tempo.is_finite() && tempo > 0.0 => Ok(Some(tempo)),
_ => Err(serde::de::Error::custom(
"project tempo must be a finite positive number",
)),
},
_ => Err(serde::de::Error::custom(
"project tempo must be a finite positive number",
)),
}
}

/// Current on-disk project format version, independent of the app version.
pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 1;

/// Versioned project envelope. The song remains the compatibility view until
/// source, derived, decision, handoff, preference, and runtime fields are
/// promoted into typed sections in a later format version.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProjectFilePayload {
project_format_version: u16,
song: RehearsalSongPayload,
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 @@ -528,12 +565,25 @@ pub fn is_youtube_video_id(value: &str) -> bool {
}

pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayload, String> {
if let Ok(parsed) = serde_json::from_str::<RehearsalSongPayload>(content) {
let payload = serde_json::from_str::<Value>(content)
.map_err(|_| "Invalid project file format".to_string())?;

if let Some(version_value) = payload.get("projectFormatVersion") {
let version = version_value
.as_u64()
.ok_or_else(|| "Invalid project file format".to_string())?;
if version != u64::from(CURRENT_PROJECT_FORMAT_VERSION) {
return Err(format!("Unsupported project format version: {version}"));
}
let envelope = serde_json::from_value::<ProjectFilePayload>(payload)
.map_err(|_| "Invalid project file format".to_string())?;
return Ok(envelope.song);
}

if let Ok(parsed) = serde_json::from_value::<RehearsalSongPayload>(payload.clone()) {
return Ok(parsed);
}

let payload = serde_json::from_str::<Value>(content)
.map_err(|_| "Invalid project file format".to_string())?;
if let Some(sections) = payload.get("sections").and_then(Value::as_array) {
for (section_index, section) in sections.iter().enumerate() {
if section
Expand All @@ -550,6 +600,15 @@ pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayloa
serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string())
}

/// Serialize one validated song into the current versioned project envelope.
pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> Result<String, String> {
serde_json::to_string_pretty(&ProjectFilePayload {
project_format_version: CURRENT_PROJECT_FORMAT_VERSION,
song: payload.clone(),
})
.map_err(|_| "Failed to serialize project file format".to_string())
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScoreAttachmentPayload {
Expand Down Expand Up @@ -869,6 +928,95 @@ mod tests {
assert_eq!(parsed.title, "Late Night Set");
}

#[test]
fn project_format_v1_round_trips_the_song_and_tempo() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = json!(120.0);
let song = serde_json::from_value::<RehearsalSongPayload>(payload)
.expect("song payload should deserialize");

let content = project_content_for_payload(&song).expect("v1 project should serialize");
let encoded: Value = serde_json::from_str(&content).expect("v1 project should be JSON");
assert_eq!(
encoded["projectFormatVersion"],
json!(CURRENT_PROJECT_FORMAT_VERSION)
);
assert_eq!(encoded["song"]["tempo"], json!(120.0));

let parsed = project_payload_from_content(&content).expect("v1 project should load");
assert_eq!(parsed.title, "Late Night Set");
assert_eq!(parsed.tempo, Some(120.0));
}

#[test]
fn project_format_v1_fixture_is_loadable() {
let parsed = project_payload_from_content(include_str!("../testdata/project-v1.json"))
.expect("the checked-in v1 fixture should load");

assert_eq!(parsed.id, "fixture-song");
assert_eq!(parsed.tempo, Some(96.0));
}

#[test]
fn project_format_rejects_unknown_fields_and_unsupported_versions() {
let payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
let mut envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION,
"song": payload
});
envelope["unexpected"] = json!(true);
assert_eq!(
project_payload_from_content(&envelope.to_string())
.expect_err("unknown fields fail closed"),
"Invalid project file format"
);

let supported_payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
let supported_envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1,
"song": supported_payload
});
assert_eq!(
project_payload_from_content(&supported_envelope.to_string())
.expect_err("unsupported version should be explicit"),
"Unsupported project format version: 2"
);

let future_envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1,
"futureEnvelopeField": true,
"song": { "futureSongField": "new schema" }
});
assert_eq!(
project_payload_from_content(&future_envelope.to_string())
.expect_err("future schema should report its unsupported version"),
"Unsupported project format version: 2"
);
}

#[test]
fn project_format_rejects_invalid_tempo_values() {
for invalid_tempo in [json!(null), json!(0), json!(-10), json!("120")] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = invalid_tempo;
assert!(
serde_json::from_value::<RehearsalSongPayload>(payload).is_err(),
"invalid tempo should fail closed"
);
}

assert!(
project_payload_from_content(
&format!(
r#"{{"projectFormatVersion":{},"song":{{"id":"song","title":"Song","tempo":1e999,"sections":[],"exportSummary":{{}}}}}}"#,
CURRENT_PROJECT_FORMAT_VERSION
)
)
.is_err(),
"non-finite JSON numbers should fail closed"
);
}

#[test]
fn project_payload_from_content_rejects_malformed_or_incomplete_payloads() {
assert_eq!(
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/core/testdata/project-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"projectFormatVersion": 1,
"song": {
"id": "fixture-song",
"title": "Fixture Rehearsal",
"tempo": 96,
"sections": [
{
"id": "verse-1",
"label": "verse",
"groove": "Straight eighths",
"timeRange": {
"start": 0,
"end": 4
},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Check the entrance."
},
"roles": [
{
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": "instrument",
"harmony": {
"chord": "C",
"functionLabel": "tonic",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Enter on the downbeat."
},
"range": {
"lowestNote": "C2",
"highestNote": "G3"
},
"confidence": {
"level": "medium",
"source": "model",
"notes": ""
},
"rehearsalPriority": "high",
"simplification": "Play roots.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": []
}
],
"partGraph": [
{
"role_id": "bass-guitar",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Start with the verse.",
"focusSections": ["verse-1"]
}
}
}
3 changes: 1 addition & 2 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,8 +750,7 @@ fn save_project(payload: Value) -> Result<(), String> {
.save_file()
.ok_or_else(|| "User cancelled".to_string())?;

let content = serde_json::to_string_pretty(&parsed)
.map_err(|_| "Failed to serialize project".to_string())?;
let content = project_content_for_payload(&parsed)?;
project_persistence::recover_project_publication(&path)?;
project_persistence::publish_new_project_file(&path, content.as_bytes())?;

Expand Down
30 changes: 20 additions & 10 deletions docs/engineering/local-project-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,33 @@ This document specifies the format and lifecycle of a BandScope `.bscope` projec

## Overview

BandScope projects are saved as `.bscope` files. These files are standard JSON containing the serialized `RehearsalSong` data structure. They allow users to persist the results of audio analysis and their manual corrections (overrides) across sessions.
BandScope projects are saved as `.bscope` files. Current writes use a standard JSON envelope with `projectFormatVersion: 1`; the nested `song` is the current compatibility view used by the desktop contract. Older raw `RehearsalSong` JSON remains loadable as an explicit legacy input and is never silently rewritten in memory as a newer version.

## Schema

The primary data structure for a `.bscope` file is the `RehearsalSong` type from `@bandscope/shared-types`.

### Top-Level Structure
### Top-Level Structure (version 1)

```json
{
"id": "string",
"title": "string",
"sections": [ ... ],
"exportSummary": {
"format": "cue-sheet",
"headline": "string",
"focusSections": ["string"]
"projectFormatVersion": 1,
"song": {
"id": "string",
"title": "string",
"tempo": 120,
"sections": [ ... ],
"exportSummary": {
"format": "cue-sheet",
"headline": "string",
"focusSections": ["string"]
}
}
}
```

The version is independent of the application package version. The v1 reader rejects unknown envelope fields and returns an explicit unsupported-version error for a well-formed future version. The checked-in golden fixture is `apps/desktop/core/testdata/project-v1.json`.

### Sections and Roles

Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). Each section contains a list of roles (instruments or vocals).
Expand Down Expand Up @@ -80,6 +86,10 @@ When loading `.bscope` files from disk, BandScope applies the following constrai
2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract.
3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks.

## Current boundary and next migration slices

Version 1 deliberately keeps the existing validated `RehearsalSong` as the compatibility view. Source references, derived analysis artifacts, user decisions, portable handoff data, UI preferences, and volatile player state are not fabricated or written into untyped bags. Their typed promotion, bounded autosave journal, backup rotation, migration receipts, and accessible restore/compare/discard flow remain the next #962 slices. Player state must use this authority after the transport state machine is stable; it must not create a second localStorage or session persistence authority.

## Extensibility

Future updates to the `.bscope` format should be backward-compatible where possible, adding new fields to the `RehearsalSong` contract rather than breaking existing fields. If structural changes are required, a format version field may be introduced.
Future updates to the `.bscope` format must add an ordered migration from the prior envelope, validate a copy before publication, retain the prior known-good artifact, and update the machine-verifiable fixture. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded.