From ba9a4ee81209b058375690187b0660924f04dbb0 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 1 Dec 2025 09:18:49 -0500 Subject: [PATCH] File save/load --- daw-backend/src/audio/clip.rs | 4 +- daw-backend/src/audio/engine.rs | 73 ++- daw-backend/src/audio/midi.rs | 4 +- daw-backend/src/audio/midi_pool.rs | 2 + daw-backend/src/audio/node_graph/graph.rs | 11 + daw-backend/src/audio/pool.rs | 47 +- daw-backend/src/audio/project.rs | 43 ++ daw-backend/src/audio/track.rs | 97 ++- daw-backend/src/command/types.rs | 8 + .../lightningbeam-core/Cargo.toml | 10 + .../lightningbeam-core/src/document.rs | 11 + .../lightningbeam-core/src/file_io.rs | 438 +++++++++++++ .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-editor/Cargo.toml | 1 + .../lightningbeam-editor/src/main.rs | 614 ++++++++++++++++-- .../src/panes/asset_library.rs | 10 +- .../lightningbeam-editor/src/panes/mod.rs | 4 +- .../src/panes/timeline.rs | 22 +- .../src/panes/virtual_piano.rs | 12 +- 19 files changed, 1339 insertions(+), 73 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/file_io.rs diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs index 6ec1c8b..6f487a2 100644 --- a/daw-backend/src/audio/clip.rs +++ b/daw-backend/src/audio/clip.rs @@ -1,3 +1,5 @@ +use serde::{Serialize, Deserialize}; + /// Audio clip instance ID type pub type AudioClipInstanceId = u32; @@ -16,7 +18,7 @@ pub type ClipId = AudioClipInstanceId; /// ## Looping /// If `external_duration` is greater than `internal_end - internal_start`, /// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioClipInstance { pub id: AudioClipInstanceId, pub audio_pool_index: usize, diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index fa2a352..1c61c5c 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -483,12 +483,19 @@ impl Engine { let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); } Command::AddAudioFile(path, data, channels, sample_rate) => { + // Detect original format from file extension + let path_buf = std::path::PathBuf::from(path.clone()); + let original_format = path_buf.extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()); + // Create AudioFile and add to pool - let audio_file = crate::audio::pool::AudioFile::new( - std::path::PathBuf::from(path.clone()), + let audio_file = crate::audio::pool::AudioFile::with_format( + path_buf, data, channels, sample_rate, + original_format, ); let pool_index = self.audio_pool.add_file(audio_file); // Notify UI about the new audio file @@ -1730,6 +1737,22 @@ impl Engine { Err(e) => QueryResponse::AudioClipInstanceAdded(Err(e.to_string())), } } + Query::GetProject => { + // Clone the entire project for serialization + QueryResponse::ProjectRetrieved(Ok(Box::new(self.project.clone()))) + } + Query::SetProject(new_project) => { + // Replace the current project with the new one + // Need to rebuild audio graphs with current sample_rate and buffer_size + let mut project = *new_project; + match project.rebuild_audio_graphs(self.buffer_pool.buffer_size()) { + Ok(()) => { + self.project = project; + QueryResponse::ProjectSet(Ok(())) + } + Err(e) => QueryResponse::ProjectSet(Err(format!("Failed to rebuild audio graphs: {}", e))), + } + } }; // Send response back @@ -1850,11 +1873,13 @@ impl Engine { frames_recorded, temp_file_path, waveform.len(), audio_data.len()); // Add to pool using the in-memory audio data (no file loading needed!) - let pool_file = crate::audio::pool::AudioFile::new( + // Recorded audio is always WAV format + let pool_file = crate::audio::pool::AudioFile::with_format( temp_file_path.clone(), audio_data, channels, sample_rate, + Some("wav".to_string()), ); let pool_index = self.audio_pool.add_file(pool_file); eprintln!("[STOP_RECORDING] Added to pool at index {}", pool_index); @@ -2741,4 +2766,46 @@ impl EngineController { Err("Export timeout".to_string()) } + + /// Get a clone of the current project for serialization + pub fn get_project(&mut self) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetProject) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::ProjectRetrieved(result)) = self.query_response_rx.pop() { + return result.map(|boxed| *boxed); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Set the project (replaces current project state) + pub fn set_project(&mut self, project: crate::audio::project::Project) -> Result<(), String> { + // Send query + if let Err(_) = self.query_tx.push(Query::SetProject(Box::new(project))) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); // Longer timeout for loading project + + while start.elapsed() < timeout { + if let Ok(QueryResponse::ProjectSet(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } } diff --git a/daw-backend/src/audio/midi.rs b/daw-backend/src/audio/midi.rs index 7dbaeb0..8a496da 100644 --- a/daw-backend/src/audio/midi.rs +++ b/daw-backend/src/audio/midi.rs @@ -73,7 +73,7 @@ pub type MidiClipInstanceId = u32; /// /// This represents the content data stored in the MidiClipPool. /// Events have timestamps relative to the start of the clip (0.0 = clip beginning). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClip { pub id: MidiClipId, pub events: Vec, @@ -132,7 +132,7 @@ impl MidiClip { /// ## Looping /// If `external_duration` is greater than `internal_end - internal_start`, /// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipInstance { pub id: MidiClipInstanceId, pub clip_id: MidiClipId, // Reference to MidiClip in pool diff --git a/daw-backend/src/audio/midi_pool.rs b/daw-backend/src/audio/midi_pool.rs index 184333a..b368d72 100644 --- a/daw-backend/src/audio/midi_pool.rs +++ b/daw-backend/src/audio/midi_pool.rs @@ -1,8 +1,10 @@ +use serde::{Serialize, Deserialize}; use std::collections::HashMap; use super::midi::{MidiClip, MidiClipId, MidiEvent}; /// Pool for storing MIDI clip content /// Similar to AudioClipPool but for MIDI data +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MidiClipPool { clips: HashMap, next_id: MidiClipId, diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 7db51bb..c0ab8f4 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -22,6 +22,16 @@ pub struct GraphNode { pub midi_output_buffers: Vec>, } +impl std::fmt::Debug for GraphNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GraphNode") + .field("node", &"") + .field("output_buffers_len", &self.output_buffers.len()) + .field("midi_output_buffers_len", &self.midi_output_buffers.len()) + .finish() + } +} + impl GraphNode { pub fn new(node: Box, buffer_size: usize) -> Self { let outputs = node.outputs(); @@ -57,6 +67,7 @@ impl GraphNode { } /// Audio processing graph for instruments/effects +#[derive(Debug)] pub struct AudioGraph { /// The audio graph (StableGraph allows node removal without index invalidation) graph: StableGraph, diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index 52b3af2..d8db76b 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -59,6 +59,9 @@ pub struct AudioFile { pub channels: u32, pub sample_rate: u32, pub frames: u64, + /// Original file format (mp3, ogg, wav, flac, etc.) + /// Used to determine if we should preserve lossy encoding during save + pub original_format: Option, } impl AudioFile { @@ -71,6 +74,20 @@ impl AudioFile { channels, sample_rate, frames, + original_format: None, + } + } + + /// Create a new AudioFile with original format information + pub fn with_format(path: PathBuf, data: Vec, channels: u32, sample_rate: u32, original_format: Option) -> Self { + let frames = (data.len() / channels as usize) as u64; + Self { + path, + data, + channels, + sample_rate, + frames, + original_format, } } @@ -452,7 +469,27 @@ impl AudioClipPool { fn embed_from_memory(audio_file: &AudioFile) -> EmbeddedAudioData { use base64::{Engine as _, engine::general_purpose}; - // Convert the f32 interleaved samples to WAV format bytes + // Check if this is a lossy format that should be preserved + let is_lossy = audio_file.original_format.as_ref().map_or(false, |fmt| { + let fmt_lower = fmt.to_lowercase(); + fmt_lower == "mp3" || fmt_lower == "ogg" || fmt_lower == "aac" + || fmt_lower == "m4a" || fmt_lower == "opus" + }); + + if is_lossy { + // For lossy formats, read the original file bytes (if it still exists) + if let Ok(original_bytes) = std::fs::read(&audio_file.path) { + let data_base64 = general_purpose::STANDARD.encode(&original_bytes); + return EmbeddedAudioData { + data_base64, + format: audio_file.original_format.clone().unwrap_or_else(|| "mp3".to_string()), + }; + } + // If we can't read the original file, fall through to WAV conversion + } + + // For lossless/PCM or if we couldn't read the original lossy file, + // convert the f32 interleaved samples to WAV format bytes let wav_data = Self::encode_wav( &audio_file.data, audio_file.channels, @@ -672,11 +709,17 @@ impl AudioClipPool { } } - let audio_file = AudioFile::new( + // Detect original format from file extension + let original_format = file_path.extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()); + + let audio_file = AudioFile::with_format( file_path.to_path_buf(), samples, channels, sample_rate, + original_format, ); if pool_index >= self.files.len() { diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index fbe1d73..1b2174f 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -4,6 +4,7 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi use super::midi_pool::MidiClipPool; use super::pool::AudioClipPool; use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode}; +use serde::{Serialize, Deserialize}; use std::collections::HashMap; /// Project manages the hierarchical track structure and clip pools @@ -13,6 +14,7 @@ use std::collections::HashMap; /// /// Clip content is stored in pools (MidiClipPool), while tracks store /// clip instances that reference the pool content. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { tracks: HashMap, next_track_id: TrackId, @@ -515,6 +517,47 @@ impl Project { track.queue_live_midi(event); } } + + /// Prepare all tracks for serialization by saving their audio graphs as presets + pub fn prepare_for_save(&mut self) { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(audio_track) => { + audio_track.prepare_for_save(); + } + TrackNode::Midi(midi_track) => { + midi_track.prepare_for_save(); + } + TrackNode::Group(_) => { + // Groups don't have audio graphs + } + } + } + } + + /// Rebuild all audio graphs from presets after deserialization + /// + /// This should be called after deserializing a Project to reconstruct + /// the AudioGraph instances from their stored presets. + /// + /// # Arguments + /// * `buffer_size` - Buffer size for audio processing (typically 8192) + pub fn rebuild_audio_graphs(&mut self, buffer_size: usize) -> Result<(), String> { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(audio_track) => { + audio_track.rebuild_audio_graph(self.sample_rate, buffer_size)?; + } + TrackNode::Midi(midi_track) => { + midi_track.rebuild_audio_graph(self.sample_rate, buffer_size)?; + } + TrackNode::Group(_) => { + // Groups don't have audio graphs + } + } + } + Ok(()) + } } impl Default for Project { diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index f181261..afcdcf3 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -4,12 +4,19 @@ use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi_pool::MidiClipPool; use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; +use super::node_graph::preset::GraphPreset; use super::pool::AudioClipPool; +use serde::{Serialize, Deserialize}; use std::collections::HashMap; /// Track ID type pub type TrackId = u32; +/// Default function for creating empty AudioGraph during deserialization +fn default_audio_graph() -> AudioGraph { + AudioGraph::new(48000, 8192) +} + /// Type alias for backwards compatibility pub type Track = AudioTrack; @@ -59,6 +66,7 @@ impl RenderContext { } /// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum TrackNode { Audio(AudioTrack), Midi(MidiTrack), @@ -145,6 +153,7 @@ impl TrackNode { } /// Metatrack that contains other tracks with time transformation capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Metatrack { pub id: TrackId, pub name: String, @@ -301,12 +310,21 @@ impl Metatrack { } /// MIDI track with MIDI clip instances and a node-based instrument +#[derive(Debug, Serialize, Deserialize)] pub struct MidiTrack { pub id: TrackId, pub name: String, /// Clip instances placed on this track (reference clips in the MidiClipPool) pub clip_instances: Vec, + + /// Serialized instrument graph (used for save/load) + #[serde(default, skip_serializing_if = "Option::is_none")] + instrument_graph_preset: Option, + + /// Runtime instrument graph (rebuilt from preset on load) + #[serde(skip, default = "default_audio_graph")] pub instrument_graph: AudioGraph, + pub volume: f32, pub muted: bool, pub solo: bool, @@ -314,9 +332,28 @@ pub struct MidiTrack { pub automation_lanes: HashMap, next_automation_id: AutomationLaneId, /// Queue for live MIDI input (virtual keyboard, MIDI controllers) + #[serde(skip)] live_midi_queue: Vec, } +impl Clone for MidiTrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + clip_instances: self.clip_instances.clone(), + instrument_graph_preset: self.instrument_graph_preset.clone(), + instrument_graph: default_audio_graph(), // Create fresh graph, not cloned + volume: self.volume, + muted: self.muted, + solo: self.solo, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + live_midi_queue: Vec::new(), // Don't clone live MIDI queue + } + } +} + impl MidiTrack { /// Create a new MIDI track with default settings pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { @@ -327,6 +364,7 @@ impl MidiTrack { id, name, clip_instances: Vec::new(), + instrument_graph_preset: None, instrument_graph: AudioGraph::new(sample_rate, default_buffer_size), volume: 1.0, muted: false, @@ -337,6 +375,22 @@ impl MidiTrack { } } + /// Prepare for serialization by saving the instrument graph as a preset + pub fn prepare_for_save(&mut self) { + self.instrument_graph_preset = Some(self.instrument_graph.to_preset("Instrument Graph")); + } + + /// Rebuild the instrument graph from preset after deserialization + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.instrument_graph_preset { + self.instrument_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + } else { + // No preset - create default graph + self.instrument_graph = AudioGraph::new(sample_rate, buffer_size); + } + Ok(()) + } + /// Add an automation lane to this track pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { let lane_id = self.next_automation_id; @@ -504,6 +558,7 @@ impl MidiTrack { } /// Audio track with audio clip instances +#[derive(Debug, Serialize, Deserialize)] pub struct AudioTrack { pub id: TrackId, pub name: String, @@ -515,10 +570,33 @@ pub struct AudioTrack { /// Automation lanes for this track pub automation_lanes: HashMap, next_automation_id: AutomationLaneId, - /// Effects processing graph for this audio track + + /// Serialized effects graph (used for save/load) + #[serde(default, skip_serializing_if = "Option::is_none")] + effects_graph_preset: Option, + + /// Runtime effects processing graph (rebuilt from preset on load) + #[serde(skip, default = "default_audio_graph")] pub effects_graph: AudioGraph, } +impl Clone for AudioTrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + clips: self.clips.clone(), + volume: self.volume, + muted: self.muted, + solo: self.solo, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + effects_graph_preset: self.effects_graph_preset.clone(), + effects_graph: default_audio_graph(), // Create fresh graph, not cloned + } + } +} + impl AudioTrack { /// Create a new audio track with default settings pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { @@ -555,10 +633,27 @@ impl AudioTrack { solo: false, automation_lanes: HashMap::new(), next_automation_id: 0, + effects_graph_preset: None, effects_graph, } } + /// Prepare for serialization by saving the effects graph as a preset + pub fn prepare_for_save(&mut self) { + self.effects_graph_preset = Some(self.effects_graph.to_preset("Effects Graph")); + } + + /// Rebuild the effects graph from preset after deserialization + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.effects_graph_preset { + self.effects_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + } else { + // No preset - create default graph + self.effects_graph = AudioGraph::new(sample_rate, buffer_size); + } + Ok(()) + } + /// Add an automation lane to this track pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { let lane_id = self.next_automation_id; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 3a2d618..a5acb97 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -272,6 +272,10 @@ pub enum Query { AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), /// Add an audio clip to a track synchronously (track_id, pool_index, start_time, duration, offset) - returns instance ID AddAudioClipSync(TrackId, usize, f64, f64, f64), + /// Get a clone of the current project for serialization + GetProject, + /// Set the project (replaces current project state) + SetProject(Box), } /// Oscilloscope data from a node @@ -335,4 +339,8 @@ pub enum QueryResponse { MidiClipInstanceAdded(Result), /// Audio clip instance added (returns instance ID) AudioClipInstanceAdded(Result), + /// Project retrieved + ProjectRetrieved(Result, String>), + /// Project set + ProjectSet(Result<(), String>), } diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 49f2093..502acb3 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -22,3 +22,13 @@ uuid = { version = "1.0", features = ["v4", "serde"] } # Audio backend daw-backend = { path = "../../daw-backend" } + +# File I/O +zip = "0.6" +chrono = "0.4" +base64 = "0.21" +pathdiff = "0.2" + +# Audio encoding for embedded files +flacenc = "0.4" # For FLAC encoding (lossless) +claxon = "0.4" # For FLAC decoding diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 0c41c3c..c00f3e4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -5,6 +5,7 @@ use crate::clip::{AudioClip, ImageAsset, VideoClip, VectorClip}; use crate::layer::AnyLayer; +use crate::layout::LayoutNode; use crate::shape::ShapeColor; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -106,6 +107,14 @@ pub struct Document { /// Image asset library - static images for fill textures pub image_assets: HashMap, + /// Current UI layout state (serialized for save/load) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_layout: Option, + + /// Name of base layout this was derived from (for reference only) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_layout_base: Option, + /// Current playback time in seconds #[serde(skip)] pub current_time: f64, @@ -126,6 +135,8 @@ impl Default for Document { video_clips: HashMap::new(), audio_clips: HashMap::new(), image_assets: HashMap::new(), + ui_layout: None, + ui_layout_base: None, current_time: 0.0, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs new file mode 100644 index 0000000..a1644e6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -0,0 +1,438 @@ +//! File I/O for .beam project files +//! +//! This module handles saving and loading Lightningbeam projects in the .beam format, +//! which is a ZIP archive containing: +//! - project.json (compressed) - Project metadata and structure +//! - media/ directory (uncompressed) - Embedded media files (FLAC for audio) + +use crate::document::Document; +use daw_backend::audio::pool::AudioPoolEntry; +use daw_backend::audio::project::Project as AudioProject; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; +use flacenc::error::Verify; + +/// File format version +pub const BEAM_VERSION: &str = "1.0.0"; + +/// Default buffer size for audio processing (512 samples = ~10.7ms at 48kHz) +pub const DEFAULT_BUFFER_SIZE: usize = 512; + +/// Complete .beam project structure for serialization +#[derive(Serialize, Deserialize)] +pub struct BeamProject { + /// File format version + pub version: String, + + /// Project creation timestamp (ISO 8601) + pub created: String, + + /// Last modified timestamp (ISO 8601) + pub modified: String, + + /// UI state (Document from lightningbeam-core) + pub ui_state: Document, + + /// Audio backend state + pub audio_backend: SerializedAudioBackend, +} + +/// Serialized audio backend state +#[derive(Serialize, Deserialize)] +pub struct SerializedAudioBackend { + /// Sample rate for audio processing + pub sample_rate: u32, + + /// Audio project (tracks, MIDI clips, etc.) + pub project: AudioProject, + + /// Audio pool entries (metadata and paths for audio files) + /// Note: embedded_data field from daw-backend is ignored; embedded files + /// are stored as FLAC in the ZIP's media/audio/ directory instead + pub audio_pool_entries: Vec, +} + +/// Settings for saving a project +#[derive(Debug, Clone)] +pub struct SaveSettings { + /// Automatically embed files smaller than this size (in bytes) + pub auto_embed_threshold_bytes: u64, + + /// Force embedding all media files + pub force_embed_all: bool, + + /// Force linking all media files (don't embed any) + pub force_link_all: bool, +} + +impl Default for SaveSettings { + fn default() -> Self { + Self { + auto_embed_threshold_bytes: 10_000_000, // 10 MB + force_embed_all: false, + force_link_all: false, + } + } +} + +/// Result of loading a project +pub struct LoadedProject { + /// Deserialized document + pub document: Document, + + /// Deserialized audio project + pub audio_project: AudioProject, + + /// Loaded audio pool entries + pub audio_pool_entries: Vec, + + /// List of files that couldn't be found + pub missing_files: Vec, +} + +/// Information about a missing file +#[derive(Debug, Clone)] +pub struct MissingFileInfo { + /// Index in the audio pool + pub pool_index: usize, + + /// Original file path + pub original_path: PathBuf, + + /// Type of media file + pub file_type: MediaFileType, +} + +/// Type of media file +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaFileType { + Audio, + Video, + Image, +} + +/// Save a project to a .beam file +/// +/// This function: +/// 1. Prepares audio project for save (saves AudioGraph presets) +/// 2. Serializes project data to JSON +/// 3. Creates ZIP archive with compressed project.json +/// 4. Embeds media files as FLAC (for audio) in media/ directory +/// +/// # Arguments +/// * `path` - Path to save the .beam file +/// * `document` - UI document state +/// * `audio_project` - Audio backend project +/// * `audio_pool_entries` - Serialized audio pool entries +/// * `settings` - Save settings (embedding preferences) +/// +/// # Returns +/// Ok(()) on success, or error message +pub fn save_beam( + path: &Path, + document: &Document, + audio_project: &mut AudioProject, + audio_pool_entries: Vec, + _settings: &SaveSettings, +) -> Result<(), String> { + // 1. Create backup if file exists + if path.exists() { + let backup_path = path.with_extension("beam.backup"); + std::fs::copy(path, &backup_path) + .map_err(|e| format!("Failed to create backup: {}", e))?; + } + + // 2. Prepare audio project for serialization (save AudioGraph presets) + audio_project.prepare_for_save(); + + // 3. Create ZIP writer + let file = File::create(path) + .map_err(|e| format!("Failed to create file: {}", e))?; + let mut zip = ZipWriter::new(file); + + // 4. Process audio pool entries and write embedded audio files to ZIP + // Smart compression: lossy formats (mp3, ogg) stored as-is, lossless data as FLAC + let mut modified_entries = Vec::new(); + for entry in &audio_pool_entries { + let mut modified_entry = entry.clone(); + + if let Some(ref embedded_data) = entry.embedded_data { + // Decode base64 audio data + let audio_bytes = base64::decode(&embedded_data.data_base64) + .map_err(|e| format!("Failed to decode base64 audio data for pool index {}: {}", entry.pool_index, e))?; + + let format_lower = embedded_data.format.to_lowercase(); + let is_lossy = format_lower == "mp3" || format_lower == "ogg" + || format_lower == "aac" || format_lower == "m4a" + || format_lower == "opus"; + + let zip_filename = if is_lossy { + // Store lossy formats directly (no transcoding) + format!("media/audio/{}.{}", entry.pool_index, embedded_data.format) + } else { + // Store lossless data as FLAC + format!("media/audio/{}.flac", entry.pool_index) + }; + + // Write to ZIP (uncompressed - audio is already compressed) + let file_options = FileOptions::default() + .compression_method(CompressionMethod::Stored); + + zip.start_file(&zip_filename, file_options) + .map_err(|e| format!("Failed to create {} in ZIP: {}", zip_filename, e))?; + + if is_lossy { + // Write lossy file directly + zip.write_all(&audio_bytes) + .map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?; + } else { + // Decode PCM samples and encode to FLAC + // The audio_bytes are raw PCM samples (interleaved f32 little-endian) + let samples: Vec = audio_bytes + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + + // Convert f32 samples to i32 for FLAC encoding (FLAC doesn't support f32) + // FLAC supports up to 24-bit samples: range [-8388608, 8388607] + let samples_i32: Vec = samples + .iter() + .map(|&s| { + // Clamp to [-1.0, 1.0] first, then scale to 24-bit range + let clamped = s.clamp(-1.0, 1.0); + (clamped * 8388607.0) as i32 + }) + .collect(); + + // Configure FLAC encoder + let config = flacenc::config::Encoder::default() + .into_verified() + .map_err(|(_, e)| format!("FLAC encoder config error: {:?}", e))?; + + let source = flacenc::source::MemSource::from_samples( + &samples_i32, + entry.channels as usize, + 24, // bits per sample (FLAC max is 24-bit) + entry.sample_rate as usize, + ); + + // Encode to FLAC + let flac_stream = flacenc::encode_with_fixed_block_size( + &config, + source, + config.block_size, + ).map_err(|e| format!("FLAC encoding failed: {:?}", e))?; + + // Convert stream to bytes + use flacenc::component::BitRepr; + let mut sink = flacenc::bitsink::ByteSink::new(); + flac_stream.write(&mut sink) + .map_err(|e| format!("Failed to write FLAC stream: {:?}", e))?; + let flac_bytes = sink.as_slice(); + + zip.write_all(flac_bytes) + .map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?; + } + + // Update entry to point to ZIP file instead of embedding data + modified_entry.embedded_data = None; + modified_entry.relative_path = Some(zip_filename); + } + + modified_entries.push(modified_entry); + } + + // 5. Build BeamProject structure with modified entries + let now = chrono::Utc::now().to_rfc3339(); + let beam_project = BeamProject { + version: BEAM_VERSION.to_string(), + created: now.clone(), + modified: now, + ui_state: document.clone(), + audio_backend: SerializedAudioBackend { + sample_rate: 48000, // TODO: Get from audio engine + project: audio_project.clone(), + audio_pool_entries: modified_entries, + }, + }; + + // 6. Write project.json (compressed with DEFLATE) + let json_options = FileOptions::default() + .compression_method(CompressionMethod::Deflated) + .compression_level(Some(6)); + + zip.start_file("project.json", json_options) + .map_err(|e| format!("Failed to create project.json in ZIP: {}", e))?; + + let json = serde_json::to_string_pretty(&beam_project) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + zip.write_all(json.as_bytes()) + .map_err(|e| format!("Failed to write project.json: {}", e))?; + + // 7. Finalize ZIP + zip.finish() + .map_err(|e| format!("Failed to finalize ZIP: {}", e))?; + + Ok(()) +} + +/// Load a project from a .beam file +/// +/// This function: +/// 1. Opens ZIP archive and reads project.json +/// 2. Deserializes project data +/// 3. Loads embedded media files from archive +/// 4. Attempts to load external media files +/// 5. Rebuilds AudioGraphs from presets with correct sample_rate +/// +/// # Arguments +/// * `path` - Path to the .beam file +/// +/// # Returns +/// LoadedProject on success (with missing_files list), or error message +pub fn load_beam(path: &Path) -> Result { + // 1. Open ZIP archive + let file = File::open(path) + .map_err(|e| format!("Failed to open file: {}", e))?; + let mut zip = ZipArchive::new(file) + .map_err(|e| format!("Failed to open ZIP archive: {}", e))?; + + // 2. Read project.json + let mut project_file = zip.by_name("project.json") + .map_err(|e| format!("Failed to find project.json in archive: {}", e))?; + + let mut json_data = String::new(); + project_file.read_to_string(&mut json_data) + .map_err(|e| format!("Failed to read project.json: {}", e))?; + + // 3. Deserialize BeamProject + let beam_project: BeamProject = serde_json::from_str(&json_data) + .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; + + // 4. Check version compatibility + if beam_project.version != BEAM_VERSION { + return Err(format!( + "Unsupported file version: {} (expected {})", + beam_project.version, BEAM_VERSION + )); + } + + // 5. Extract document and audio backend state + let document = beam_project.ui_state; + let mut audio_project = beam_project.audio_backend.project; + let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; + + // 6. Rebuild AudioGraphs from presets + audio_project.rebuild_audio_graphs(DEFAULT_BUFFER_SIZE) + .map_err(|e| format!("Failed to rebuild audio graphs: {}", e))?; + + // 7. Extract embedded audio files from ZIP and restore to entries + drop(project_file); // Close project.json file handle + let mut restored_entries = Vec::new(); + + for entry in &audio_pool_entries { + let mut restored_entry = entry.clone(); + + // Check if this entry has a file in the ZIP (relative_path starts with "media/audio/") + if let Some(ref rel_path) = entry.relative_path { + if rel_path.starts_with("media/audio/") { + // Extract file from ZIP + match zip.by_name(rel_path) { + Ok(mut audio_file) => { + let mut audio_bytes = Vec::new(); + audio_file.read_to_end(&mut audio_bytes) + .map_err(|e| format!("Failed to read {} from ZIP: {}", rel_path, e))?; + + // Determine format from filename + let format = rel_path.split('.').last() + .unwrap_or("flac") + .to_string(); + + // For lossless formats, decode back to PCM f32 samples + // For lossy formats, store the original bytes + let embedded_data = if format == "flac" { + // Decode FLAC to PCM f32 samples + let cursor = std::io::Cursor::new(&audio_bytes); + let mut reader = claxon::FlacReader::new(cursor) + .map_err(|e| format!("Failed to create FLAC reader: {:?}", e))?; + + let stream_info = reader.streaminfo(); + let bits_per_sample = stream_info.bits_per_sample; + let max_value = (1i64 << (bits_per_sample - 1)) as f32; + + // Read all samples and convert to f32 + let mut samples_f32 = Vec::new(); + for sample_result in reader.samples() { + let sample = sample_result + .map_err(|e| format!("Failed to read FLAC sample: {:?}", e))?; + samples_f32.push(sample as f32 / max_value); + } + + // Convert f32 samples to bytes (little-endian) + let mut pcm_bytes = Vec::new(); + for sample in samples_f32 { + pcm_bytes.extend_from_slice(&sample.to_le_bytes()); + } + + Some(daw_backend::audio::pool::EmbeddedAudioData { + data_base64: base64::encode(&pcm_bytes), + format: "wav".to_string(), // Mark as WAV since it's now PCM + }) + } else { + // Lossy format - store as-is + Some(daw_backend::audio::pool::EmbeddedAudioData { + data_base64: base64::encode(&audio_bytes), + format: format.clone(), + }) + }; + + restored_entry.embedded_data = embedded_data; + restored_entry.relative_path = None; // Clear ZIP path + } + Err(_) => { + // File not found in ZIP, treat as external reference + } + } + } + } + + restored_entries.push(restored_entry); + } + + // 8. Check for missing external files + // An entry is missing if it has a relative_path (external reference) + // but no embedded_data and the file doesn't exist + let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let missing_files: Vec = restored_entries + .iter() + .enumerate() + .filter_map(|(idx, entry)| { + // Check if this entry references an external file that doesn't exist + if entry.embedded_data.is_none() { + if let Some(ref rel_path) = entry.relative_path { + let full_path = project_dir.join(rel_path); + if !full_path.exists() { + return Some(MissingFileInfo { + pool_index: idx, + original_path: full_path, + file_type: MediaFileType::Audio, + }); + } + } + } + None + }) + .collect(); + + Ok(LoadedProject { + document, + audio_project, + audio_pool_entries: restored_entries, + missing_files, + }) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 3cb097c..a8bbf8c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -29,3 +29,4 @@ pub mod intersection_graph; pub mod segment_builder; pub mod planar_graph; pub mod file_types; +pub mod file_io; diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 0e9296e..ee5dcc9 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" lightningbeam-core = { path = "../lightningbeam-core" } daw-backend = { path = "../../daw-backend" } rtrb = "0.3" +cpal = "0.15" # UI Framework eframe = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 281956f..a6049a2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -245,6 +245,164 @@ impl ToolIconCache { } } +/// Command sent to file operations worker thread +enum FileCommand { + Save { + path: std::path::PathBuf, + document: lightningbeam_core::document::Document, + progress_tx: std::sync::mpsc::Sender, + }, + Load { + path: std::path::PathBuf, + progress_tx: std::sync::mpsc::Sender, + }, +} + +/// Progress updates from file operations worker +enum FileProgress { + SerializingAudioPool, + EncodingAudio { current: usize, total: usize }, + WritingZip, + LoadingProject, + DecodingAudio { current: usize, total: usize }, + Complete(Result), // For loading + Error(String), + Done, +} + +/// Active file operation state +enum FileOperation { + Saving { + path: std::path::PathBuf, + progress_rx: std::sync::mpsc::Receiver, + }, + Loading { + path: std::path::PathBuf, + progress_rx: std::sync::mpsc::Receiver, + }, +} + +/// Worker thread for file operations (save/load) +struct FileOperationsWorker { + command_rx: std::sync::mpsc::Receiver, + audio_controller: std::sync::Arc>, +} + +impl FileOperationsWorker { + /// Create a new worker and spawn it on a background thread + fn spawn(audio_controller: std::sync::Arc>) + -> std::sync::mpsc::Sender + { + let (command_tx, command_rx) = std::sync::mpsc::channel(); + + let worker = FileOperationsWorker { + command_rx, + audio_controller, + }; + + std::thread::spawn(move || { + worker.run(); + }); + + command_tx + } + + /// Main worker loop - processes file commands + fn run(self) { + while let Ok(command) = self.command_rx.recv() { + match command { + FileCommand::Save { path, document, progress_tx } => { + self.handle_save(path, document, progress_tx); + } + FileCommand::Load { path, progress_tx } => { + self.handle_load(path, progress_tx); + } + } + } + } + + /// Handle save command + fn handle_save( + &self, + path: std::path::PathBuf, + document: lightningbeam_core::document::Document, + progress_tx: std::sync::mpsc::Sender, + ) { + use lightningbeam_core::file_io::{save_beam, SaveSettings}; + + // Step 1: Serialize audio pool + let _ = progress_tx.send(FileProgress::SerializingAudioPool); + + let audio_pool_entries = { + let mut controller = self.audio_controller.lock().unwrap(); + match controller.serialize_audio_pool(&path) { + Ok(entries) => entries, + Err(e) => { + let _ = progress_tx.send(FileProgress::Error(format!("Failed to serialize audio pool: {}", e))); + return; + } + } + }; + + // Step 2: Get project + let mut audio_project = { + let mut controller = self.audio_controller.lock().unwrap(); + match controller.get_project() { + Ok(p) => p, + Err(e) => { + let _ = progress_tx.send(FileProgress::Error(format!("Failed to get project: {}", e))); + return; + } + } + }; + + // Step 3: Save to file + let _ = progress_tx.send(FileProgress::WritingZip); + + let settings = SaveSettings::default(); + match save_beam(&path, &document, &mut audio_project, audio_pool_entries, &settings) { + Ok(()) => { + println!("✅ Saved to: {}", path.display()); + let _ = progress_tx.send(FileProgress::Done); + } + Err(e) => { + let _ = progress_tx.send(FileProgress::Error(format!("Save failed: {}", e))); + } + } + } + + /// Handle load command + fn handle_load( + &self, + path: std::path::PathBuf, + progress_tx: std::sync::mpsc::Sender, + ) { + use lightningbeam_core::file_io::load_beam; + + // Step 1: Load from file + let _ = progress_tx.send(FileProgress::LoadingProject); + + let loaded_project = match load_beam(&path) { + Ok(p) => p, + Err(e) => { + let _ = progress_tx.send(FileProgress::Error(format!("Load failed: {}", e))); + return; + } + }; + + // Check for missing files + if !loaded_project.missing_files.is_empty() { + eprintln!("⚠️ {} missing files", loaded_project.missing_files.len()); + for missing in &loaded_project.missing_files { + eprintln!(" - {}", missing.original_path.display()); + } + } + + // Send the loaded project back to UI thread for processing + let _ = progress_tx.send(FileProgress::Complete(Ok(loaded_project))); + } +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -272,7 +430,11 @@ struct EditorApp { rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) // Audio engine integration - audio_system: Option, // Audio system (must be kept alive for stream) + audio_stream: Option, // Audio stream (must be kept alive) + audio_controller: Option>>, // Shared audio controller + audio_event_rx: Option>, // Audio event receiver + audio_sample_rate: u32, // Audio sample rate + audio_channels: u32, // Audio channel count // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) layer_to_track_map: HashMap, track_to_layer_map: HashMap, @@ -293,6 +455,13 @@ struct EditorApp { /// Prevents repeated backend queries for the same MIDI clip /// Format: (timestamp, note_number, is_note_on) midi_event_cache: HashMap>, + /// Current file path (None if not yet saved) + current_file_path: Option, + + /// File operations worker command sender + file_command_tx: std::sync::mpsc::Sender, + /// Current file operation in progress (if any) + file_operation: Option, } /// Import filter types for the file dialog @@ -338,18 +507,35 @@ impl EditorApp { // Wrap document in ActionExecutor let action_executor = lightningbeam_core::action::ActionExecutor::new(document); - // Initialize audio system (keep the whole system to maintain the audio stream) - let audio_system = match daw_backend::AudioSystem::new(None, 256) { - Ok(audio_system) => { - println!("✅ Audio engine initialized successfully"); - Some(audio_system) - } - Err(e) => { - eprintln!("❌ Failed to initialize audio engine: {}", e); - eprintln!(" Playback will be disabled"); - None - } - }; + // Initialize audio system and destructure it for sharing + let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) = + match daw_backend::AudioSystem::new(None, 256) { + Ok(audio_system) => { + println!("✅ Audio engine initialized successfully"); + + // Extract components + let stream = audio_system.stream; + let sample_rate = audio_system.sample_rate; + let channels = audio_system.channels; + let event_rx = audio_system.event_rx; + + // Wrap controller in Arc> for sharing with worker thread + let controller = std::sync::Arc::new(std::sync::Mutex::new(audio_system.controller)); + + // Spawn file operations worker + let file_command_tx = FileOperationsWorker::spawn(controller.clone()); + + (Some(stream), Some(controller), event_rx, sample_rate, channels, file_command_tx) + } + Err(e) => { + eprintln!("❌ Failed to initialize audio engine: {}", e); + eprintln!(" Playback will be disabled"); + + // Create a dummy channel for file operations (won't be used) + let (tx, _rx) = std::sync::mpsc::channel(); + (None, None, None, 48000, 2, tx) + } + }; Self { layouts, @@ -376,7 +562,11 @@ impl EditorApp { draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves rdp_tolerance: 10.0, // Default RDP tolerance schneider_max_error: 30.0, // Default Schneider max error - audio_system, + audio_stream, + audio_controller, + audio_event_rx, + audio_sample_rate, + audio_channels, layer_to_track_map: HashMap::new(), track_to_layer_map: HashMap::new(), playback_time: 0.0, // Start at beginning @@ -388,6 +578,9 @@ impl EditorApp { paint_bucket_gap_tolerance: 5.0, // Default gap tolerance polygon_sides: 5, // Default to pentagon midi_event_cache: HashMap::new(), // Initialize empty MIDI event cache + current_file_path: None, // No file loaded initially + file_command_tx, + file_operation: None, // No file operation in progress initially } } @@ -419,15 +612,16 @@ impl EditorApp { } // Create daw-backend MIDI track - if let Some(ref mut audio_system) = self.audio_system { - match audio_system.controller.create_midi_track_sync(layer_name.clone()) { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + match controller.create_midi_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); // Load default instrument - if let Err(e) = default_instrument::load_default_instrument(&mut audio_system.controller, track_id) { + if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); } else { println!("✅ Synced MIDI layer '{}' to backend (TrackId: {})", layer_name, track_id); @@ -449,6 +643,9 @@ impl EditorApp { fn switch_layout(&mut self, index: usize) { self.current_layout_index = index; self.current_layout = self.layouts[index].layout.clone(); + + // Clear pane instances so they rebuild with new layout + self.pane_instances.clear(); } fn current_layout_def(&self) -> &LayoutDefinition { @@ -488,23 +685,85 @@ impl EditorApp { // File menu MenuAction::NewFile => { println!("Menu: New File"); - // TODO: Implement new file + // TODO: Prompt to save current file if modified + + // Create new document + let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) + .with_duration(10.0) + .with_framerate(60.0); + + // Add default layer + use lightningbeam_core::layer::{AnyLayer, VectorLayer}; + let vector_layer = VectorLayer::new("Layer 1"); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Replace action executor with new document + self.action_executor = lightningbeam_core::action::ActionExecutor::new(document); + self.active_layer_id = Some(layer_id); + + // Reset audio project (send command to create new empty project) + // TODO: Add ResetProject command to EngineController + self.layer_to_track_map.clear(); + self.track_to_layer_map.clear(); + + // Clear file path + self.current_file_path = None; + println!("Created new file"); } MenuAction::NewWindow => { println!("Menu: New Window"); - // TODO: Implement new window + // TODO: Implement new window (requires multi-window support) } MenuAction::Save => { - println!("Menu: Save"); - // TODO: Implement save + use rfd::FileDialog; + + if let Some(path) = &self.current_file_path { + // Save to existing path + self.save_to_file(path.clone()); + } else { + // No current path, fall through to Save As + if let Some(path) = FileDialog::new() + .add_filter("Lightningbeam Project", &["beam"]) + .set_file_name("Untitled.beam") + .save_file() + { + self.save_to_file(path); + } + } } MenuAction::SaveAs => { - println!("Menu: Save As"); - // TODO: Implement save as + use rfd::FileDialog; + + let dialog = FileDialog::new() + .add_filter("Lightningbeam Project", &["beam"]) + .set_file_name("Untitled.beam"); + + // Set initial directory if we have a current file + let dialog = if let Some(current_path) = &self.current_file_path { + if let Some(parent) = current_path.parent() { + dialog.set_directory(parent) + } else { + dialog + } + } else { + dialog + }; + + if let Some(path) = dialog.save_file() { + self.save_to_file(path); + } } MenuAction::OpenFile => { - println!("Menu: Open File"); - // TODO: Implement open file + use rfd::FileDialog; + + // TODO: Prompt to save current file if modified + + if let Some(path) = FileDialog::new() + .add_filter("Lightningbeam Project", &["beam"]) + .pick_file() + { + self.load_from_file(path); + } } MenuAction::Revert => { println!("Menu: Revert"); @@ -607,9 +866,10 @@ impl EditorApp { // Edit menu MenuAction::Undo => { - if let Some(ref mut audio_system) = self.audio_system { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { - audio_controller: Some(&mut audio_system.controller), + audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; @@ -627,9 +887,10 @@ impl EditorApp { } } MenuAction::Redo => { - if let Some(ref mut audio_system) = self.audio_system { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { - audio_controller: Some(&mut audio_system.controller), + audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; @@ -727,15 +988,16 @@ impl EditorApp { self.active_layer_id = Some(layer_id); // Create corresponding daw-backend MIDI track - if let Some(ref mut audio_system) = self.audio_system { - match audio_system.controller.create_midi_track_sync(layer_name.clone()) { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + match controller.create_midi_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); // Load default instrument into the track - if let Err(e) = default_instrument::load_default_instrument(&mut audio_system.controller, track_id) { + if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); } else { println!("✅ Created {} (backend TrackId: {}, instrument: {})", @@ -893,6 +1155,170 @@ impl EditorApp { } } + /// Prepare document for saving by storing current UI layout + fn prepare_document_for_save(&mut self) { + let doc = self.action_executor.document_mut(); + + // Store current layout state + doc.ui_layout = Some(self.current_layout.clone()); + + // Store base layout name for reference + if self.current_layout_index < self.layouts.len() { + doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone()); + } + } + + /// Save the current document to a .beam file + fn save_to_file(&mut self, path: std::path::PathBuf) { + println!("Saving to: {}", path.display()); + + if self.audio_controller.is_none() { + eprintln!("❌ Audio system not initialized"); + return; + } + + // Prepare document for save (including layout) + self.prepare_document_for_save(); + + // Create progress channel + let (progress_tx, progress_rx) = std::sync::mpsc::channel(); + + // Clone document for background thread + let document = self.action_executor.document().clone(); + + // Send save command to worker thread + let command = FileCommand::Save { + path: path.clone(), + document, + progress_tx, + }; + + if let Err(e) = self.file_command_tx.send(command) { + eprintln!("❌ Failed to send save command: {}", e); + return; + } + + // Store operation state + self.file_operation = Some(FileOperation::Saving { + path, + progress_rx, + }); + } + + /// Load a document from a .beam file + fn load_from_file(&mut self, path: std::path::PathBuf) { + println!("Loading from: {}", path.display()); + + if self.audio_controller.is_none() { + eprintln!("❌ Audio system not initialized"); + return; + } + + // Create progress channel + let (progress_tx, progress_rx) = std::sync::mpsc::channel(); + + // Send load command to worker thread + let command = FileCommand::Load { + path: path.clone(), + progress_tx, + }; + + if let Err(e) = self.file_command_tx.send(command) { + eprintln!("❌ Failed to send load command: {}", e); + return; + } + + // Store operation state + self.file_operation = Some(FileOperation::Loading { + path, + progress_rx, + }); + } + + /// Restore UI layout from loaded document + fn restore_layout_from_document(&mut self) { + let doc = self.action_executor.document(); + + // Restore saved layout if present + if let Some(saved_layout) = &doc.ui_layout { + self.current_layout = saved_layout.clone(); + + // Try to find matching base layout by name + if let Some(base_name) = &doc.ui_layout_base { + if let Some(index) = self.layouts.iter().position(|l| &l.name == base_name) { + self.current_layout_index = index; + } else { + // Base layout not found (maybe renamed/removed), default to first + self.current_layout_index = 0; + } + } + + println!("✅ Restored UI layout from save file"); + } else { + // No saved layout (old file format or new project) + // Keep the default (first layout) + self.current_layout_index = 0; + self.current_layout = self.layouts[0].layout.clone(); + println!("ℹ️ No saved layout found, using default"); + } + + // Clear existing pane instances so they rebuild with new layout + self.pane_instances.clear(); + } + + /// Apply loaded project data (called after successful load in background) + fn apply_loaded_project(&mut self, loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) { + use lightningbeam_core::action::ActionExecutor; + + // Check for missing files + if !loaded_project.missing_files.is_empty() { + eprintln!("⚠️ {} missing files", loaded_project.missing_files.len()); + for missing in &loaded_project.missing_files { + eprintln!(" - {}", missing.original_path.display()); + } + // TODO Phase 5: Show recovery dialog + } + + // Replace document + self.action_executor = ActionExecutor::new(loaded_project.document); + + // Restore UI layout from loaded document + self.restore_layout_from_document(); + + // Set project in audio engine via query + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + if let Err(e) = controller.set_project(loaded_project.audio_project) { + eprintln!("❌ Failed to set project: {}", e); + return; + } + + // Load audio pool + if let Err(e) = controller.load_audio_pool( + loaded_project.audio_pool_entries, + &path, + ) { + eprintln!("❌ Failed to load audio pool: {}", e); + return; + } + } + + // Reset state + self.layer_to_track_map.clear(); + self.track_to_layer_map.clear(); + self.sync_midi_layers_to_backend(); + self.playback_time = 0.0; + self.is_playing = false; + self.current_file_path = Some(path.clone()); + + // Set active layer + if let Some(first) = self.action_executor.document().root.children.first() { + self.active_layer_id = Some(first.id()); + } + + println!("✅ Loaded from: {}", path.display()); + } + /// Import an image file as an ImageAsset fn import_image(&mut self, path: &std::path::Path) { use lightningbeam_core::clip::ImageAsset; @@ -949,10 +1375,11 @@ impl EditorApp { let sample_rate = audio_file.sample_rate; // Add to audio engine pool if available - if let Some(ref mut audio_system) = self.audio_system { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); // Send audio data to the engine let path_str = path.to_string_lossy().to_string(); - audio_system.controller.add_audio_file( + controller.add_audio_file( path_str.clone(), audio_file.data, channels, @@ -1011,8 +1438,9 @@ impl EditorApp { let note_event_count = processed_events.len(); // Add to backend MIDI clip pool FIRST and get the backend clip ID - if let Some(ref mut audio_system) = self.audio_system { - audio_system.controller.add_midi_clip_to_pool(midi_clip.clone()); + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.add_midi_clip_to_pool(midi_clip.clone()); let backend_clip_id = midi_clip.id; // The backend clip ID // Cache MIDI events in frontend for rendering (thumbnails & timeline piano roll) @@ -1076,10 +1504,104 @@ impl eframe::App for EditorApp { } } + // Handle file operation progress + if let Some(ref mut operation) = self.file_operation { + // Set wait cursor + ctx.set_cursor_icon(egui::CursorIcon::Progress); + + // Poll for progress updates + let mut operation_complete = false; + let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None; + + match operation { + FileOperation::Saving { ref mut progress_rx, ref path } => { + while let Ok(progress) = progress_rx.try_recv() { + match progress { + FileProgress::Done => { + println!("✅ Save complete!"); + self.current_file_path = Some(path.clone()); + operation_complete = true; + } + FileProgress::Error(e) => { + eprintln!("❌ Save error: {}", e); + operation_complete = true; + } + _ => { + // Other progress states - just keep going + } + } + } + + // Render progress dialog + egui::Window::new("Saving Project") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add(egui::Spinner::new()); + ui.add_space(8.0); + ui.label("Saving project..."); + ui.label(format!("Path: {}", path.display())); + }); + }); + } + FileOperation::Loading { ref mut progress_rx, ref path } => { + while let Ok(progress) = progress_rx.try_recv() { + match progress { + FileProgress::Complete(Ok(loaded_project)) => { + println!("✅ Load complete!"); + // Store data to apply after dialog closes + loaded_project_data = Some((loaded_project, path.clone())); + operation_complete = true; + } + FileProgress::Complete(Err(e)) => { + eprintln!("❌ Load error: {}", e); + operation_complete = true; + } + FileProgress::Error(e) => { + eprintln!("❌ Load error: {}", e); + operation_complete = true; + } + _ => { + // Other progress states - just keep going + } + } + } + + // Render progress dialog + egui::Window::new("Loading Project") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add(egui::Spinner::new()); + ui.add_space(8.0); + ui.label("Loading project..."); + ui.label(format!("Path: {}", path.display())); + }); + }); + } + } + + // Clear operation if complete + if operation_complete { + self.file_operation = None; + } + + // Apply loaded project data if available + if let Some((loaded_project, path)) = loaded_project_data { + self.apply_loaded_project(loaded_project, path); + } + + // Request repaint to keep updating progress + ctx.request_repaint(); + } + // Poll audio events from the audio engine - if let Some(audio_system) = &mut self.audio_system { - if let Some(event_rx) = &mut audio_system.event_rx { - while let Ok(event) = event_rx.pop() { + if let Some(event_rx) = &mut self.audio_event_rx { + while let Ok(event) = event_rx.pop() { use daw_backend::AudioEvent; match event { AudioEvent::PlaybackPosition(time) => { @@ -1091,7 +1613,6 @@ impl eframe::App for EditorApp { _ => {} // Ignore other events for now } } - } } // Request continuous repaints when playing to update time display @@ -1144,7 +1665,7 @@ impl eframe::App for EditorApp { draw_simplify_mode: &mut self.draw_simplify_mode, rdp_tolerance: &mut self.rdp_tolerance, schneider_max_error: &mut self.schneider_max_error, - audio_controller: self.audio_system.as_mut().map(|sys| &mut sys.controller), + audio_controller: self.audio_controller.as_ref(), playback_time: &mut self.playback_time, is_playing: &mut self.is_playing, dragging_asset: &mut self.dragging_asset, @@ -1189,9 +1710,10 @@ impl eframe::App for EditorApp { // Execute all pending actions (two-phase dispatch) for action in pending_actions { // Create backend context for actions that need backend sync - if let Some(ref mut audio_system) = self.audio_system { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { - audio_controller: Some(&mut audio_system.controller), + audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; @@ -1308,7 +1830,7 @@ struct RenderContext<'a> { draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, rdp_tolerance: &'a mut f64, schneider_max_error: &'a mut f64, - audio_controller: Option<&'a mut daw_backend::EngineController>, + audio_controller: Option<&'a std::sync::Arc>>, playback_time: &'a mut f64, is_playing: &'a mut bool, dragging_asset: &'a mut Option, @@ -1782,7 +2304,7 @@ fn render_pane( draw_simplify_mode: ctx.draw_simplify_mode, rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, - audio_controller: ctx.audio_controller.as_mut().map(|c| &mut **c), + audio_controller: ctx.audio_controller, layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, @@ -1836,7 +2358,7 @@ fn render_pane( draw_simplify_mode: ctx.draw_simplify_mode, rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, - audio_controller: ctx.audio_controller.as_mut().map(|c| &mut **c), + audio_controller: ctx.audio_controller, layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 44c9428..0e8a346 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1126,8 +1126,9 @@ impl AssetLibraryPane { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { - if let Some(audio_controller) = shared.audio_controller.as_mut() { - audio_controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) .ok() .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) } else { @@ -1397,8 +1398,9 @@ impl AssetLibraryPane { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { - if let Some(audio_controller) = shared.audio_controller.as_mut() { - audio_controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) .ok() .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) } else { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 5c7b51f..95e8142 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -107,8 +107,8 @@ pub struct SharedPaneState<'a> { pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, pub rdp_tolerance: &'a mut f64, pub schneider_max_error: &'a mut f64, - /// Audio engine controller for playback control - pub audio_controller: Option<&'a mut daw_backend::EngineController>, + /// Audio engine controller for playback control (wrapped in Arc> for thread safety) + pub audio_controller: Option<&'a std::sync::Arc>>, /// Mapping from Document layer UUIDs to daw-backend TrackIds pub layer_to_track_map: &'a std::collections::HashMap, /// Global playback state diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index b2ac36c..a4581a1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -975,7 +975,7 @@ impl TimelinePane { pending_actions: &mut Vec>, playback_time: &mut f64, is_playing: &mut bool, - audio_controller: Option<&mut daw_backend::EngineController>, + audio_controller: Option<&std::sync::Arc>>, ) { // Don't allocate the header area for input - let widgets handle it directly // Only allocate content area (ruler + layers) with click and drag @@ -1382,7 +1382,8 @@ impl TimelinePane { else if !response.dragged() && self.is_scrubbing { self.is_scrubbing = false; // Seek the audio engine to the new position - if let Some(controller) = audio_controller { + if let Some(controller_arc) = audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.seek(*playback_time); } } @@ -1492,7 +1493,8 @@ impl PaneRenderer for TimelinePane { // Go to start if ui.add_sized(button_size, egui::Button::new("|◀")).clicked() { *shared.playback_time = 0.0; - if let Some(controller) = shared.audio_controller.as_mut() { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.seek(0.0); } } @@ -1500,7 +1502,8 @@ impl PaneRenderer for TimelinePane { // Rewind (step backward) if ui.add_sized(button_size, egui::Button::new("◀◀")).clicked() { *shared.playback_time = (*shared.playback_time - 0.1).max(0.0); - if let Some(controller) = shared.audio_controller.as_mut() { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.seek(*shared.playback_time); } } @@ -1512,7 +1515,8 @@ impl PaneRenderer for TimelinePane { println!("🔘 Play/Pause button clicked! is_playing = {}", *shared.is_playing); // Send play/pause command to audio engine - if let Some(controller) = shared.audio_controller.as_mut() { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); if *shared.is_playing { controller.play(); println!("▶ Started playback"); @@ -1528,7 +1532,8 @@ impl PaneRenderer for TimelinePane { // Fast forward (step forward) if ui.add_sized(button_size, egui::Button::new("▶▶")).clicked() { *shared.playback_time = (*shared.playback_time + 0.1).min(self.duration); - if let Some(controller) = shared.audio_controller.as_mut() { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.seek(*shared.playback_time); } } @@ -1536,7 +1541,8 @@ impl PaneRenderer for TimelinePane { // Go to end if ui.add_sized(button_size, egui::Button::new("▶|")).clicked() { *shared.playback_time = self.duration; - if let Some(controller) = shared.audio_controller.as_mut() { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.seek(self.duration); } } @@ -1690,7 +1696,7 @@ impl PaneRenderer for TimelinePane { shared.pending_actions, shared.playback_time, shared.is_playing, - shared.audio_controller.as_mut().map(|c| &mut **c), + shared.audio_controller, ); // Handle asset drag-and-drop from Asset Library diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index f6e8af4..2c51049 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -358,7 +358,8 @@ impl VirtualPianoPane { if let Some(active_layer_id) = *shared.active_layer_id { // Look up daw-backend track ID from layer ID if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { - if let Some(ref mut controller) = shared.audio_controller { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.send_midi_note_on(track_id, note, velocity); } } @@ -380,7 +381,8 @@ impl VirtualPianoPane { if let Some(active_layer_id) = *shared.active_layer_id { if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { - if let Some(ref mut controller) = shared.audio_controller { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.send_midi_note_off(track_id, note); } } @@ -560,7 +562,8 @@ impl VirtualPianoPane { self.pressed_notes.remove(¬e); if let Some(active_layer_id) = *shared.active_layer_id { if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { - if let Some(ref mut controller) = shared.audio_controller { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.send_midi_note_off(track_id, note); } } @@ -573,7 +576,8 @@ impl VirtualPianoPane { self.pressed_notes.remove(note); if let Some(active_layer_id) = *shared.active_layer_id { if let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) { - if let Some(ref mut controller) = shared.audio_controller { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); controller.send_midi_note_off(track_id, *note); } }