File save/load
This commit is contained in:
parent
5379e3bc8c
commit
ba9a4ee812
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<crate::audio::project::Project, String> {
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MidiEvent>,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<MidiClipId, MidiClip>,
|
||||
next_id: MidiClipId,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,16 @@ pub struct GraphNode {
|
|||
pub midi_output_buffers: Vec<Vec<MidiEvent>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GraphNode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GraphNode")
|
||||
.field("node", &"<AudioNode>")
|
||||
.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<dyn AudioNode>, 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<GraphNode, Connection>,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<f32>, channels: u32, sample_rate: u32, original_format: Option<String>) -> 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() {
|
||||
|
|
|
|||
|
|
@ -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<TrackId, TrackNode>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<MidiClipInstance>,
|
||||
|
||||
/// Serialized instrument graph (used for save/load)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
instrument_graph_preset: Option<GraphPreset>,
|
||||
|
||||
/// 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<AutomationLaneId, AutomationLane>,
|
||||
next_automation_id: AutomationLaneId,
|
||||
/// Queue for live MIDI input (virtual keyboard, MIDI controllers)
|
||||
#[serde(skip)]
|
||||
live_midi_queue: Vec<MidiEvent>,
|
||||
}
|
||||
|
||||
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<AutomationLaneId, AutomationLane>,
|
||||
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<GraphPreset>,
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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<crate::audio::project::Project>),
|
||||
}
|
||||
|
||||
/// Oscilloscope data from a node
|
||||
|
|
@ -335,4 +339,8 @@ pub enum QueryResponse {
|
|||
MidiClipInstanceAdded(Result<MidiClipInstanceId, String>),
|
||||
/// Audio clip instance added (returns instance ID)
|
||||
AudioClipInstanceAdded(Result<AudioClipInstanceId, String>),
|
||||
/// Project retrieved
|
||||
ProjectRetrieved(Result<Box<crate::audio::project::Project>, String>),
|
||||
/// Project set
|
||||
ProjectSet(Result<(), String>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Uuid, ImageAsset>,
|
||||
|
||||
/// Current UI layout state (serialized for save/load)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_layout: Option<LayoutNode>,
|
||||
|
||||
/// Name of base layout this was derived from (for reference only)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_layout_base: Option<String>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AudioPoolEntry>,
|
||||
}
|
||||
|
||||
/// 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<AudioPoolEntry>,
|
||||
|
||||
/// List of files that couldn't be found
|
||||
pub missing_files: Vec<MissingFileInfo>,
|
||||
}
|
||||
|
||||
/// 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<AudioPoolEntry>,
|
||||
_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<f32> = 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<i32> = 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<LoadedProject, String> {
|
||||
// 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<MissingFileInfo> = 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -29,3 +29,4 @@ pub mod intersection_graph;
|
|||
pub mod segment_builder;
|
||||
pub mod planar_graph;
|
||||
pub mod file_types;
|
||||
pub mod file_io;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<FileProgress>,
|
||||
},
|
||||
Load {
|
||||
path: std::path::PathBuf,
|
||||
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Progress updates from file operations worker
|
||||
enum FileProgress {
|
||||
SerializingAudioPool,
|
||||
EncodingAudio { current: usize, total: usize },
|
||||
WritingZip,
|
||||
LoadingProject,
|
||||
DecodingAudio { current: usize, total: usize },
|
||||
Complete(Result<lightningbeam_core::file_io::LoadedProject, String>), // For loading
|
||||
Error(String),
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Active file operation state
|
||||
enum FileOperation {
|
||||
Saving {
|
||||
path: std::path::PathBuf,
|
||||
progress_rx: std::sync::mpsc::Receiver<FileProgress>,
|
||||
},
|
||||
Loading {
|
||||
path: std::path::PathBuf,
|
||||
progress_rx: std::sync::mpsc::Receiver<FileProgress>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Worker thread for file operations (save/load)
|
||||
struct FileOperationsWorker {
|
||||
command_rx: std::sync::mpsc::Receiver<FileCommand>,
|
||||
audio_controller: std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>,
|
||||
}
|
||||
|
||||
impl FileOperationsWorker {
|
||||
/// Create a new worker and spawn it on a background thread
|
||||
fn spawn(audio_controller: std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>)
|
||||
-> std::sync::mpsc::Sender<FileCommand>
|
||||
{
|
||||
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<FileProgress>,
|
||||
) {
|
||||
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<FileProgress>,
|
||||
) {
|
||||
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<LayoutDefinition>,
|
||||
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<daw_backend::AudioSystem>, // Audio system (must be kept alive for stream)
|
||||
audio_stream: Option<cpal::Stream>, // Audio stream (must be kept alive)
|
||||
audio_controller: Option<std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>, // Shared audio controller
|
||||
audio_event_rx: Option<rtrb::Consumer<daw_backend::AudioEvent>>, // 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<Uuid, daw_backend::TrackId>,
|
||||
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
|
||||
|
|
@ -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<u32, Vec<(f64, u8, bool)>>,
|
||||
/// Current file path (None if not yet saved)
|
||||
current_file_path: Option<std::path::PathBuf>,
|
||||
|
||||
/// File operations worker command sender
|
||||
file_command_tx: std::sync::mpsc::Sender<FileCommand>,
|
||||
/// Current file operation in progress (if any)
|
||||
file_operation: Option<FileOperation>,
|
||||
}
|
||||
|
||||
/// 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<Mutex<>> 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<std::sync::Mutex<daw_backend::EngineController>>>,
|
||||
playback_time: &'a mut f64,
|
||||
is_playing: &'a mut bool,
|
||||
dragging_asset: &'a mut Option<panes::DraggingAsset>,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Mutex<>> for thread safety)
|
||||
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
|
||||
/// Mapping from Document layer UUIDs to daw-backend TrackIds
|
||||
pub layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>,
|
||||
/// Global playback state
|
||||
|
|
|
|||
|
|
@ -975,7 +975,7 @@ impl TimelinePane {
|
|||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||
playback_time: &mut f64,
|
||||
is_playing: &mut bool,
|
||||
audio_controller: Option<&mut daw_backend::EngineController>,
|
||||
audio_controller: Option<&std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
|
||||
) {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue