Load audio clips
This commit is contained in:
parent
ba9a4ee812
commit
cffb61e5a8
|
|
@ -136,8 +136,8 @@ impl AudioFile {
|
|||
let peak_start = start_frame + peak_idx * frames_per_peak;
|
||||
let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
|
||||
|
||||
let mut min = 0.0f32;
|
||||
let mut max = 0.0f32;
|
||||
let mut min = f32::MAX;
|
||||
let mut max = f32::MIN;
|
||||
|
||||
// Scan all samples in this window
|
||||
for frame_idx in peak_start..peak_end {
|
||||
|
|
@ -152,6 +152,14 @@ impl AudioFile {
|
|||
}
|
||||
}
|
||||
|
||||
// If no samples were found, clamp to safe defaults
|
||||
if min == f32::MAX {
|
||||
min = 0.0;
|
||||
}
|
||||
if max == f32::MIN {
|
||||
max = 0.0;
|
||||
}
|
||||
|
||||
peaks.push(crate::io::WaveformPeak { min, max });
|
||||
}
|
||||
|
||||
|
|
@ -549,13 +557,18 @@ impl AudioClipPool {
|
|||
entries: Vec<AudioPoolEntry>,
|
||||
project_path: &Path,
|
||||
) -> Result<Vec<usize>, String> {
|
||||
let fn_start = std::time::Instant::now();
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Starting load_from_serialized with {} entries...", entries.len());
|
||||
|
||||
let project_dir = project_path.parent()
|
||||
.ok_or_else(|| "Project path has no parent directory".to_string())?;
|
||||
|
||||
let mut missing_indices = Vec::new();
|
||||
|
||||
// Clear existing pool
|
||||
let clear_start = std::time::Instant::now();
|
||||
self.files.clear();
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Clear pool took {:.2}ms", clear_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Find the maximum pool index to determine required size
|
||||
let max_index = entries.iter()
|
||||
|
|
@ -564,12 +577,18 @@ impl AudioClipPool {
|
|||
.unwrap_or(0);
|
||||
|
||||
// Ensure we have space for all entries
|
||||
let resize_start = std::time::Instant::now();
|
||||
self.files.resize(max_index + 1, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100));
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", max_index + 1, resize_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
for entry in entries {
|
||||
let success = if let Some(embedded) = entry.embedded_data {
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let entry_start = std::time::Instant::now();
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Processing entry {}/{}: '{}'", i + 1, entries.len(), entry.name);
|
||||
|
||||
let success = if let Some(ref embedded) = entry.embedded_data {
|
||||
// Load from embedded data
|
||||
match Self::load_from_embedded_into_pool(self, entry.pool_index, embedded, &entry.name) {
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Entry has embedded data (format: {})", embedded.format);
|
||||
match Self::load_from_embedded_into_pool(self, entry.pool_index, embedded.clone(), &entry.name) {
|
||||
Ok(_) => {
|
||||
eprintln!("[AudioPool] Successfully loaded embedded audio: {}", entry.name);
|
||||
true
|
||||
|
|
@ -579,8 +598,9 @@ impl AudioClipPool {
|
|||
false
|
||||
}
|
||||
}
|
||||
} else if let Some(rel_path) = entry.relative_path {
|
||||
} else if let Some(ref rel_path) = entry.relative_path {
|
||||
// Load from file path
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Entry has file path: {:?}", rel_path);
|
||||
let full_path = project_dir.join(&rel_path);
|
||||
|
||||
if full_path.exists() {
|
||||
|
|
@ -597,8 +617,12 @@ impl AudioClipPool {
|
|||
if !success {
|
||||
missing_indices.push(entry.pool_index);
|
||||
}
|
||||
|
||||
eprintln!("📊 [LOAD_SERIALIZED] Entry {} took {:.2}ms (success: {})", i + 1, entry_start.elapsed().as_secs_f64() * 1000.0, success);
|
||||
}
|
||||
|
||||
eprintln!("📊 [LOAD_SERIALIZED] ✅ Total load_from_serialized time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
Ok(missing_indices)
|
||||
}
|
||||
|
||||
|
|
@ -611,20 +635,29 @@ impl AudioClipPool {
|
|||
) -> Result<(), String> {
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
let fn_start = std::time::Instant::now();
|
||||
eprintln!("📊 [POOL] Loading embedded audio '{}'...", name);
|
||||
|
||||
// Decode base64
|
||||
let step1_start = std::time::Instant::now();
|
||||
let data = general_purpose::STANDARD
|
||||
.decode(&embedded.data_base64)
|
||||
.map_err(|e| format!("Failed to decode base64: {}", e))?;
|
||||
eprintln!("📊 [POOL] Step 1: Decode base64 ({} bytes) took {:.2}ms", data.len(), step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Write to temporary file for symphonia to decode
|
||||
let step2_start = std::time::Instant::now();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_path = temp_dir.join(format!("lightningbeam_embedded_{}.{}", pool_index, embedded.format));
|
||||
|
||||
std::fs::write(&temp_path, &data)
|
||||
.map_err(|e| format!("Failed to write temporary file: {}", e))?;
|
||||
eprintln!("📊 [POOL] Step 2: Write temp file took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Load the temporary file using existing infrastructure
|
||||
let step3_start = std::time::Instant::now();
|
||||
let result = Self::load_file_into_pool(self, pool_index, &temp_path);
|
||||
eprintln!("📊 [POOL] Step 3: Decode audio with Symphonia took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Clean up temporary file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
|
|
@ -634,6 +667,8 @@ impl AudioClipPool {
|
|||
self.files[pool_index].path = PathBuf::from(format!("<embedded: {}>", name));
|
||||
}
|
||||
|
||||
eprintln!("📊 [POOL] ✅ Total load_from_embedded time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -139,70 +139,163 @@ pub fn save_beam(
|
|||
audio_pool_entries: Vec<AudioPoolEntry>,
|
||||
_settings: &SaveSettings,
|
||||
) -> Result<(), String> {
|
||||
// 1. Create backup if file exists
|
||||
if path.exists() {
|
||||
let fn_start = std::time::Instant::now();
|
||||
eprintln!("📊 [SAVE_BEAM] Starting save_beam()...");
|
||||
|
||||
// 1. Create backup if file exists and open it for reading old audio files
|
||||
let step1_start = std::time::Instant::now();
|
||||
let mut old_zip = 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))?;
|
||||
|
||||
// Open the backup as a ZIP archive for reading
|
||||
match File::open(&backup_path) {
|
||||
Ok(file) => match ZipArchive::new(file) {
|
||||
Ok(archive) => {
|
||||
eprintln!("📊 [SAVE_BEAM] Step 1: Create backup and open for reading took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
Some(archive)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ [SAVE_BEAM] Failed to open backup as ZIP: {}, will not copy old audio files", e);
|
||||
eprintln!("📊 [SAVE_BEAM] Step 1: Create backup took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ [SAVE_BEAM] Failed to open backup: {}, will not copy old audio files", e);
|
||||
eprintln!("📊 [SAVE_BEAM] Step 1: Create backup took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("📊 [SAVE_BEAM] Step 1: No backup needed (new file)");
|
||||
None
|
||||
};
|
||||
|
||||
// 2. Prepare audio project for serialization (save AudioGraph presets)
|
||||
let step2_start = std::time::Instant::now();
|
||||
audio_project.prepare_for_save();
|
||||
eprintln!("📊 [SAVE_BEAM] Step 2: Prepare audio project took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 3. Create ZIP writer
|
||||
let step3_start = std::time::Instant::now();
|
||||
let file = File::create(path)
|
||||
.map_err(|e| format!("Failed to create file: {}", e))?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
eprintln!("📊 [SAVE_BEAM] Step 3: Create ZIP writer took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 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
|
||||
// Priority: old ZIP file > external file > encode PCM as FLAC
|
||||
let step4_start = std::time::Instant::now();
|
||||
let mut modified_entries = Vec::new();
|
||||
let mut flac_encode_time = 0.0;
|
||||
let mut zip_write_time = 0.0;
|
||||
let project_dir = path.parent().unwrap_or_else(|| Path::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)
|
||||
// Try to get audio data from various sources (in priority order)
|
||||
let audio_source: Option<(Vec<u8>, String)> = if let Some(ref rel_path) = entry.relative_path {
|
||||
// Priority 1: Check if file is in the old ZIP
|
||||
if rel_path.starts_with("media/audio/") {
|
||||
if let Some(ref mut old_zip_archive) = old_zip {
|
||||
match old_zip_archive.by_name(rel_path) {
|
||||
Ok(mut file) => {
|
||||
let mut bytes = Vec::new();
|
||||
if file.read_to_end(&mut bytes).is_ok() {
|
||||
let extension = rel_path.split('.').last().unwrap_or("bin").to_string();
|
||||
eprintln!("📊 [SAVE_BEAM] Copying from old ZIP: {}", rel_path);
|
||||
Some((bytes, extension))
|
||||
} else {
|
||||
// Store lossless data as FLAC
|
||||
format!("media/audio/{}.flac", entry.pool_index)
|
||||
eprintln!("⚠️ [SAVE_BEAM] Failed to read {} from old ZIP", rel_path);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("⚠️ [SAVE_BEAM] File {} not found in old ZIP", rel_path);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// Priority 2: Check external filesystem
|
||||
else {
|
||||
let full_path = project_dir.join(rel_path);
|
||||
if full_path.exists() {
|
||||
match std::fs::read(&full_path) {
|
||||
Ok(bytes) => {
|
||||
let extension = full_path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin")
|
||||
.to_string();
|
||||
eprintln!("📊 [SAVE_BEAM] Using external file: {:?}", full_path);
|
||||
Some((bytes, extension))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ [SAVE_BEAM] Failed to read {:?}: {}", full_path, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("⚠️ [SAVE_BEAM] External file not found: {:?}", full_path);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Write to ZIP (uncompressed - audio is already compressed)
|
||||
if let Some((audio_bytes, extension)) = audio_source {
|
||||
// We have the original file - copy it directly
|
||||
let zip_filename = format!("media/audio/{}.{}", entry.pool_index, extension);
|
||||
|
||||
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
|
||||
let write_start = std::time::Instant::now();
|
||||
zip.write_all(&audio_bytes)
|
||||
.map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?;
|
||||
} else {
|
||||
// Decode PCM samples and encode to FLAC
|
||||
zip_write_time += write_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Update entry to point to ZIP file
|
||||
modified_entry.embedded_data = None;
|
||||
modified_entry.relative_path = Some(zip_filename);
|
||||
|
||||
} else if let Some(ref embedded_data) = entry.embedded_data {
|
||||
// Priority 3: No original file - encode PCM as FLAC
|
||||
eprintln!("📊 [SAVE_BEAM] Encoding PCM to FLAC for pool {} (no original file)", entry.pool_index);
|
||||
// Embedded data is always PCM - encode as FLAC
|
||||
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 zip_filename = format!("media/audio/{}.flac", entry.pool_index);
|
||||
|
||||
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))?;
|
||||
|
||||
// Encode PCM samples to FLAC
|
||||
let flac_start = std::time::Instant::now();
|
||||
|
||||
// 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]
|
||||
// Convert f32 samples to i32 for FLAC encoding
|
||||
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
|
||||
})
|
||||
|
|
@ -216,7 +309,7 @@ pub fn save_beam(
|
|||
let source = flacenc::source::MemSource::from_samples(
|
||||
&samples_i32,
|
||||
entry.channels as usize,
|
||||
24, // bits per sample (FLAC max is 24-bit)
|
||||
24,
|
||||
entry.sample_rate as usize,
|
||||
);
|
||||
|
||||
|
|
@ -234,9 +327,12 @@ pub fn save_beam(
|
|||
.map_err(|e| format!("Failed to write FLAC stream: {:?}", e))?;
|
||||
let flac_bytes = sink.as_slice();
|
||||
|
||||
flac_encode_time += flac_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let write_start = std::time::Instant::now();
|
||||
zip.write_all(flac_bytes)
|
||||
.map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?;
|
||||
}
|
||||
zip_write_time += write_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Update entry to point to ZIP file instead of embedding data
|
||||
modified_entry.embedded_data = None;
|
||||
|
|
@ -245,8 +341,17 @@ pub fn save_beam(
|
|||
|
||||
modified_entries.push(modified_entry);
|
||||
}
|
||||
eprintln!("📊 [SAVE_BEAM] Step 4: Process audio pool ({} entries) took {:.2}ms",
|
||||
audio_pool_entries.len(), step4_start.elapsed().as_secs_f64() * 1000.0);
|
||||
if flac_encode_time > 0.0 {
|
||||
eprintln!("📊 [SAVE_BEAM] - FLAC encoding: {:.2}ms", flac_encode_time);
|
||||
}
|
||||
if zip_write_time > 0.0 {
|
||||
eprintln!("📊 [SAVE_BEAM] - ZIP writing: {:.2}ms", zip_write_time);
|
||||
}
|
||||
|
||||
// 5. Build BeamProject structure with modified entries
|
||||
let step5_start = std::time::Instant::now();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let beam_project = BeamProject {
|
||||
version: BEAM_VERSION.to_string(),
|
||||
|
|
@ -259,8 +364,10 @@ pub fn save_beam(
|
|||
audio_pool_entries: modified_entries,
|
||||
},
|
||||
};
|
||||
eprintln!("📊 [SAVE_BEAM] Step 5: Build BeamProject structure took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 6. Write project.json (compressed with DEFLATE)
|
||||
let step6_start = std::time::Instant::now();
|
||||
let json_options = FileOptions::default()
|
||||
.compression_method(CompressionMethod::Deflated)
|
||||
.compression_level(Some(6));
|
||||
|
|
@ -273,10 +380,15 @@ pub fn save_beam(
|
|||
|
||||
zip.write_all(json.as_bytes())
|
||||
.map_err(|e| format!("Failed to write project.json: {}", e))?;
|
||||
eprintln!("📊 [SAVE_BEAM] Step 6: Write project.json ({} bytes) took {:.2}ms", json.len(), step6_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 7. Finalize ZIP
|
||||
let step7_start = std::time::Instant::now();
|
||||
zip.finish()
|
||||
.map_err(|e| format!("Failed to finalize ZIP: {}", e))?;
|
||||
eprintln!("📊 [SAVE_BEAM] Step 7: Finalize ZIP took {:.2}ms", step7_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
eprintln!("📊 [SAVE_BEAM] ✅ Total save_beam() time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -296,23 +408,32 @@ pub fn save_beam(
|
|||
/// # Returns
|
||||
/// LoadedProject on success (with missing_files list), or error message
|
||||
pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
||||
let fn_start = std::time::Instant::now();
|
||||
eprintln!("📊 [LOAD_BEAM] Starting load_beam()...");
|
||||
|
||||
// 1. Open ZIP archive
|
||||
let step1_start = std::time::Instant::now();
|
||||
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))?;
|
||||
eprintln!("📊 [LOAD_BEAM] Step 1: Open ZIP archive took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 2. Read project.json
|
||||
let step2_start = std::time::Instant::now();
|
||||
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))?;
|
||||
eprintln!("📊 [LOAD_BEAM] Step 2: Read project.json ({} bytes) took {:.2}ms", json_data.len(), step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 3. Deserialize BeamProject
|
||||
let step3_start = std::time::Instant::now();
|
||||
let beam_project: BeamProject = serde_json::from_str(&json_data)
|
||||
.map_err(|e| format!("Failed to deserialize project.json: {}", e))?;
|
||||
eprintln!("📊 [LOAD_BEAM] Step 3: Deserialize BeamProject took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 4. Check version compatibility
|
||||
if beam_project.version != BEAM_VERSION {
|
||||
|
|
@ -323,17 +444,23 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
}
|
||||
|
||||
// 5. Extract document and audio backend state
|
||||
let step5_start = std::time::Instant::now();
|
||||
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;
|
||||
eprintln!("📊 [LOAD_BEAM] Step 5: Extract document and audio state took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 6. Rebuild AudioGraphs from presets
|
||||
let step6_start = std::time::Instant::now();
|
||||
audio_project.rebuild_audio_graphs(DEFAULT_BUFFER_SIZE)
|
||||
.map_err(|e| format!("Failed to rebuild audio graphs: {}", e))?;
|
||||
eprintln!("📊 [LOAD_BEAM] Step 6: Rebuild AudioGraphs took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 7. Extract embedded audio files from ZIP and restore to entries
|
||||
let step7_start = std::time::Instant::now();
|
||||
drop(project_file); // Close project.json file handle
|
||||
let mut restored_entries = Vec::new();
|
||||
let mut flac_decode_time = 0.0;
|
||||
|
||||
for entry in &audio_pool_entries {
|
||||
let mut restored_entry = entry.clone();
|
||||
|
|
@ -357,6 +484,8 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
// For lossy formats, store the original bytes
|
||||
let embedded_data = if format == "flac" {
|
||||
// Decode FLAC to PCM f32 samples
|
||||
let flac_decode_start = std::time::Instant::now();
|
||||
|
||||
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))?;
|
||||
|
|
@ -379,6 +508,8 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
pcm_bytes.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
|
||||
flac_decode_time += flac_decode_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
Some(daw_backend::audio::pool::EmbeddedAudioData {
|
||||
data_base64: base64::encode(&pcm_bytes),
|
||||
format: "wav".to_string(), // Mark as WAV since it's now PCM
|
||||
|
|
@ -403,10 +534,16 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
|
||||
restored_entries.push(restored_entry);
|
||||
}
|
||||
eprintln!("📊 [LOAD_BEAM] Step 7: Extract embedded audio ({} entries) took {:.2}ms",
|
||||
audio_pool_entries.len(), step7_start.elapsed().as_secs_f64() * 1000.0);
|
||||
if flac_decode_time > 0.0 {
|
||||
eprintln!("📊 [LOAD_BEAM] - FLAC decoding: {:.2}ms", flac_decode_time);
|
||||
}
|
||||
|
||||
// 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 step8_start = std::time::Instant::now();
|
||||
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let missing_files: Vec<MissingFileInfo> = restored_entries
|
||||
.iter()
|
||||
|
|
@ -428,6 +565,9 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
None
|
||||
})
|
||||
.collect();
|
||||
eprintln!("📊 [LOAD_BEAM] Step 8: Check missing files took {:.2}ms", step8_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
eprintln!("📊 [LOAD_BEAM] ✅ Total load_beam() time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
Ok(LoadedProject {
|
||||
document,
|
||||
|
|
|
|||
|
|
@ -330,8 +330,12 @@ impl FileOperationsWorker {
|
|||
) {
|
||||
use lightningbeam_core::file_io::{save_beam, SaveSettings};
|
||||
|
||||
let save_start = std::time::Instant::now();
|
||||
eprintln!("📊 [SAVE] Starting save operation...");
|
||||
|
||||
// Step 1: Serialize audio pool
|
||||
let _ = progress_tx.send(FileProgress::SerializingAudioPool);
|
||||
let step1_start = std::time::Instant::now();
|
||||
|
||||
let audio_pool_entries = {
|
||||
let mut controller = self.audio_controller.lock().unwrap();
|
||||
|
|
@ -343,8 +347,10 @@ impl FileOperationsWorker {
|
|||
}
|
||||
}
|
||||
};
|
||||
eprintln!("📊 [SAVE] Step 1: Serialize audio pool took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Step 2: Get project
|
||||
let step2_start = std::time::Instant::now();
|
||||
let mut audio_project = {
|
||||
let mut controller = self.audio_controller.lock().unwrap();
|
||||
match controller.get_project() {
|
||||
|
|
@ -355,13 +361,17 @@ impl FileOperationsWorker {
|
|||
}
|
||||
}
|
||||
};
|
||||
eprintln!("📊 [SAVE] Step 2: Get audio project took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Step 3: Save to file
|
||||
let _ = progress_tx.send(FileProgress::WritingZip);
|
||||
let step3_start = std::time::Instant::now();
|
||||
|
||||
let settings = SaveSettings::default();
|
||||
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, &settings) {
|
||||
Ok(()) => {
|
||||
eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||
eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("✅ Saved to: {}", path.display());
|
||||
let _ = progress_tx.send(FileProgress::Done);
|
||||
}
|
||||
|
|
@ -379,8 +389,12 @@ impl FileOperationsWorker {
|
|||
) {
|
||||
use lightningbeam_core::file_io::load_beam;
|
||||
|
||||
let load_start = std::time::Instant::now();
|
||||
eprintln!("📊 [LOAD] Starting load operation...");
|
||||
|
||||
// Step 1: Load from file
|
||||
let _ = progress_tx.send(FileProgress::LoadingProject);
|
||||
let step1_start = std::time::Instant::now();
|
||||
|
||||
let loaded_project = match load_beam(&path) {
|
||||
Ok(p) => p,
|
||||
|
|
@ -389,6 +403,7 @@ impl FileOperationsWorker {
|
|||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("📊 [LOAD] Step 1: load_beam() took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Check for missing files
|
||||
if !loaded_project.missing_files.is_empty() {
|
||||
|
|
@ -398,6 +413,8 @@ impl FileOperationsWorker {
|
|||
}
|
||||
}
|
||||
|
||||
eprintln!("📊 [LOAD] ✅ Total load time: {:.2}ms", load_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Send the loaded project back to UI thread for processing
|
||||
let _ = progress_tx.send(FileProgress::Complete(Ok(loaded_project)));
|
||||
}
|
||||
|
|
@ -455,6 +472,10 @@ 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)>>,
|
||||
/// Cache for audio waveform data (keyed by audio_pool_index)
|
||||
/// Prevents repeated backend queries for the same audio file
|
||||
/// Format: Vec of WaveformPeak (min/max pairs)
|
||||
waveform_cache: HashMap<usize, Vec<daw_backend::WaveformPeak>>,
|
||||
/// Current file path (None if not yet saved)
|
||||
current_file_path: Option<std::path::PathBuf>,
|
||||
|
||||
|
|
@ -578,6 +599,7 @@ 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
|
||||
waveform_cache: HashMap::new(), // Initialize empty waveform cache
|
||||
current_file_path: None, // No file loaded initially
|
||||
file_command_tx,
|
||||
file_operation: None, // No file operation in progress initially
|
||||
|
|
@ -590,19 +612,18 @@ impl EditorApp {
|
|||
/// - After loading a document from file
|
||||
/// - After creating a new document with pre-existing MIDI layers
|
||||
///
|
||||
/// For each MIDI audio layer:
|
||||
/// 1. Creates a daw-backend MIDI track
|
||||
/// 2. Loads the default instrument
|
||||
/// For each audio layer (MIDI or Sampled):
|
||||
/// 1. Creates a daw-backend track (MIDI or Audio)
|
||||
/// 2. For MIDI: Loads the default instrument
|
||||
/// 3. Stores the bidirectional mapping
|
||||
/// 4. Syncs any existing clips on the layer
|
||||
fn sync_midi_layers_to_backend(&mut self) {
|
||||
fn sync_audio_layers_to_backend(&mut self) {
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||
|
||||
// Iterate through all layers in the document
|
||||
for layer in &self.action_executor.document().root.children {
|
||||
// Only process Audio layers with MIDI type
|
||||
// Only process Audio layers
|
||||
if let AnyLayer::Audio(audio_layer) = layer {
|
||||
if audio_layer.audio_layer_type == AudioLayerType::Midi {
|
||||
let layer_id = audio_layer.layer.id;
|
||||
let layer_name = &audio_layer.layer.name;
|
||||
|
||||
|
|
@ -611,6 +632,9 @@ impl EditorApp {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Handle both MIDI and Sampled audio tracks
|
||||
match audio_layer.audio_layer_type {
|
||||
AudioLayerType::Midi => {
|
||||
// Create daw-backend MIDI track
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
|
|
@ -636,7 +660,56 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
AudioLayerType::Sampled => {
|
||||
// Create daw-backend Audio track
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.create_audio_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);
|
||||
println!("✅ Synced Audio layer '{}' to backend (TrackId: {})", layer_name, track_id);
|
||||
|
||||
// TODO: Sync any existing clips on this layer to the backend
|
||||
// This will be implemented when we add clip synchronization
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to create daw-backend audio track for '{}': {}", layer_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch waveform data from backend for a specific audio pool index
|
||||
/// Returns cached data if available, otherwise queries backend
|
||||
fn fetch_waveform(&mut self, pool_index: usize) -> Option<Vec<daw_backend::WaveformPeak>> {
|
||||
// Check if already cached
|
||||
if let Some(waveform) = self.waveform_cache.get(&pool_index) {
|
||||
return Some(waveform.clone());
|
||||
}
|
||||
|
||||
// Fetch from backend
|
||||
// Request 20,000 peaks for high-detail waveform visualization
|
||||
// For a 200s file, this gives ~100 peaks/second, providing smooth visualization at all zoom levels
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.get_pool_waveform(pool_index, 20000) {
|
||||
Ok(waveform) => {
|
||||
self.waveform_cache.insert(pool_index, waveform.clone());
|
||||
Some(waveform)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to fetch waveform for pool index {}: {}", pool_index, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -969,8 +1042,37 @@ impl EditorApp {
|
|||
// TODO: Implement add video layer
|
||||
}
|
||||
MenuAction::AddAudioTrack => {
|
||||
println!("Menu: Add Audio Track");
|
||||
// TODO: Implement add audio track
|
||||
// Create a new sampled audio layer with a default name
|
||||
let layer_count = self.action_executor.document().root.children.len();
|
||||
let layer_name = format!("Audio Track {}", layer_count + 1);
|
||||
|
||||
// Create audio layer in document
|
||||
let audio_layer = AudioLayer::new_sampled(layer_name.clone());
|
||||
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(audio_layer));
|
||||
self.action_executor.execute(Box::new(action));
|
||||
|
||||
// Get the newly created layer ID
|
||||
if let Some(last_layer) = self.action_executor.document().root.children.last() {
|
||||
let layer_id = last_layer.id();
|
||||
self.active_layer_id = Some(layer_id);
|
||||
|
||||
// Create corresponding daw-backend audio track
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.create_audio_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);
|
||||
println!("✅ Created {} (backend TrackId: {})", layer_name, track_id);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to create daw-backend audio track for {}: {}", layer_name, e);
|
||||
eprintln!(" Layer created but will be silent until backend track is available");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::AddMidiTrack => {
|
||||
// Create a new MIDI audio layer with a default name
|
||||
|
|
@ -1270,6 +1372,9 @@ impl EditorApp {
|
|||
fn apply_loaded_project(&mut self, loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) {
|
||||
use lightningbeam_core::action::ActionExecutor;
|
||||
|
||||
let apply_start = std::time::Instant::now();
|
||||
eprintln!("📊 [APPLY] Starting apply_loaded_project() on UI thread...");
|
||||
|
||||
// Check for missing files
|
||||
if !loaded_project.missing_files.is_empty() {
|
||||
eprintln!("⚠️ {} missing files", loaded_project.missing_files.len());
|
||||
|
|
@ -1280,33 +1385,78 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
// Replace document
|
||||
let step1_start = std::time::Instant::now();
|
||||
self.action_executor = ActionExecutor::new(loaded_project.document);
|
||||
eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Restore UI layout from loaded document
|
||||
let step2_start = std::time::Instant::now();
|
||||
self.restore_layout_from_document();
|
||||
eprintln!("📊 [APPLY] Step 2: Restore UI layout took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Set project in audio engine via query
|
||||
let step3_start = std::time::Instant::now();
|
||||
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;
|
||||
}
|
||||
eprintln!("📊 [APPLY] Step 3: Set audio project took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Load audio pool
|
||||
if let Err(e) = controller.load_audio_pool(
|
||||
loaded_project.audio_pool_entries,
|
||||
&path,
|
||||
) {
|
||||
// Load audio pool asynchronously to avoid blocking UI
|
||||
let step4_start = std::time::Instant::now();
|
||||
let controller_clone = controller_arc.clone();
|
||||
let path_clone = path.clone();
|
||||
let audio_pool_entries = loaded_project.audio_pool_entries;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
eprintln!("📊 [APPLY] Step 4: Starting async audio pool load...");
|
||||
let load_start = std::time::Instant::now();
|
||||
let mut controller = controller_clone.lock().unwrap();
|
||||
if let Err(e) = controller.load_audio_pool(audio_pool_entries, &path_clone) {
|
||||
eprintln!("❌ Failed to load audio pool: {}", e);
|
||||
return;
|
||||
} else {
|
||||
eprintln!("📊 [APPLY] Step 4: Async audio pool load completed in {:.2}ms", load_start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
eprintln!("📊 [APPLY] Step 4: Spawned async audio pool load in {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
let step5_start = std::time::Instant::now();
|
||||
self.layer_to_track_map.clear();
|
||||
self.track_to_layer_map.clear();
|
||||
self.sync_midi_layers_to_backend();
|
||||
eprintln!("📊 [APPLY] Step 5: Clear track maps took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Sync audio layers (MIDI and Sampled)
|
||||
let step6_start = std::time::Instant::now();
|
||||
self.sync_audio_layers_to_backend();
|
||||
eprintln!("📊 [APPLY] Step 6: Sync audio layers took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Fetch waveforms for all audio clips in the loaded project
|
||||
let step7_start = std::time::Instant::now();
|
||||
// Collect pool indices first to avoid borrowing issues
|
||||
let pool_indices: Vec<usize> = self.action_executor.document()
|
||||
.audio_clips.values()
|
||||
.filter_map(|clip| {
|
||||
if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
||||
Some(*audio_pool_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut waveforms_fetched = 0;
|
||||
for pool_index in pool_indices {
|
||||
if self.fetch_waveform(pool_index).is_some() {
|
||||
waveforms_fetched += 1;
|
||||
}
|
||||
}
|
||||
eprintln!("📊 [APPLY] Step 7: Fetched {} waveforms in {:.2}ms", waveforms_fetched, step7_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Reset playback state
|
||||
self.playback_time = 0.0;
|
||||
self.is_playing = false;
|
||||
self.current_file_path = Some(path.clone());
|
||||
|
|
@ -1316,6 +1466,7 @@ impl EditorApp {
|
|||
self.active_layer_id = Some(first.id());
|
||||
}
|
||||
|
||||
eprintln!("📊 [APPLY] ✅ Total apply_loaded_project() time: {:.2}ms", apply_start.elapsed().as_secs_f64() * 1000.0);
|
||||
println!("✅ Loaded from: {}", path.display());
|
||||
}
|
||||
|
||||
|
|
@ -1376,6 +1527,7 @@ impl EditorApp {
|
|||
|
||||
// Add to audio engine pool if available
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let pool_index = {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
// Send audio data to the engine
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
|
@ -1388,11 +1540,18 @@ impl EditorApp {
|
|||
|
||||
// For now, use a placeholder pool index (the engine will assign the real one)
|
||||
// In a full implementation, we'd wait for the AudioFileAdded event
|
||||
let pool_index = self.action_executor.document().audio_clips.len();
|
||||
self.action_executor.document().audio_clips.len()
|
||||
}; // Controller lock is dropped here
|
||||
|
||||
// Create audio clip in document
|
||||
let clip = AudioClip::new_sampled(&name, pool_index, duration);
|
||||
let clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
||||
|
||||
// Fetch waveform from backend and cache it for rendering
|
||||
if let Some(waveform) = self.fetch_waveform(pool_index) {
|
||||
println!("✅ Cached waveform with {} peaks", waveform.len());
|
||||
}
|
||||
|
||||
println!("Imported audio '{}' ({:.1}s, {}ch, {}Hz) - ID: {}",
|
||||
name, duration, channels, sample_rate, clip_id);
|
||||
} else {
|
||||
|
|
@ -1504,6 +1663,29 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch missing waveforms on-demand (for lazy loading after project load)
|
||||
// Collect pool indices that need waveforms
|
||||
let missing_waveforms: Vec<usize> = self.action_executor.document()
|
||||
.audio_clips.values()
|
||||
.filter_map(|clip| {
|
||||
if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
||||
// Check if not already cached
|
||||
if !self.waveform_cache.contains_key(audio_pool_index) {
|
||||
Some(*audio_pool_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fetch missing waveforms
|
||||
for pool_index in missing_waveforms {
|
||||
self.fetch_waveform(pool_index);
|
||||
}
|
||||
|
||||
// Handle file operation progress
|
||||
if let Some(ref mut operation) = self.file_operation {
|
||||
// Set wait cursor
|
||||
|
|
@ -1675,6 +1857,7 @@ impl eframe::App for EditorApp {
|
|||
polygon_sides: &mut self.polygon_sides,
|
||||
layer_to_track_map: &self.layer_to_track_map,
|
||||
midi_event_cache: &self.midi_event_cache,
|
||||
waveform_cache: &self.waveform_cache,
|
||||
};
|
||||
|
||||
render_layout_node(
|
||||
|
|
@ -1843,6 +2026,8 @@ struct RenderContext<'a> {
|
|||
layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>,
|
||||
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
||||
midi_event_cache: &'a HashMap<u32, Vec<(f64, u8, bool)>>,
|
||||
/// Cache of waveform data for rendering (keyed by audio_pool_index)
|
||||
waveform_cache: &'a HashMap<usize, Vec<daw_backend::WaveformPeak>>,
|
||||
}
|
||||
|
||||
/// Recursively render a layout node with drag support
|
||||
|
|
@ -2314,6 +2499,7 @@ fn render_pane(
|
|||
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
|
||||
polygon_sides: ctx.polygon_sides,
|
||||
midi_event_cache: ctx.midi_event_cache,
|
||||
waveform_cache: ctx.waveform_cache,
|
||||
};
|
||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||
}
|
||||
|
|
@ -2368,6 +2554,7 @@ fn render_pane(
|
|||
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
|
||||
polygon_sides: ctx.polygon_sides,
|
||||
midi_event_cache: ctx.midi_event_cache,
|
||||
waveform_cache: ctx.waveform_cache,
|
||||
};
|
||||
|
||||
// Render pane content (header was already rendered above)
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub polygon_sides: &'a mut u32,
|
||||
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
||||
pub midi_event_cache: &'a std::collections::HashMap<u32, Vec<(f64, u8, bool)>>,
|
||||
/// Cache of waveform data for rendering (keyed by audio_pool_index)
|
||||
pub waveform_cache: &'a std::collections::HashMap<usize, Vec<daw_backend::WaveformPeak>>,
|
||||
}
|
||||
|
||||
/// Trait for pane rendering
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
|||
const RULER_HEIGHT: f32 = 30.0;
|
||||
const LAYER_HEIGHT: f32 = 60.0;
|
||||
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
||||
const MIN_PIXELS_PER_SECOND: f32 = 20.0;
|
||||
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
|
||||
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||||
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
||||
|
||||
|
|
@ -456,6 +456,135 @@ impl TimelinePane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render waveform visualization for audio clips on timeline
|
||||
/// Uses peak-based rendering: each waveform sample has a fixed pixel width that scales with zoom
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_audio_waveform(
|
||||
painter: &egui::Painter,
|
||||
clip_rect: egui::Rect,
|
||||
clip_start_x: f32, // Absolute screen x where clip starts (can be offscreen)
|
||||
clip_bg_color: egui::Color32, // Background color of the clip
|
||||
waveform: &[daw_backend::WaveformPeak],
|
||||
clip_duration: f64,
|
||||
pixels_per_second: f32,
|
||||
trim_start: f64,
|
||||
theme: &crate::theme::Theme,
|
||||
ctx: &egui::Context,
|
||||
) {
|
||||
if waveform.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let clip_height = clip_rect.height();
|
||||
let center_y = clip_rect.center().y;
|
||||
|
||||
// Calculate waveform color: lighten the clip background color
|
||||
// Blend clip background with white (70% white + 30% clip color) for subtle tint
|
||||
// Use full opacity to prevent overlapping lines from blending lighter when zoomed out
|
||||
let r = ((255.0 * 0.7) + (clip_bg_color.r() as f32 * 0.3)) as u8;
|
||||
let g = ((255.0 * 0.7) + (clip_bg_color.g() as f32 * 0.3)) as u8;
|
||||
let b = ((255.0 * 0.7) + (clip_bg_color.b() as f32 * 0.3)) as u8;
|
||||
let waveform_color = egui::Color32::from_rgb(r, g, b);
|
||||
|
||||
// Calculate how wide each peak should be at current zoom (mirrors JavaScript)
|
||||
// fullSourceWidth = sourceDuration * pixelsPerSecond
|
||||
// pixelsPerPeak = fullSourceWidth / waveformData.length
|
||||
let full_source_width = clip_duration * pixels_per_second as f64;
|
||||
let pixels_per_peak = full_source_width / waveform.len() as f64;
|
||||
|
||||
// Calculate which peak corresponds to the clip's offset (trimmed left edge)
|
||||
let offset_peak_index = ((trim_start / clip_duration) * waveform.len() as f64).floor() as usize;
|
||||
let offset_peak_index = offset_peak_index.min(waveform.len().saturating_sub(1));
|
||||
|
||||
// Calculate visible peak range
|
||||
// firstVisiblePeak = max(offsetPeakIndex, floor((visibleStart - startX) / pixelsPerPeak) + offsetPeakIndex)
|
||||
let visible_start = clip_rect.min.x;
|
||||
let visible_end = clip_rect.max.x;
|
||||
|
||||
let first_visible_peak_from_viewport = if pixels_per_peak > 0.0 {
|
||||
(((visible_start - clip_start_x) as f64 / pixels_per_peak).floor() as isize + offset_peak_index as isize).max(0)
|
||||
} else {
|
||||
offset_peak_index as isize
|
||||
};
|
||||
let first_visible_peak = (first_visible_peak_from_viewport as usize).max(offset_peak_index);
|
||||
|
||||
let last_visible_peak_from_viewport = if pixels_per_peak > 0.0 {
|
||||
((visible_end - clip_start_x) as f64 / pixels_per_peak).ceil() as isize + offset_peak_index as isize
|
||||
} else {
|
||||
offset_peak_index as isize
|
||||
};
|
||||
let last_visible_peak = (last_visible_peak_from_viewport as usize)
|
||||
.min(waveform.len().saturating_sub(1));
|
||||
|
||||
if first_visible_peak > last_visible_peak || first_visible_peak >= waveform.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n🎵 WAVEFORM RENDER:");
|
||||
println!(" Waveform total peaks: {}", waveform.len());
|
||||
println!(" Clip duration: {:.2}s", clip_duration);
|
||||
println!(" Pixels per second: {}", pixels_per_second);
|
||||
println!(" Pixels per peak: {:.4}", pixels_per_peak);
|
||||
println!(" Trim start: {:.2}s", trim_start);
|
||||
println!(" Offset peak index: {}", offset_peak_index);
|
||||
println!(" Clip start X: {:.1}", clip_start_x);
|
||||
println!(" Clip rect: x=[{:.1}, {:.1}], y=[{:.1}, {:.1}]",
|
||||
clip_rect.min.x, clip_rect.max.x, clip_rect.min.y, clip_rect.max.y);
|
||||
println!(" Visible start: {:.1}, end: {:.1}", visible_start, visible_end);
|
||||
println!(" First visible peak: {} (time: {:.2}s)",
|
||||
first_visible_peak, first_visible_peak as f64 * clip_duration / waveform.len() as f64);
|
||||
println!(" Last visible peak: {} (time: {:.2}s)",
|
||||
last_visible_peak, last_visible_peak as f64 * clip_duration / waveform.len() as f64);
|
||||
println!(" Peak range size: {}", last_visible_peak - first_visible_peak + 1);
|
||||
|
||||
// Draw waveform as vertical lines from min to max
|
||||
// Line width scales with zoom to avoid gaps between peaks
|
||||
let line_width = if pixels_per_peak > 1.0 {
|
||||
pixels_per_peak.ceil() as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mut peaks_drawn = 0;
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for i in first_visible_peak..=last_visible_peak {
|
||||
if i >= waveform.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let peak_x = clip_start_x + ((i as isize - offset_peak_index as isize) as f64 * pixels_per_peak) as f32;
|
||||
let peak = &waveform[i];
|
||||
|
||||
// Calculate Y positions for min and max
|
||||
let max_y = center_y + (peak.max * clip_height * 0.45);
|
||||
let min_y = center_y + (peak.min * clip_height * 0.45);
|
||||
|
||||
if peaks_drawn < 3 {
|
||||
println!(" PEAK[{}]: x={:.1}, min={:.3} (y={:.1}), max={:.3} (y={:.1})",
|
||||
i, peak_x, peak.min, min_y, peak.max, max_y);
|
||||
}
|
||||
|
||||
// Draw vertical line from min to max
|
||||
lines.push((
|
||||
egui::pos2(peak_x, max_y),
|
||||
egui::pos2(peak_x, min_y),
|
||||
));
|
||||
|
||||
peaks_drawn += 1;
|
||||
}
|
||||
|
||||
println!(" Peaks drawn: {}, line width: {:.1}px", peaks_drawn, line_width);
|
||||
|
||||
// Draw all lines with clipping
|
||||
for (start, end) in lines {
|
||||
painter.with_clip_rect(clip_rect).line_segment(
|
||||
[start, end],
|
||||
egui::Stroke::new(line_width, waveform_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render layer header column (left side with track names and controls)
|
||||
fn render_layer_headers(
|
||||
&mut self,
|
||||
|
|
@ -514,9 +643,14 @@ impl TimelinePane {
|
|||
let layer_data = layer.layer();
|
||||
let layer_name = &layer_data.name;
|
||||
let (layer_type, type_color) = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(100, 150, 255)), // Blue
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => ("Audio", egui::Color32::from_rgb(100, 255, 150)), // Green
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(255, 150, 100)), // Orange
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), // Orange
|
||||
lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => {
|
||||
match audio_layer.audio_layer_type {
|
||||
lightningbeam_core::layer::AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), // Green
|
||||
lightningbeam_core::layer::AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), // Blue
|
||||
}
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(255, 150, 100)), // Orange/Red
|
||||
};
|
||||
|
||||
// Color indicator bar on the left edge
|
||||
|
|
@ -735,6 +869,7 @@ impl TimelinePane {
|
|||
active_layer_id: &Option<uuid::Uuid>,
|
||||
selection: &lightningbeam_core::selection::Selection,
|
||||
midi_event_cache: &std::collections::HashMap<u32, Vec<(f64, u8, bool)>>,
|
||||
waveform_cache: &std::collections::HashMap<usize, Vec<daw_backend::WaveformPeak>>,
|
||||
) {
|
||||
let painter = ui.painter();
|
||||
|
||||
|
|
@ -875,16 +1010,24 @@ impl TimelinePane {
|
|||
// Choose color based on layer type
|
||||
let (clip_color, bright_color) = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => (
|
||||
egui::Color32::from_rgb(100, 150, 255), // Blue
|
||||
egui::Color32::from_rgb(150, 200, 255), // Bright blue
|
||||
egui::Color32::from_rgb(220, 150, 80), // Orange
|
||||
egui::Color32::from_rgb(255, 210, 150), // Bright orange
|
||||
),
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => (
|
||||
egui::Color32::from_rgb(100, 255, 150), // Green
|
||||
lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => {
|
||||
match audio_layer.audio_layer_type {
|
||||
lightningbeam_core::layer::AudioLayerType::Midi => (
|
||||
egui::Color32::from_rgb(100, 200, 150), // Green
|
||||
egui::Color32::from_rgb(150, 255, 200), // Bright green
|
||||
),
|
||||
lightningbeam_core::layer::AudioLayerType::Sampled => (
|
||||
egui::Color32::from_rgb(80, 150, 220), // Blue
|
||||
egui::Color32::from_rgb(150, 210, 255), // Bright blue
|
||||
),
|
||||
}
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => (
|
||||
egui::Color32::from_rgb(255, 150, 100), // Orange
|
||||
egui::Color32::from_rgb(255, 200, 150), // Bright orange
|
||||
egui::Color32::from_rgb(255, 150, 100), // Orange/Red
|
||||
egui::Color32::from_rgb(255, 200, 150), // Bright orange/red
|
||||
),
|
||||
};
|
||||
|
||||
|
|
@ -900,10 +1043,12 @@ impl TimelinePane {
|
|||
clip_color,
|
||||
);
|
||||
|
||||
// MIDI VISUALIZATION: Draw piano roll overlay for MIDI clips
|
||||
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay
|
||||
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
|
||||
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
|
||||
if let lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } = &clip.clip_type {
|
||||
match &clip.clip_type {
|
||||
// MIDI: Draw piano roll
|
||||
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
||||
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
||||
Self::render_midi_piano_roll(
|
||||
painter,
|
||||
|
|
@ -920,6 +1065,27 @@ impl TimelinePane {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Sampled Audio: Draw waveform
|
||||
lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => {
|
||||
if let Some(waveform) = waveform_cache.get(audio_pool_index) {
|
||||
// Calculate absolute screen x where clip starts (can be offscreen)
|
||||
let clip_start_x = rect.min.x + start_x;
|
||||
|
||||
Self::render_audio_waveform(
|
||||
painter,
|
||||
clip_rect,
|
||||
clip_start_x,
|
||||
clip_color, // Pass clip background color for tinting
|
||||
waveform,
|
||||
clip.duration,
|
||||
self.pixels_per_second,
|
||||
clip_instance.trim_start,
|
||||
theme,
|
||||
ui.ctx(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1673,7 +1839,7 @@ impl PaneRenderer for TimelinePane {
|
|||
|
||||
// Render layer rows with clipping
|
||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||
self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache);
|
||||
self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.waveform_cache);
|
||||
|
||||
// Render playhead on top (clip to timeline area)
|
||||
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
||||
|
|
|
|||
Loading…
Reference in New Issue