diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..f2312a8a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c14b4e9f3..3044738ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..81cc9cdee 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -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, sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } +fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result, 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, +} + /// 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. @@ -528,12 +565,25 @@ pub fn is_youtube_video_id(value: &str) -> bool { } pub fn project_payload_from_content(content: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(content) { + let payload = serde_json::from_str::(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::(payload) + .map_err(|_| "Invalid project file format".to_string())?; + return Ok(envelope.song); + } + + if let Ok(parsed) = serde_json::from_value::(payload.clone()) { return Ok(parsed); } - let payload = serde_json::from_str::(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 @@ -550,6 +600,15 @@ pub fn project_payload_from_content(content: &str) -> Result Result { + 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 { @@ -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::(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::(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!( diff --git a/apps/desktop/core/testdata/project-v1.json b/apps/desktop/core/testdata/project-v1.json new file mode 100644 index 000000000..fe2abd1fe --- /dev/null +++ b/apps/desktop/core/testdata/project-v1.json @@ -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"] + } + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c870d1e7e..6fe3a00ff 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -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())?; diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 4c4368f2c..7d34e3b30 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -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). @@ -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.