From 30aa639460e42107c5aebb51ab7cb198b7abd3d4 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 12 Nov 2025 08:53:08 -0500 Subject: [PATCH] Add looped instrument samples and auto-detection of loop points --- daw-backend/src/audio/engine.rs | 35 ++- daw-backend/src/audio/metronome.rs | 1 + daw-backend/src/audio/node_graph/graph.rs | 11 +- daw-backend/src/audio/node_graph/nodes/mod.rs | 2 +- .../audio/node_graph/nodes/multi_sampler.rs | 296 ++++++++++++++++-- daw-backend/src/audio/node_graph/preset.rs | 11 + daw-backend/src/command/types.rs | 11 +- daw-backend/src/io/midi_input.rs | 7 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/audio.rs | 167 +++++++--- src/main.js | 163 +++++++++- src/styles.css | 17 + 13 files changed, 644 insertions(+), 79 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 082430c..e231d3a 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1114,11 +1114,20 @@ impl Engine { // Write to file if let Ok(json) = preset.to_json() { - if let Err(e) = std::fs::write(&preset_path, json) { - let _ = self.event_tx.push(AudioEvent::GraphConnectionError( - track_id, - format!("Failed to save preset: {}", e) - )); + match std::fs::write(&preset_path, json) { + Ok(_) => { + // Emit success event with path + let _ = self.event_tx.push(AudioEvent::GraphPresetSaved( + track_id, + preset_path.clone() + )); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to save preset: {}", e) + )); + } } } else { let _ = self.event_tx.push(AudioEvent::GraphConnectionError( @@ -1230,7 +1239,7 @@ impl Engine { } } - Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max) => { + Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { @@ -1244,7 +1253,7 @@ impl Engine { unsafe { let multi_sampler_node = &mut *node_ptr; - if let Err(e) = multi_sampler_node.load_layer_from_file(&file_path, key_min, key_max, root_key, velocity_min, velocity_max) { + if let Err(e) = multi_sampler_node.load_layer_from_file(&file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { eprintln!("Failed to add sample layer: {}", e); } } @@ -1252,7 +1261,7 @@ impl Engine { } } - Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max) => { + Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { @@ -1266,7 +1275,7 @@ impl Engine { unsafe { let multi_sampler_node = &mut *node_ptr; - if let Err(e) = multi_sampler_node.update_layer(layer_index, key_min, key_max, root_key, velocity_min, velocity_max) { + if let Err(e) = multi_sampler_node.update_layer(layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { eprintln!("Failed to update sample layer: {}", e); } } @@ -2254,13 +2263,13 @@ impl EngineController { } /// Add a sample layer to a MultiSampler node - pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8) { - let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max)); + pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); } /// Update a MultiSampler layer's configuration - pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8) { - let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max)); + pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); } /// Remove a layer from a MultiSampler node diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs index f50b29b..4612cf4 100644 --- a/daw-backend/src/audio/metronome.rs +++ b/daw-backend/src/audio/metronome.rs @@ -14,6 +14,7 @@ pub struct Metronome { click_position: usize, // Current position in the click sample (0 = not playing) playing_high_click: bool, // Which click we're currently playing + #[allow(dead_code)] sample_rate: u32, } diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 24c01a2..7db51bb 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -472,7 +472,7 @@ impl AudioGraph { // This is safe because each output buffer is independent let buffer = &mut node.output_buffers[i] as *mut Vec; unsafe { - let slice = &mut (*buffer)[..process_size.min((*buffer).len())]; + let slice = &mut (&mut *buffer)[..process_size.min((*buffer).len())]; output_slices.push(slice); } } @@ -733,6 +733,9 @@ impl AudioGraph { root_key: info.root_key, velocity_min: info.velocity_min, velocity_max: info.velocity_max, + loop_start: info.loop_start, + loop_end: info.loop_end, + loop_mode: info.loop_mode, } }) .collect(); @@ -938,6 +941,9 @@ impl AudioGraph { layer.root_key, layer.velocity_min, layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, ); } } else if let Some(ref path) = layer.file_path { @@ -950,6 +956,9 @@ impl AudioGraph { layer.root_key, layer.velocity_min, layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, ) { eprintln!("Failed to load sample layer from {}: {}", resolved_path, e); } diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 60483e0..9a94602 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -63,7 +63,7 @@ pub use math::MathNode; pub use midi_input::MidiInputNode; pub use midi_to_cv::MidiToCVNode; pub use mixer::MixerNode; -pub use multi_sampler::MultiSamplerNode; +pub use multi_sampler::{MultiSamplerNode, LoopMode}; pub use noise::NoiseGeneratorNode; pub use oscillator::OscillatorNode; pub use oscilloscope::OscilloscopeNode; diff --git a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs index 3f4e4c7..a482100 100644 --- a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs @@ -7,6 +7,16 @@ const PARAM_ATTACK: u32 = 1; const PARAM_RELEASE: u32 = 2; const PARAM_TRANSPOSE: u32 = 3; +/// Loop playback mode +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoopMode { + /// Play sample once, no looping + OneShot, + /// Loop continuously between loop_start and loop_end + Continuous, +} + /// Metadata about a loaded sample layer (for preset serialization) #[derive(Clone, Debug)] pub struct LayerInfo { @@ -16,6 +26,9 @@ pub struct LayerInfo { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + pub loop_start: Option, // Loop start point in samples + pub loop_end: Option, // Loop end point in samples + pub loop_mode: LoopMode, } /// Single sample with velocity range and key range @@ -32,6 +45,11 @@ struct SampleLayer { // Velocity range: 0-127 velocity_min: u8, velocity_max: u8, + + // Loop points (in samples) + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, } impl SampleLayer { @@ -43,6 +61,9 @@ impl SampleLayer { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Self { Self { sample_data, @@ -52,6 +73,9 @@ impl SampleLayer { root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode, } } @@ -62,6 +86,114 @@ impl SampleLayer { && velocity >= self.velocity_min && velocity <= self.velocity_max } + + /// Auto-detect loop points using autocorrelation to find a good loop region + /// Returns (loop_start, loop_end) in samples + fn detect_loop_points(sample_data: &[f32], sample_rate: f32) -> Option<(usize, usize)> { + if sample_data.len() < (sample_rate * 0.5) as usize { + return None; // Need at least 0.5 seconds of audio + } + + // Look for loop in the sustain region (skip attack/decay, avoid release) + // For sustained instruments, look in the middle 50% of the sample + let search_start = (sample_data.len() as f32 * 0.25) as usize; + let search_end = (sample_data.len() as f32 * 0.75) as usize; + + if search_end <= search_start { + return None; + } + + // Find the best loop point using autocorrelation + // For sustained instruments like brass/woodwind, we want longer loops + let min_loop_length = (sample_rate * 0.1) as usize; // Min 0.1s loop (more stable) + let max_loop_length = (sample_rate * 10.0) as usize; // Max 10 second loop + + let mut best_correlation = -1.0; + let mut best_loop_start = search_start; + let mut best_loop_end = search_end; + + // Try different loop lengths from LONGEST to SHORTEST + // This way we prefer longer loops and stop early if we find a good one + let length_step = ((sample_rate * 0.05) as usize).max(512); // 50ms steps + let actual_max_length = max_loop_length.min(search_end - search_start); + + // Manually iterate backwards since step_by().rev() doesn't work on RangeInclusive + let mut loop_length = actual_max_length; + while loop_length >= min_loop_length { + // Try different starting points in the sustain region (finer steps) + let start_step = ((sample_rate * 0.02) as usize).max(256); // 20ms steps + for start in (search_start..search_end - loop_length).step_by(start_step) { + let end = start + loop_length; + if end > search_end { + break; + } + + // Calculate correlation between loop end and loop start + let correlation = Self::calculate_loop_correlation(sample_data, start, end); + + if correlation > best_correlation { + best_correlation = correlation; + best_loop_start = start; + best_loop_end = end; + } + } + + // If we found a good enough loop, stop searching shorter ones + if best_correlation > 0.8 { + break; + } + + // Decrement loop_length, with underflow protection + if loop_length < length_step { + break; + } + loop_length -= length_step; + } + + // Lower threshold since longer loops are harder to match perfectly + if best_correlation > 0.6 { + Some((best_loop_start, best_loop_end)) + } else { + // Fallback: use a reasonable chunk of the sustain region + let fallback_length = ((search_end - search_start) / 2).max(min_loop_length); + Some((search_start, search_start + fallback_length)) + } + } + + /// Calculate how well the audio loops at the given points + /// Returns correlation value between -1.0 and 1.0 (higher is better) + fn calculate_loop_correlation(sample_data: &[f32], loop_start: usize, loop_end: usize) -> f32 { + let loop_length = loop_end - loop_start; + let window_size = (loop_length / 10).max(128).min(2048); // Compare last 10% of loop + + if loop_end + window_size >= sample_data.len() { + return -1.0; + } + + // Compare the end of the loop region with the beginning + let region1_start = loop_end - window_size; + let region2_start = loop_start; + + let mut sum_xy = 0.0; + let mut sum_x2 = 0.0; + let mut sum_y2 = 0.0; + + for i in 0..window_size { + let x = sample_data[region1_start + i]; + let y = sample_data[region2_start + i]; + sum_xy += x * y; + sum_x2 += x * x; + sum_y2 += y * y; + } + + // Pearson correlation coefficient + let denominator = (sum_x2 * sum_y2).sqrt(); + if denominator > 0.0 { + sum_xy / denominator + } else { + -1.0 + } + } } /// Active voice playing a sample @@ -75,6 +207,10 @@ struct Voice { // Envelope envelope_phase: EnvelopePhase, envelope_value: f32, + + // Loop crossfade state + crossfade_buffer: Vec, // Stores samples from before loop_start for crossfading + crossfade_length: usize, // Length of crossfade in samples (e.g., 100 samples = ~2ms @ 48kHz) } #[derive(Debug, Clone, Copy, PartialEq)] @@ -94,6 +230,8 @@ impl Voice { is_active: true, envelope_phase: EnvelopePhase::Attack, envelope_value: 0.0, + crossfade_buffer: Vec::new(), + crossfade_length: 1000, // ~20ms at 48kHz (longer for smoother loops) } } } @@ -166,6 +304,9 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) { let layer = SampleLayer::new( sample_data, @@ -175,6 +316,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode, ); self.layers.push(layer); } @@ -188,10 +332,25 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Result<(), String> { use crate::audio::sample_loader::load_audio_file; let sample_data = load_audio_file(path)?; + + // Auto-detect loop points if not provided and mode is Continuous + let (final_loop_start, final_loop_end) = if loop_mode == LoopMode::Continuous && loop_start.is_none() && loop_end.is_none() { + if let Some((start, end)) = SampleLayer::detect_loop_points(&sample_data.samples, sample_data.sample_rate as f32) { + (Some(start), Some(end)) + } else { + (None, None) + } + } else { + (loop_start, loop_end) + }; + self.add_layer( sample_data.samples, sample_data.sample_rate as f32, @@ -200,6 +359,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + final_loop_start, + final_loop_end, + loop_mode, ); // Store layer metadata for preset serialization @@ -210,6 +372,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + loop_start: final_loop_start, + loop_end: final_loop_end, + loop_mode, }); Ok(()) @@ -236,6 +401,9 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Result<(), String> { if layer_index >= self.layers.len() { return Err("Layer index out of bounds".to_string()); @@ -247,6 +415,9 @@ impl MultiSamplerNode { self.layers[layer_index].root_key = root_key; self.layers[layer_index].velocity_min = velocity_min; self.layers[layer_index].velocity_max = velocity_max; + self.layers[layer_index].loop_start = loop_start; + self.layers[layer_index].loop_end = loop_end; + self.layers[layer_index].loop_mode = loop_mode; // Update the layer info if layer_index < self.layer_infos.len() { @@ -255,6 +426,9 @@ impl MultiSamplerNode { self.layer_infos[layer_index].root_key = root_key; self.layer_infos[layer_index].velocity_min = velocity_min; self.layer_infos[layer_index].velocity_max = velocity_max; + self.layer_infos[layer_index].loop_start = loop_start; + self.layer_infos[layer_index].loop_end = loop_end; + self.layer_infos[layer_index].loop_mode = loop_mode; } Ok(()) @@ -429,25 +603,109 @@ impl AudioNode for MultiSamplerNode { let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32); for frame in 0..frames { - // Read sample with linear interpolation + // Read sample with linear interpolation and loop handling let playhead = voice.playhead; - let sample = if !layer.sample_data.is_empty() && playhead >= 0.0 { + let mut sample = 0.0; + + if !layer.sample_data.is_empty() && playhead >= 0.0 { let index = playhead.floor() as usize; - if index < layer.sample_data.len() { - let frac = playhead - playhead.floor(); - let sample1 = layer.sample_data[index]; - let sample2 = if index + 1 < layer.sample_data.len() { - layer.sample_data[index + 1] + + // Check if we need to handle looping + if layer.loop_mode == LoopMode::Continuous { + if let (Some(loop_start), Some(loop_end)) = (layer.loop_start, layer.loop_end) { + // Validate loop points + if loop_start < loop_end && loop_end <= layer.sample_data.len() { + // Fill crossfade buffer on first loop with samples just before loop_start + // These will be crossfaded with the beginning of the loop for seamless looping + if voice.crossfade_buffer.is_empty() && loop_start >= voice.crossfade_length { + let crossfade_start = loop_start.saturating_sub(voice.crossfade_length); + voice.crossfade_buffer = layer.sample_data[crossfade_start..loop_start].to_vec(); + } + + // Check if we've reached the loop end + if index >= loop_end { + // Wrap around to loop start + let loop_length = loop_end - loop_start; + let offset_from_end = index - loop_end; + let wrapped_index = loop_start + (offset_from_end % loop_length); + voice.playhead = wrapped_index as f32 + (playhead - playhead.floor()); + } + + // Read sample at current position + let current_index = voice.playhead.floor() as usize; + if current_index < layer.sample_data.len() { + let frac = voice.playhead - voice.playhead.floor(); + let sample1 = layer.sample_data[current_index]; + let sample2 = if current_index + 1 < layer.sample_data.len() { + layer.sample_data[current_index + 1] + } else { + layer.sample_data[loop_start] // Wrap to loop start for interpolation + }; + sample = sample1 + (sample2 - sample1) * frac; + + // Apply crossfade only at the END of loop + // Crossfade the end of loop with samples BEFORE loop_start + if current_index >= loop_start && current_index < loop_end { + if !voice.crossfade_buffer.is_empty() { + let crossfade_len = voice.crossfade_length.min(voice.crossfade_buffer.len()); + + // Only crossfade at loop end (last crossfade_length samples) + // This blends end samples (i,j,k) with pre-loop samples (a,b,c) + if current_index >= loop_end - crossfade_len && current_index < loop_end { + let crossfade_pos = current_index - (loop_end - crossfade_len); + if crossfade_pos < voice.crossfade_buffer.len() { + let end_sample = sample; // Current sample at end of loop (i, j, or k) + let pre_loop_sample = voice.crossfade_buffer[crossfade_pos]; // Corresponding pre-loop sample (a, b, or c) + // Equal-power crossfade: fade out end, fade in pre-loop + let fade_ratio = crossfade_pos as f32 / crossfade_len as f32; + let fade_out = (1.0 - fade_ratio).sqrt(); + let fade_in = fade_ratio.sqrt(); + sample = end_sample * fade_out + pre_loop_sample * fade_in; + } + } + } + } + } + } else { + // Invalid loop points, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } } else { - 0.0 - }; - sample1 + (sample2 - sample1) * frac + // No loop points defined, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } } else { - 0.0 + // OneShot mode - play normally without looping + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } } - } else { - 0.0 - }; + } // Process envelope match voice.envelope_phase { @@ -484,10 +742,12 @@ impl AudioNode for MultiSamplerNode { // Advance playhead voice.playhead += speed_adjusted; - // Stop if we've reached the end - if voice.playhead >= layer.sample_data.len() as f32 { - voice.is_active = false; - break; + // Stop if we've reached the end (only for OneShot mode) + if layer.loop_mode == LoopMode::OneShot { + if voice.playhead >= layer.sample_data.len() as f32 { + voice.is_active = false; + break; + } } } } diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs index 45a3545..9a67125 100644 --- a/daw-backend/src/audio/node_graph/preset.rs +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use super::nodes::LoopMode; /// Sample data for preset serialization #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,6 +38,16 @@ pub struct LayerData { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_end: Option, + #[serde(default = "default_loop_mode")] + pub loop_mode: LoopMode, +} + +fn default_loop_mode() -> LoopMode { + LoopMode::OneShot } /// Serializable representation of a node graph preset diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 1590424..a45cda2 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -3,6 +3,7 @@ use crate::audio::{ TrackId, }; use crate::audio::buffer_pool::BufferPoolStats; +use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; /// Commands sent from UI/control thread to audio thread @@ -151,10 +152,10 @@ pub enum Command { /// Load a sample into a SimpleSampler node (track_id, node_id, file_path) SamplerLoadSample(TrackId, u32, String), - /// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max) - MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8), - /// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max) - MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8), + /// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) + MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option, Option, LoopMode), + /// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) + MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option, Option, LoopMode), /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) MultiSamplerRemoveLayer(TrackId, u32, usize), @@ -215,6 +216,8 @@ pub enum AudioEvent { GraphStateChanged(TrackId), /// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded GraphPresetLoaded(TrackId), + /// Preset has been saved to file (track_id, preset_path) + GraphPresetSaved(TrackId, String), } /// Synchronous queries sent from UI thread to audio thread diff --git a/daw-backend/src/io/midi_input.rs b/daw-backend/src/io/midi_input.rs index 4a5c496..559df53 100644 --- a/daw-backend/src/io/midi_input.rs +++ b/daw-backend/src/io/midi_input.rs @@ -9,6 +9,7 @@ use std::time::Duration; pub struct MidiInputManager { connections: Arc>>, active_track_id: Arc>>, + #[allow(dead_code)] command_tx: Arc>>, } @@ -147,14 +148,16 @@ impl MidiInputManager { println!("MIDI: Connected to: {}", port_name); // Need to recreate MidiInput for next iteration - midi_in = MidiInput::new("Lightningbeam") + let _midi_in = MidiInput::new("Lightningbeam") .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; + midi_in = _midi_in; } Err(e) => { eprintln!("MIDI: Failed to connect to {}: {}", port_name, e); // Recreate MidiInput to continue with other ports - midi_in = MidiInput::new("Lightningbeam") + let _midi_in = MidiInput::new("Lightningbeam") .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; + midi_in = _midi_in; } } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb1f00e..bb73abb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2523,6 +2523,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-shell", "tiny_http", + "tokio", "tracing", "tracing-subscriber", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 248155a..84e6cf7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ chrono = "0.4" daw-backend = { path = "../daw-backend" } cpal = "0.15" rtrb = "0.3" +tokio = { version = "1", features = ["sync", "time"] } # Video decoding ffmpeg-next = "7.0" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 37bae40..0bbc8a2 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex}; use std::collections::HashMap; use std::path::Path; use tauri::{Emitter, Manager}; +use tokio::sync::oneshot; #[derive(serde::Serialize)] pub struct AudioFileMetadata { @@ -39,6 +40,8 @@ pub struct AudioState { pub(crate) next_graph_node_id: u32, // Track next node ID for each VoiceAllocator template (VoiceAllocator backend ID -> next template node ID) pub(crate) template_node_counters: HashMap, + // Pending preset save notifications (preset_path -> oneshot sender) + pub(crate) preset_save_waiters: Arc>>>, } impl Default for AudioState { @@ -51,6 +54,7 @@ impl Default for AudioState { next_track_id: 0, next_pool_index: 0, next_graph_node_id: 0, + preset_save_waiters: Arc::new(Mutex::new(HashMap::new())), template_node_counters: HashMap::new(), } } @@ -59,10 +63,20 @@ impl Default for AudioState { /// Implementation of EventEmitter that uses Tauri's event system struct TauriEventEmitter { app_handle: tauri::AppHandle, + preset_save_waiters: Arc>>>, } impl EventEmitter for TauriEventEmitter { fn emit(&self, event: AudioEvent) { + // Handle preset save notifications + if let AudioEvent::GraphPresetSaved(_, ref preset_path) = event { + if let Ok(mut waiters) = self.preset_save_waiters.lock() { + if let Some(sender) = waiters.remove(preset_path) { + let _ = sender.send(()); + } + } + } + // Serialize the event to the format expected by the frontend let serialized_event = match event { AudioEvent::PlaybackPosition(time) => { @@ -98,6 +112,9 @@ impl EventEmitter for TauriEventEmitter { AudioEvent::GraphPresetLoaded(track_id) => { SerializedAudioEvent::GraphPresetLoaded { track_id } } + AudioEvent::GraphPresetSaved(track_id, preset_path) => { + SerializedAudioEvent::GraphPresetSaved { track_id, preset_path } + } AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { SerializedAudioEvent::MidiRecordingStopped { track_id, clip_id, note_count } } @@ -140,7 +157,10 @@ pub async fn audio_init( } // Create TauriEventEmitter - let emitter = Arc::new(TauriEventEmitter { app_handle }); + let emitter = Arc::new(TauriEventEmitter { + app_handle, + preset_save_waiters: audio_state.preset_save_waiters.clone(), + }); // Get buffer size from audio_state (default is 256) let buffer_size = audio_state.buffer_size; @@ -1136,9 +1156,21 @@ pub async fn multi_sampler_add_layer( root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: Option, ) -> Result<(), String> { + use daw_backend::audio::node_graph::nodes::LoopMode; + let mut audio_state = state.lock().unwrap(); + // Parse loop mode string to enum + let loop_mode_enum = match loop_mode.as_deref() { + Some("continuous") => LoopMode::Continuous, + Some("oneshot") | Some("one-shot") => LoopMode::OneShot, + _ => LoopMode::OneShot, // Default + }; + if let Some(controller) = &mut audio_state.controller { controller.multi_sampler_add_layer( track_id, @@ -1149,6 +1181,9 @@ pub async fn multi_sampler_add_layer( root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode_enum, ); Ok(()) } else { @@ -1164,6 +1199,9 @@ pub struct LayerInfo { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + pub loop_start: Option, + pub loop_end: Option, + pub loop_mode: String, } #[tauri::command] @@ -1175,32 +1213,70 @@ pub async fn multi_sampler_get_layers( eprintln!("[multi_sampler_get_layers] FUNCTION CALLED with track_id: {}, node_id: {}", track_id, node_id); use daw_backend::GraphPreset; - let mut audio_state = state.lock().unwrap(); - if let Some(controller) = &mut audio_state.controller { - // Use preset serialization to get node data including layers - // Use timestamp to ensure unique temp file for each query to avoid conflicts - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_path = std::env::temp_dir().join(format!("temp_layers_query_{}_{}_{}.json", track_id, node_id, timestamp)); - let temp_path_str = temp_path.to_string_lossy().to_string(); - eprintln!("[multi_sampler_get_layers] Temp path: {}", temp_path_str); + // Set up oneshot channel to wait for preset save completion + let (tx, rx) = oneshot::channel(); - controller.graph_save_preset( - track_id, - temp_path_str.clone(), - "temp".to_string(), - "".to_string(), - vec![] - ); + let (temp_path_str, preset_save_waiters) = { + let mut audio_state = state.lock().unwrap(); - // Give the audio thread time to process - std::thread::sleep(std::time::Duration::from_millis(50)); + // Clone preset_save_waiters first before any mutable borrows + let preset_save_waiters = audio_state.preset_save_waiters.clone(); - // Read the temp file and parse it - eprintln!("[multi_sampler_get_layers] Reading temp file..."); - match std::fs::read_to_string(&temp_path) { + if let Some(controller) = &mut audio_state.controller { + // Use preset serialization to get node data including layers + // Use timestamp to ensure unique temp file for each query to avoid conflicts + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_path = std::env::temp_dir().join(format!("temp_layers_query_{}_{}_{}.json", track_id, node_id, timestamp)); + let temp_path_str = temp_path.to_string_lossy().to_string(); + eprintln!("[multi_sampler_get_layers] Temp path: {}", temp_path_str); + + // Register waiter for this preset path + { + let mut waiters = preset_save_waiters.lock().unwrap(); + waiters.insert(temp_path_str.clone(), tx); + } + + controller.graph_save_preset( + track_id, + temp_path_str.clone(), + "temp".to_string(), + "".to_string(), + vec![] + ); + + (temp_path_str, preset_save_waiters) + } else { + eprintln!("[multi_sampler_get_layers] Audio not initialized"); + return Err("Audio not initialized".to_string()); + } + }; + + // Wait for preset save event with timeout + eprintln!("[multi_sampler_get_layers] Waiting for preset save completion..."); + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(())) => { + eprintln!("[multi_sampler_get_layers] Preset save complete, reading file..."); + } + Ok(Err(_)) => { + eprintln!("[multi_sampler_get_layers] Preset save channel closed"); + return Ok(Vec::new()); + } + Err(_) => { + eprintln!("[multi_sampler_get_layers] Timeout waiting for preset save"); + // Clean up waiter + let mut waiters = preset_save_waiters.lock().unwrap(); + waiters.remove(&temp_path_str); + return Ok(Vec::new()); + } + } + + let temp_path = std::path::PathBuf::from(&temp_path_str); + // Read the temp file and parse it + eprintln!("[multi_sampler_get_layers] Reading temp file..."); + match std::fs::read_to_string(&temp_path) { Ok(json) => { // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -1222,13 +1298,22 @@ pub async fn multi_sampler_get_layers( // Check if it's a MultiSampler if let daw_backend::audio::node_graph::preset::SampleData::MultiSampler { layers } = sample_data { eprintln!("[multi_sampler_get_layers] Returning {} layers", layers.len()); - return Ok(layers.iter().map(|layer| LayerInfo { - file_path: layer.file_path.clone().unwrap_or_default(), - key_min: layer.key_min, - key_max: layer.key_max, - root_key: layer.root_key, - velocity_min: layer.velocity_min, - velocity_max: layer.velocity_max, + return Ok(layers.iter().map(|layer| { + let loop_mode_str = match layer.loop_mode { + daw_backend::audio::node_graph::nodes::LoopMode::Continuous => "continuous", + daw_backend::audio::node_graph::nodes::LoopMode::OneShot => "oneshot", + }; + LayerInfo { + file_path: layer.file_path.clone().unwrap_or_default(), + key_min: layer.key_min, + key_max: layer.key_max, + root_key: layer.root_key, + velocity_min: layer.velocity_min, + velocity_max: layer.velocity_max, + loop_start: layer.loop_start, + loop_end: layer.loop_end, + loop_mode: loop_mode_str.to_string(), + } }).collect()); } else { eprintln!("[multi_sampler_get_layers] sample_data is not MultiSampler type"); @@ -1247,10 +1332,6 @@ pub async fn multi_sampler_get_layers( Ok(Vec::new()) // Return empty list if file doesn't exist } } - } else { - eprintln!("[multi_sampler_get_layers] Audio not initialized"); - Err("Audio not initialized".to_string()) - } } #[tauri::command] @@ -1264,9 +1345,21 @@ pub async fn multi_sampler_update_layer( root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: Option, ) -> Result<(), String> { + use daw_backend::audio::node_graph::nodes::LoopMode; + let mut audio_state = state.lock().unwrap(); + // Parse loop mode string to enum + let loop_mode_enum = match loop_mode.as_deref() { + Some("continuous") => LoopMode::Continuous, + Some("oneshot") | Some("one-shot") => LoopMode::OneShot, + _ => LoopMode::OneShot, // Default + }; + if let Some(controller) = &mut audio_state.controller { controller.multi_sampler_update_layer( track_id, @@ -1277,6 +1370,9 @@ pub async fn multi_sampler_update_layer( root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode_enum, ); Ok(()) } else { @@ -1432,6 +1528,7 @@ pub enum SerializedAudioEvent { GraphConnectionError { track_id: u32, message: String }, GraphStateChanged { track_id: u32 }, GraphPresetLoaded { track_id: u32 }, + GraphPresetSaved { track_id: u32, preset_path: String }, } // audio_get_events command removed - events are now pushed via Tauri event system diff --git a/src/main.js b/src/main.js index 19b41b2..cfc6c0f 100644 --- a/src/main.js +++ b/src/main.js @@ -5270,6 +5270,83 @@ async function startup() { startup(); +// Track maximized pane state +let maximizedPane = null; +let savedPaneParent = null; +let savedRootPaneChildren = []; +let savedRootPaneClasses = null; + +function toggleMaximizePane(paneDiv) { + if (maximizedPane === paneDiv) { + // Restore layout + if (savedPaneParent && savedRootPaneChildren.length > 0) { + // Remove pane from root + rootPane.removeChild(paneDiv); + + // Restore all root pane children + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + for (const child of savedRootPaneChildren) { + rootPane.appendChild(child); + } + + // Put pane back in its original parent + savedPaneParent.appendChild(paneDiv); + + // Restore root pane classes + if (savedRootPaneClasses) { + rootPane.className = savedRootPaneClasses; + } + + savedPaneParent = null; + savedRootPaneChildren = []; + savedRootPaneClasses = null; + } + maximizedPane = null; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; + btn.title = "Maximize Pane"; + } + + // Trigger updates + updateAll(); + } else { + // Maximize pane + // Save pane's current parent + savedPaneParent = paneDiv.parentElement; + + // Save all root pane children + savedRootPaneChildren = Array.from(rootPane.children); + savedRootPaneClasses = rootPane.className; + + // Remove pane from its parent + savedPaneParent.removeChild(paneDiv); + + // Clear root pane + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + + // Add only the maximized pane to root + rootPane.appendChild(paneDiv); + maximizedPane = paneDiv; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; // Could use different icon for restore + btn.title = "Restore Layout"; + } + + // Trigger updates + updateAll(); + } +} + function createPaneMenu(div) { const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu @@ -5386,6 +5463,16 @@ function createPane(paneType = undefined, div = undefined) { } } + // Add maximize/restore button in top right + const maximizeBtn = document.createElement("button"); + maximizeBtn.className = "maximize-btn"; + maximizeBtn.title = "Maximize Pane"; + maximizeBtn.innerHTML = "⛶"; // Maximize icon + maximizeBtn.addEventListener("click", () => { + toggleMaximizePane(div); + }); + header.appendChild(maximizeBtn); + div.className = "vertical-grid pane"; div.setAttribute("data-pane-name", paneType.name); header.style.height = "calc( 2 * var(--lineheight))"; @@ -8708,7 +8795,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Wait a bit for the audio thread to process the add command @@ -8822,7 +8912,10 @@ function nodeEditor() { keyMax: layer.key_max, rootKey: layer.root_key, velocityMin: layer.velocity_min, - velocityMax: layer.velocity_max + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode }); if (layerConfig) { @@ -8835,7 +8928,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Refresh the list @@ -9872,7 +9968,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Wait a bit for the audio thread to process the add command @@ -10677,6 +10776,9 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const rootKey = existingConfig?.rootKey ?? 60; const velocityMin = existingConfig?.velocityMin ?? 0; const velocityMax = existingConfig?.velocityMax ?? 127; + const loopMode = existingConfig?.loopMode ?? 'oneshot'; + const loopStart = existingConfig?.loopStart ?? null; + const loopEnd = existingConfig?.loopEnd ?? null; // Create modal dialog const dialog = document.createElement('div'); @@ -10723,6 +10825,33 @@ function showLayerConfigDialog(filePath, existingConfig = null) { +
+ + +
+ Continuous mode will auto-detect loop points if not specified +
+
+
+ +
+
+ + +
+ - +
+ + +
+
+
+ Leave empty to auto-detect optimal loop points +
+
@@ -10737,6 +10866,8 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const keyMinInput = dialog.querySelector('#key-min'); const keyMaxInput = dialog.querySelector('#key-max'); const rootKeyInput = dialog.querySelector('#root-key'); + const loopModeSelect = dialog.querySelector('#loop-mode'); + const loopPointsGroup = dialog.querySelector('#loop-points-group'); const updateKeyMinName = () => { const note = parseInt(keyMinInput.value) || 0; @@ -10757,6 +10888,12 @@ function showLayerConfigDialog(filePath, existingConfig = null) { keyMaxInput.addEventListener('input', updateKeyMaxName); rootKeyInput.addEventListener('input', updateRootKeyName); + // Toggle loop points visibility based on loop mode + loopModeSelect.addEventListener('change', () => { + const isContinuous = loopModeSelect.value === 'continuous'; + loopPointsGroup.style.display = isContinuous ? 'block' : 'none'; + }); + // Focus first input setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100); @@ -10775,6 +10912,13 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const rootKey = parseInt(rootKeyInput.value); const velocityMin = parseInt(dialog.querySelector('#velocity-min').value); const velocityMax = parseInt(dialog.querySelector('#velocity-max').value); + const loopMode = loopModeSelect.value; + + // Get loop points (null if empty) + const loopStartInput = dialog.querySelector('#loop-start'); + const loopEndInput = dialog.querySelector('#loop-end'); + const loopStart = loopStartInput.value ? parseInt(loopStartInput.value) : null; + const loopEnd = loopEndInput.value ? parseInt(loopEndInput.value) : null; // Validate ranges if (keyMin > keyMax) { @@ -10792,13 +10936,22 @@ function showLayerConfigDialog(filePath, existingConfig = null) { return; } + // Validate loop points if both are specified + if (loopStart !== null && loopEnd !== null && loopStart >= loopEnd) { + alert('Loop Start must be less than Loop End'); + return; + } + dialog.remove(); resolve({ keyMin, keyMax, rootKey, velocityMin, - velocityMax + velocityMax, + loopMode, + loopStart, + loopEnd }); }); diff --git a/src/styles.css b/src/styles.css index 6c02544..2fee420 100644 --- a/src/styles.css +++ b/src/styles.css @@ -213,6 +213,23 @@ button { user-select: none; } +/* Maximize button in pane headers */ +.maximize-btn { + margin-left: auto; + margin-right: 8px; + padding: 4px 8px; + background: none; + border: 1px solid var(--foreground-color); + color: var(--text-primary); + cursor: pointer; + border-radius: 3px; + font-size: 14px; +} + +.maximize-btn:hover { + background-color: var(--surface-light); +} + .pane { user-select: none; }