Add looped instrument samples and auto-detection of loop points
This commit is contained in:
parent
3296d3ab6e
commit
30aa639460
|
|
@ -1114,12 +1114,21 @@ impl Engine {
|
||||||
|
|
||||||
// Write to file
|
// Write to file
|
||||||
if let Ok(json) = preset.to_json() {
|
if let Ok(json) = preset.to_json() {
|
||||||
if let Err(e) = std::fs::write(&preset_path, json) {
|
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(
|
let _ = self.event_tx.push(AudioEvent::GraphConnectionError(
|
||||||
track_id,
|
track_id,
|
||||||
format!("Failed to save preset: {}", e)
|
format!("Failed to save preset: {}", e)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphConnectionError(
|
let _ = self.event_tx.push(AudioEvent::GraphConnectionError(
|
||||||
track_id,
|
track_id,
|
||||||
|
|
@ -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;
|
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
|
|
@ -1244,7 +1253,7 @@ impl Engine {
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let multi_sampler_node = &mut *node_ptr;
|
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);
|
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;
|
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
|
|
@ -1266,7 +1275,7 @@ impl Engine {
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let multi_sampler_node = &mut *node_ptr;
|
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);
|
eprintln!("Failed to update sample layer: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2254,13 +2263,13 @@ impl EngineController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a sample layer to a MultiSampler node
|
/// 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) {
|
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<usize>, loop_end: Option<usize>, 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));
|
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
|
/// 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) {
|
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<usize>, loop_end: Option<usize>, 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));
|
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
|
/// Remove a layer from a MultiSampler node
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub struct Metronome {
|
||||||
click_position: usize, // Current position in the click sample (0 = not playing)
|
click_position: usize, // Current position in the click sample (0 = not playing)
|
||||||
playing_high_click: bool, // Which click we're currently playing
|
playing_high_click: bool, // Which click we're currently playing
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -472,7 +472,7 @@ impl AudioGraph {
|
||||||
// This is safe because each output buffer is independent
|
// This is safe because each output buffer is independent
|
||||||
let buffer = &mut node.output_buffers[i] as *mut Vec<f32>;
|
let buffer = &mut node.output_buffers[i] as *mut Vec<f32>;
|
||||||
unsafe {
|
unsafe {
|
||||||
let slice = &mut (*buffer)[..process_size.min((*buffer).len())];
|
let slice = &mut (&mut *buffer)[..process_size.min((*buffer).len())];
|
||||||
output_slices.push(slice);
|
output_slices.push(slice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -733,6 +733,9 @@ impl AudioGraph {
|
||||||
root_key: info.root_key,
|
root_key: info.root_key,
|
||||||
velocity_min: info.velocity_min,
|
velocity_min: info.velocity_min,
|
||||||
velocity_max: info.velocity_max,
|
velocity_max: info.velocity_max,
|
||||||
|
loop_start: info.loop_start,
|
||||||
|
loop_end: info.loop_end,
|
||||||
|
loop_mode: info.loop_mode,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -938,6 +941,9 @@ impl AudioGraph {
|
||||||
layer.root_key,
|
layer.root_key,
|
||||||
layer.velocity_min,
|
layer.velocity_min,
|
||||||
layer.velocity_max,
|
layer.velocity_max,
|
||||||
|
layer.loop_start,
|
||||||
|
layer.loop_end,
|
||||||
|
layer.loop_mode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if let Some(ref path) = layer.file_path {
|
} else if let Some(ref path) = layer.file_path {
|
||||||
|
|
@ -950,6 +956,9 @@ impl AudioGraph {
|
||||||
layer.root_key,
|
layer.root_key,
|
||||||
layer.velocity_min,
|
layer.velocity_min,
|
||||||
layer.velocity_max,
|
layer.velocity_max,
|
||||||
|
layer.loop_start,
|
||||||
|
layer.loop_end,
|
||||||
|
layer.loop_mode,
|
||||||
) {
|
) {
|
||||||
eprintln!("Failed to load sample layer from {}: {}", resolved_path, e);
|
eprintln!("Failed to load sample layer from {}: {}", resolved_path, e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub use math::MathNode;
|
||||||
pub use midi_input::MidiInputNode;
|
pub use midi_input::MidiInputNode;
|
||||||
pub use midi_to_cv::MidiToCVNode;
|
pub use midi_to_cv::MidiToCVNode;
|
||||||
pub use mixer::MixerNode;
|
pub use mixer::MixerNode;
|
||||||
pub use multi_sampler::MultiSamplerNode;
|
pub use multi_sampler::{MultiSamplerNode, LoopMode};
|
||||||
pub use noise::NoiseGeneratorNode;
|
pub use noise::NoiseGeneratorNode;
|
||||||
pub use oscillator::OscillatorNode;
|
pub use oscillator::OscillatorNode;
|
||||||
pub use oscilloscope::OscilloscopeNode;
|
pub use oscilloscope::OscilloscopeNode;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ const PARAM_ATTACK: u32 = 1;
|
||||||
const PARAM_RELEASE: u32 = 2;
|
const PARAM_RELEASE: u32 = 2;
|
||||||
const PARAM_TRANSPOSE: u32 = 3;
|
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)
|
/// Metadata about a loaded sample layer (for preset serialization)
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LayerInfo {
|
pub struct LayerInfo {
|
||||||
|
|
@ -16,6 +26,9 @@ pub struct LayerInfo {
|
||||||
pub root_key: u8,
|
pub root_key: u8,
|
||||||
pub velocity_min: u8,
|
pub velocity_min: u8,
|
||||||
pub velocity_max: u8,
|
pub velocity_max: u8,
|
||||||
|
pub loop_start: Option<usize>, // Loop start point in samples
|
||||||
|
pub loop_end: Option<usize>, // Loop end point in samples
|
||||||
|
pub loop_mode: LoopMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single sample with velocity range and key range
|
/// Single sample with velocity range and key range
|
||||||
|
|
@ -32,6 +45,11 @@ struct SampleLayer {
|
||||||
// Velocity range: 0-127
|
// Velocity range: 0-127
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
|
||||||
|
// Loop points (in samples)
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: LoopMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SampleLayer {
|
impl SampleLayer {
|
||||||
|
|
@ -43,6 +61,9 @@ impl SampleLayer {
|
||||||
root_key: u8,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: LoopMode,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sample_data,
|
sample_data,
|
||||||
|
|
@ -52,6 +73,9 @@ impl SampleLayer {
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
loop_start,
|
||||||
|
loop_end,
|
||||||
|
loop_mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +86,114 @@ impl SampleLayer {
|
||||||
&& velocity >= self.velocity_min
|
&& velocity >= self.velocity_min
|
||||||
&& velocity <= self.velocity_max
|
&& 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<usize>
|
||||||
|
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
|
/// Active voice playing a sample
|
||||||
|
|
@ -75,6 +207,10 @@ struct Voice {
|
||||||
// Envelope
|
// Envelope
|
||||||
envelope_phase: EnvelopePhase,
|
envelope_phase: EnvelopePhase,
|
||||||
envelope_value: f32,
|
envelope_value: f32,
|
||||||
|
|
||||||
|
// Loop crossfade state
|
||||||
|
crossfade_buffer: Vec<f32>, // 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)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
|
@ -94,6 +230,8 @@ impl Voice {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
envelope_phase: EnvelopePhase::Attack,
|
envelope_phase: EnvelopePhase::Attack,
|
||||||
envelope_value: 0.0,
|
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,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: LoopMode,
|
||||||
) {
|
) {
|
||||||
let layer = SampleLayer::new(
|
let layer = SampleLayer::new(
|
||||||
sample_data,
|
sample_data,
|
||||||
|
|
@ -175,6 +316,9 @@ impl MultiSamplerNode {
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
loop_start,
|
||||||
|
loop_end,
|
||||||
|
loop_mode,
|
||||||
);
|
);
|
||||||
self.layers.push(layer);
|
self.layers.push(layer);
|
||||||
}
|
}
|
||||||
|
|
@ -188,10 +332,25 @@ impl MultiSamplerNode {
|
||||||
root_key: u8,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: LoopMode,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::audio::sample_loader::load_audio_file;
|
use crate::audio::sample_loader::load_audio_file;
|
||||||
|
|
||||||
let sample_data = load_audio_file(path)?;
|
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(
|
self.add_layer(
|
||||||
sample_data.samples,
|
sample_data.samples,
|
||||||
sample_data.sample_rate as f32,
|
sample_data.sample_rate as f32,
|
||||||
|
|
@ -200,6 +359,9 @@ impl MultiSamplerNode {
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
final_loop_start,
|
||||||
|
final_loop_end,
|
||||||
|
loop_mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store layer metadata for preset serialization
|
// Store layer metadata for preset serialization
|
||||||
|
|
@ -210,6 +372,9 @@ impl MultiSamplerNode {
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
loop_start: final_loop_start,
|
||||||
|
loop_end: final_loop_end,
|
||||||
|
loop_mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -236,6 +401,9 @@ impl MultiSamplerNode {
|
||||||
root_key: u8,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: LoopMode,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if layer_index >= self.layers.len() {
|
if layer_index >= self.layers.len() {
|
||||||
return Err("Layer index out of bounds".to_string());
|
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].root_key = root_key;
|
||||||
self.layers[layer_index].velocity_min = velocity_min;
|
self.layers[layer_index].velocity_min = velocity_min;
|
||||||
self.layers[layer_index].velocity_max = velocity_max;
|
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
|
// Update the layer info
|
||||||
if layer_index < self.layer_infos.len() {
|
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].root_key = root_key;
|
||||||
self.layer_infos[layer_index].velocity_min = velocity_min;
|
self.layer_infos[layer_index].velocity_min = velocity_min;
|
||||||
self.layer_infos[layer_index].velocity_max = velocity_max;
|
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(())
|
Ok(())
|
||||||
|
|
@ -429,10 +603,71 @@ impl AudioNode for MultiSamplerNode {
|
||||||
let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32);
|
let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32);
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Read sample with linear interpolation
|
// Read sample with linear interpolation and loop handling
|
||||||
let playhead = voice.playhead;
|
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;
|
let index = playhead.floor() as usize;
|
||||||
|
|
||||||
|
// 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() {
|
if index < layer.sample_data.len() {
|
||||||
let frac = playhead - playhead.floor();
|
let frac = playhead - playhead.floor();
|
||||||
let sample1 = layer.sample_data[index];
|
let sample1 = layer.sample_data[index];
|
||||||
|
|
@ -441,13 +676,36 @@ impl AudioNode for MultiSamplerNode {
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
sample1 + (sample2 - sample1) * frac
|
sample = sample1 + (sample2 - sample1) * frac;
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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 {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
sample = sample1 + (sample2 - sample1) * frac;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process envelope
|
// Process envelope
|
||||||
match voice.envelope_phase {
|
match voice.envelope_phase {
|
||||||
|
|
@ -484,7 +742,8 @@ impl AudioNode for MultiSamplerNode {
|
||||||
// Advance playhead
|
// Advance playhead
|
||||||
voice.playhead += speed_adjusted;
|
voice.playhead += speed_adjusted;
|
||||||
|
|
||||||
// Stop if we've reached the end
|
// 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 {
|
if voice.playhead >= layer.sample_data.len() as f32 {
|
||||||
voice.is_active = false;
|
voice.is_active = false;
|
||||||
break;
|
break;
|
||||||
|
|
@ -492,6 +751,7 @@ impl AudioNode for MultiSamplerNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.voices.clear();
|
self.voices.clear();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use super::nodes::LoopMode;
|
||||||
|
|
||||||
/// Sample data for preset serialization
|
/// Sample data for preset serialization
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -37,6 +38,16 @@ pub struct LayerData {
|
||||||
pub root_key: u8,
|
pub root_key: u8,
|
||||||
pub velocity_min: u8,
|
pub velocity_min: u8,
|
||||||
pub velocity_max: u8,
|
pub velocity_max: u8,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub loop_start: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub loop_end: Option<usize>,
|
||||||
|
#[serde(default = "default_loop_mode")]
|
||||||
|
pub loop_mode: LoopMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_loop_mode() -> LoopMode {
|
||||||
|
LoopMode::OneShot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializable representation of a node graph preset
|
/// Serializable representation of a node graph preset
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use crate::audio::{
|
||||||
TrackId,
|
TrackId,
|
||||||
};
|
};
|
||||||
use crate::audio::buffer_pool::BufferPoolStats;
|
use crate::audio::buffer_pool::BufferPoolStats;
|
||||||
|
use crate::audio::node_graph::nodes::LoopMode;
|
||||||
use crate::io::WaveformPeak;
|
use crate::io::WaveformPeak;
|
||||||
|
|
||||||
/// Commands sent from UI/control thread to audio thread
|
/// 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)
|
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
|
||||||
SamplerLoadSample(TrackId, u32, String),
|
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)
|
/// 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),
|
MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
|
||||||
/// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max)
|
/// 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),
|
MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
|
||||||
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
|
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
|
||||||
MultiSamplerRemoveLayer(TrackId, u32, usize),
|
MultiSamplerRemoveLayer(TrackId, u32, usize),
|
||||||
|
|
||||||
|
|
@ -215,6 +216,8 @@ pub enum AudioEvent {
|
||||||
GraphStateChanged(TrackId),
|
GraphStateChanged(TrackId),
|
||||||
/// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded
|
/// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded
|
||||||
GraphPresetLoaded(TrackId),
|
GraphPresetLoaded(TrackId),
|
||||||
|
/// Preset has been saved to file (track_id, preset_path)
|
||||||
|
GraphPresetSaved(TrackId, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronous queries sent from UI thread to audio thread
|
/// Synchronous queries sent from UI thread to audio thread
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use std::time::Duration;
|
||||||
pub struct MidiInputManager {
|
pub struct MidiInputManager {
|
||||||
connections: Arc<Mutex<Vec<ActiveMidiConnection>>>,
|
connections: Arc<Mutex<Vec<ActiveMidiConnection>>>,
|
||||||
active_track_id: Arc<Mutex<Option<TrackId>>>,
|
active_track_id: Arc<Mutex<Option<TrackId>>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
command_tx: Arc<Mutex<rtrb::Producer<Command>>>,
|
command_tx: Arc<Mutex<rtrb::Producer<Command>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,14 +148,16 @@ impl MidiInputManager {
|
||||||
println!("MIDI: Connected to: {}", port_name);
|
println!("MIDI: Connected to: {}", port_name);
|
||||||
|
|
||||||
// Need to recreate MidiInput for next iteration
|
// 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))?;
|
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
|
||||||
|
midi_in = _midi_in;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("MIDI: Failed to connect to {}: {}", port_name, e);
|
eprintln!("MIDI: Failed to connect to {}: {}", port_name, e);
|
||||||
// Recreate MidiInput to continue with other ports
|
// 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))?;
|
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
|
||||||
|
midi_in = _midi_in;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2523,6 +2523,7 @@ dependencies = [
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ chrono = "0.4"
|
||||||
daw-backend = { path = "../daw-backend" }
|
daw-backend = { path = "../daw-backend" }
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
rtrb = "0.3"
|
rtrb = "0.3"
|
||||||
|
tokio = { version = "1", features = ["sync", "time"] }
|
||||||
|
|
||||||
# Video decoding
|
# Video decoding
|
||||||
ffmpeg-next = "7.0"
|
ffmpeg-next = "7.0"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct AudioFileMetadata {
|
pub struct AudioFileMetadata {
|
||||||
|
|
@ -39,6 +40,8 @@ pub struct AudioState {
|
||||||
pub(crate) next_graph_node_id: u32,
|
pub(crate) next_graph_node_id: u32,
|
||||||
// Track next node ID for each VoiceAllocator template (VoiceAllocator backend ID -> next template node ID)
|
// Track next node ID for each VoiceAllocator template (VoiceAllocator backend ID -> next template node ID)
|
||||||
pub(crate) template_node_counters: HashMap<u32, u32>,
|
pub(crate) template_node_counters: HashMap<u32, u32>,
|
||||||
|
// Pending preset save notifications (preset_path -> oneshot sender)
|
||||||
|
pub(crate) preset_save_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioState {
|
impl Default for AudioState {
|
||||||
|
|
@ -51,6 +54,7 @@ impl Default for AudioState {
|
||||||
next_track_id: 0,
|
next_track_id: 0,
|
||||||
next_pool_index: 0,
|
next_pool_index: 0,
|
||||||
next_graph_node_id: 0,
|
next_graph_node_id: 0,
|
||||||
|
preset_save_waiters: Arc::new(Mutex::new(HashMap::new())),
|
||||||
template_node_counters: HashMap::new(),
|
template_node_counters: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,10 +63,20 @@ impl Default for AudioState {
|
||||||
/// Implementation of EventEmitter that uses Tauri's event system
|
/// Implementation of EventEmitter that uses Tauri's event system
|
||||||
struct TauriEventEmitter {
|
struct TauriEventEmitter {
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
|
preset_save_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter for TauriEventEmitter {
|
impl EventEmitter for TauriEventEmitter {
|
||||||
fn emit(&self, event: AudioEvent) {
|
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
|
// Serialize the event to the format expected by the frontend
|
||||||
let serialized_event = match event {
|
let serialized_event = match event {
|
||||||
AudioEvent::PlaybackPosition(time) => {
|
AudioEvent::PlaybackPosition(time) => {
|
||||||
|
|
@ -98,6 +112,9 @@ impl EventEmitter for TauriEventEmitter {
|
||||||
AudioEvent::GraphPresetLoaded(track_id) => {
|
AudioEvent::GraphPresetLoaded(track_id) => {
|
||||||
SerializedAudioEvent::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) => {
|
AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => {
|
||||||
SerializedAudioEvent::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
|
// 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)
|
// Get buffer size from audio_state (default is 256)
|
||||||
let buffer_size = audio_state.buffer_size;
|
let buffer_size = audio_state.buffer_size;
|
||||||
|
|
@ -1136,9 +1156,21 @@ pub async fn multi_sampler_add_layer(
|
||||||
root_key: u8,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
use daw_backend::audio::node_graph::nodes::LoopMode;
|
||||||
|
|
||||||
let mut audio_state = state.lock().unwrap();
|
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 {
|
if let Some(controller) = &mut audio_state.controller {
|
||||||
controller.multi_sampler_add_layer(
|
controller.multi_sampler_add_layer(
|
||||||
track_id,
|
track_id,
|
||||||
|
|
@ -1149,6 +1181,9 @@ pub async fn multi_sampler_add_layer(
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
loop_start,
|
||||||
|
loop_end,
|
||||||
|
loop_mode_enum,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1164,6 +1199,9 @@ pub struct LayerInfo {
|
||||||
pub root_key: u8,
|
pub root_key: u8,
|
||||||
pub velocity_min: u8,
|
pub velocity_min: u8,
|
||||||
pub velocity_max: u8,
|
pub velocity_max: u8,
|
||||||
|
pub loop_start: Option<usize>,
|
||||||
|
pub loop_end: Option<usize>,
|
||||||
|
pub loop_mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -1175,7 +1213,15 @@ pub async fn multi_sampler_get_layers(
|
||||||
eprintln!("[multi_sampler_get_layers] FUNCTION CALLED with track_id: {}, node_id: {}", track_id, node_id);
|
eprintln!("[multi_sampler_get_layers] FUNCTION CALLED with track_id: {}, node_id: {}", track_id, node_id);
|
||||||
use daw_backend::GraphPreset;
|
use daw_backend::GraphPreset;
|
||||||
|
|
||||||
|
// Set up oneshot channel to wait for preset save completion
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let (temp_path_str, preset_save_waiters) = {
|
||||||
let mut audio_state = state.lock().unwrap();
|
let mut audio_state = state.lock().unwrap();
|
||||||
|
|
||||||
|
// Clone preset_save_waiters first before any mutable borrows
|
||||||
|
let preset_save_waiters = audio_state.preset_save_waiters.clone();
|
||||||
|
|
||||||
if let Some(controller) = &mut audio_state.controller {
|
if let Some(controller) = &mut audio_state.controller {
|
||||||
// Use preset serialization to get node data including layers
|
// Use preset serialization to get node data including layers
|
||||||
// Use timestamp to ensure unique temp file for each query to avoid conflicts
|
// Use timestamp to ensure unique temp file for each query to avoid conflicts
|
||||||
|
|
@ -1187,6 +1233,12 @@ pub async fn multi_sampler_get_layers(
|
||||||
let temp_path_str = temp_path.to_string_lossy().to_string();
|
let temp_path_str = temp_path.to_string_lossy().to_string();
|
||||||
eprintln!("[multi_sampler_get_layers] Temp path: {}", temp_path_str);
|
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(
|
controller.graph_save_preset(
|
||||||
track_id,
|
track_id,
|
||||||
temp_path_str.clone(),
|
temp_path_str.clone(),
|
||||||
|
|
@ -1195,9 +1247,33 @@ pub async fn multi_sampler_get_layers(
|
||||||
vec![]
|
vec![]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Give the audio thread time to process
|
(temp_path_str, preset_save_waiters)
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
} 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
|
// Read the temp file and parse it
|
||||||
eprintln!("[multi_sampler_get_layers] Reading temp file...");
|
eprintln!("[multi_sampler_get_layers] Reading temp file...");
|
||||||
match std::fs::read_to_string(&temp_path) {
|
match std::fs::read_to_string(&temp_path) {
|
||||||
|
|
@ -1222,13 +1298,22 @@ pub async fn multi_sampler_get_layers(
|
||||||
// Check if it's a MultiSampler
|
// Check if it's a MultiSampler
|
||||||
if let daw_backend::audio::node_graph::preset::SampleData::MultiSampler { layers } = sample_data {
|
if let daw_backend::audio::node_graph::preset::SampleData::MultiSampler { layers } = sample_data {
|
||||||
eprintln!("[multi_sampler_get_layers] Returning {} layers", layers.len());
|
eprintln!("[multi_sampler_get_layers] Returning {} layers", layers.len());
|
||||||
return Ok(layers.iter().map(|layer| LayerInfo {
|
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(),
|
file_path: layer.file_path.clone().unwrap_or_default(),
|
||||||
key_min: layer.key_min,
|
key_min: layer.key_min,
|
||||||
key_max: layer.key_max,
|
key_max: layer.key_max,
|
||||||
root_key: layer.root_key,
|
root_key: layer.root_key,
|
||||||
velocity_min: layer.velocity_min,
|
velocity_min: layer.velocity_min,
|
||||||
velocity_max: layer.velocity_max,
|
velocity_max: layer.velocity_max,
|
||||||
|
loop_start: layer.loop_start,
|
||||||
|
loop_end: layer.loop_end,
|
||||||
|
loop_mode: loop_mode_str.to_string(),
|
||||||
|
}
|
||||||
}).collect());
|
}).collect());
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[multi_sampler_get_layers] sample_data is not MultiSampler type");
|
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
|
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]
|
#[tauri::command]
|
||||||
|
|
@ -1264,9 +1345,21 @@ pub async fn multi_sampler_update_layer(
|
||||||
root_key: u8,
|
root_key: u8,
|
||||||
velocity_min: u8,
|
velocity_min: u8,
|
||||||
velocity_max: u8,
|
velocity_max: u8,
|
||||||
|
loop_start: Option<usize>,
|
||||||
|
loop_end: Option<usize>,
|
||||||
|
loop_mode: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
use daw_backend::audio::node_graph::nodes::LoopMode;
|
||||||
|
|
||||||
let mut audio_state = state.lock().unwrap();
|
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 {
|
if let Some(controller) = &mut audio_state.controller {
|
||||||
controller.multi_sampler_update_layer(
|
controller.multi_sampler_update_layer(
|
||||||
track_id,
|
track_id,
|
||||||
|
|
@ -1277,6 +1370,9 @@ pub async fn multi_sampler_update_layer(
|
||||||
root_key,
|
root_key,
|
||||||
velocity_min,
|
velocity_min,
|
||||||
velocity_max,
|
velocity_max,
|
||||||
|
loop_start,
|
||||||
|
loop_end,
|
||||||
|
loop_mode_enum,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1432,6 +1528,7 @@ pub enum SerializedAudioEvent {
|
||||||
GraphConnectionError { track_id: u32, message: String },
|
GraphConnectionError { track_id: u32, message: String },
|
||||||
GraphStateChanged { track_id: u32 },
|
GraphStateChanged { track_id: u32 },
|
||||||
GraphPresetLoaded { 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
|
// audio_get_events command removed - events are now pushed via Tauri event system
|
||||||
|
|
|
||||||
163
src/main.js
163
src/main.js
|
|
@ -5270,6 +5270,83 @@ async function startup() {
|
||||||
|
|
||||||
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) {
|
function createPaneMenu(div) {
|
||||||
const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu
|
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.className = "vertical-grid pane";
|
||||||
div.setAttribute("data-pane-name", paneType.name);
|
div.setAttribute("data-pane-name", paneType.name);
|
||||||
header.style.height = "calc( 2 * var(--lineheight))";
|
header.style.height = "calc( 2 * var(--lineheight))";
|
||||||
|
|
@ -8708,7 +8795,10 @@ function nodeEditor() {
|
||||||
keyMax: layerConfig.keyMax,
|
keyMax: layerConfig.keyMax,
|
||||||
rootKey: layerConfig.rootKey,
|
rootKey: layerConfig.rootKey,
|
||||||
velocityMin: layerConfig.velocityMin,
|
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
|
// Wait a bit for the audio thread to process the add command
|
||||||
|
|
@ -8822,7 +8912,10 @@ function nodeEditor() {
|
||||||
keyMax: layer.key_max,
|
keyMax: layer.key_max,
|
||||||
rootKey: layer.root_key,
|
rootKey: layer.root_key,
|
||||||
velocityMin: layer.velocity_min,
|
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) {
|
if (layerConfig) {
|
||||||
|
|
@ -8835,7 +8928,10 @@ function nodeEditor() {
|
||||||
keyMax: layerConfig.keyMax,
|
keyMax: layerConfig.keyMax,
|
||||||
rootKey: layerConfig.rootKey,
|
rootKey: layerConfig.rootKey,
|
||||||
velocityMin: layerConfig.velocityMin,
|
velocityMin: layerConfig.velocityMin,
|
||||||
velocityMax: layerConfig.velocityMax
|
velocityMax: layerConfig.velocityMax,
|
||||||
|
loopStart: layerConfig.loopStart,
|
||||||
|
loopEnd: layerConfig.loopEnd,
|
||||||
|
loopMode: layerConfig.loopMode
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh the list
|
// Refresh the list
|
||||||
|
|
@ -9872,7 +9968,10 @@ function nodeEditor() {
|
||||||
keyMax: layerConfig.keyMax,
|
keyMax: layerConfig.keyMax,
|
||||||
rootKey: layerConfig.rootKey,
|
rootKey: layerConfig.rootKey,
|
||||||
velocityMin: layerConfig.velocityMin,
|
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
|
// 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 rootKey = existingConfig?.rootKey ?? 60;
|
||||||
const velocityMin = existingConfig?.velocityMin ?? 0;
|
const velocityMin = existingConfig?.velocityMin ?? 0;
|
||||||
const velocityMax = existingConfig?.velocityMax ?? 127;
|
const velocityMax = existingConfig?.velocityMax ?? 127;
|
||||||
|
const loopMode = existingConfig?.loopMode ?? 'oneshot';
|
||||||
|
const loopStart = existingConfig?.loopStart ?? null;
|
||||||
|
const loopEnd = existingConfig?.loopEnd ?? null;
|
||||||
|
|
||||||
// Create modal dialog
|
// Create modal dialog
|
||||||
const dialog = document.createElement('div');
|
const dialog = document.createElement('div');
|
||||||
|
|
@ -10723,6 +10825,33 @@ function showLayerConfigDialog(filePath, existingConfig = null) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Loop Mode</label>
|
||||||
|
<select id="loop-mode">
|
||||||
|
<option value="oneshot" ${loopMode === 'oneshot' ? 'selected' : ''}>One-Shot (play once)</option>
|
||||||
|
<option value="continuous" ${loopMode === 'continuous' ? 'selected' : ''}>Continuous (loop)</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-note" style="font-size: 11px; color: #888; margin-top: 4px;">
|
||||||
|
Continuous mode will auto-detect loop points if not specified
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="loop-points-group" class="form-group" style="display: ${loopMode === 'continuous' ? 'block' : 'none'};">
|
||||||
|
<label>Loop Points (optional, samples)</label>
|
||||||
|
<div class="form-group-inline">
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 11px; color: #888;">Start</label>
|
||||||
|
<input type="number" id="loop-start" min="0" value="${loopStart ?? ''}" placeholder="Auto" />
|
||||||
|
</div>
|
||||||
|
<span>-</span>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 11px; color: #888;">End</label>
|
||||||
|
<input type="number" id="loop-end" min="0" value="${loopEnd ?? ''}" placeholder="Auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-note" style="font-size: 11px; color: #888; margin-top: 4px;">
|
||||||
|
Leave empty to auto-detect optimal loop points
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn-cancel">Cancel</button>
|
<button type="button" class="btn-cancel">Cancel</button>
|
||||||
<button type="submit" class="btn-primary">${isEdit ? 'Update' : 'Add'} Layer</button>
|
<button type="submit" class="btn-primary">${isEdit ? 'Update' : 'Add'} Layer</button>
|
||||||
|
|
@ -10737,6 +10866,8 @@ function showLayerConfigDialog(filePath, existingConfig = null) {
|
||||||
const keyMinInput = dialog.querySelector('#key-min');
|
const keyMinInput = dialog.querySelector('#key-min');
|
||||||
const keyMaxInput = dialog.querySelector('#key-max');
|
const keyMaxInput = dialog.querySelector('#key-max');
|
||||||
const rootKeyInput = dialog.querySelector('#root-key');
|
const rootKeyInput = dialog.querySelector('#root-key');
|
||||||
|
const loopModeSelect = dialog.querySelector('#loop-mode');
|
||||||
|
const loopPointsGroup = dialog.querySelector('#loop-points-group');
|
||||||
|
|
||||||
const updateKeyMinName = () => {
|
const updateKeyMinName = () => {
|
||||||
const note = parseInt(keyMinInput.value) || 0;
|
const note = parseInt(keyMinInput.value) || 0;
|
||||||
|
|
@ -10757,6 +10888,12 @@ function showLayerConfigDialog(filePath, existingConfig = null) {
|
||||||
keyMaxInput.addEventListener('input', updateKeyMaxName);
|
keyMaxInput.addEventListener('input', updateKeyMaxName);
|
||||||
rootKeyInput.addEventListener('input', updateRootKeyName);
|
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
|
// Focus first input
|
||||||
setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100);
|
setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100);
|
||||||
|
|
||||||
|
|
@ -10775,6 +10912,13 @@ function showLayerConfigDialog(filePath, existingConfig = null) {
|
||||||
const rootKey = parseInt(rootKeyInput.value);
|
const rootKey = parseInt(rootKeyInput.value);
|
||||||
const velocityMin = parseInt(dialog.querySelector('#velocity-min').value);
|
const velocityMin = parseInt(dialog.querySelector('#velocity-min').value);
|
||||||
const velocityMax = parseInt(dialog.querySelector('#velocity-max').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
|
// Validate ranges
|
||||||
if (keyMin > keyMax) {
|
if (keyMin > keyMax) {
|
||||||
|
|
@ -10792,13 +10936,22 @@ function showLayerConfigDialog(filePath, existingConfig = null) {
|
||||||
return;
|
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();
|
dialog.remove();
|
||||||
resolve({
|
resolve({
|
||||||
keyMin,
|
keyMin,
|
||||||
keyMax,
|
keyMax,
|
||||||
rootKey,
|
rootKey,
|
||||||
velocityMin,
|
velocityMin,
|
||||||
velocityMax
|
velocityMax,
|
||||||
|
loopMode,
|
||||||
|
loopStart,
|
||||||
|
loopEnd
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,23 @@ button {
|
||||||
user-select: none;
|
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 {
|
.pane {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue