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
|
||||
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(
|
||||
track_id,
|
||||
format!("Failed to save preset: {}", e)
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = self.event_tx.push(AudioEvent::GraphConnectionError(
|
||||
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;
|
||||
|
||||
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<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, 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<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, loop_start, loop_end, loop_mode));
|
||||
}
|
||||
|
||||
/// 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)
|
||||
playing_high_click: bool, // Which click we're currently playing
|
||||
|
||||
#[allow(dead_code)]
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<f32>;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -32,6 +45,11 @@ struct SampleLayer {
|
|||
// Velocity range: 0-127
|
||||
velocity_min: u8,
|
||||
velocity_max: u8,
|
||||
|
||||
// Loop points (in samples)
|
||||
loop_start: Option<usize>,
|
||||
loop_end: Option<usize>,
|
||||
loop_mode: LoopMode,
|
||||
}
|
||||
|
||||
impl SampleLayer {
|
||||
|
|
@ -43,6 +61,9 @@ impl SampleLayer {
|
|||
root_key: u8,
|
||||
velocity_min: u8,
|
||||
velocity_max: u8,
|
||||
loop_start: Option<usize>,
|
||||
loop_end: Option<usize>,
|
||||
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<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
|
||||
|
|
@ -75,6 +207,10 @@ struct Voice {
|
|||
// Envelope
|
||||
envelope_phase: EnvelopePhase,
|
||||
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)]
|
||||
|
|
@ -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<usize>,
|
||||
loop_end: Option<usize>,
|
||||
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<usize>,
|
||||
loop_end: Option<usize>,
|
||||
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<usize>,
|
||||
loop_end: Option<usize>,
|
||||
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,10 +603,71 @@ 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;
|
||||
|
||||
// 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];
|
||||
|
|
@ -441,13 +676,36 @@ impl AudioNode for MultiSamplerNode {
|
|||
} else {
|
||||
0.0
|
||||
};
|
||||
sample1 + (sample2 - sample1) * frac
|
||||
} else {
|
||||
0.0
|
||||
sample = sample1 + (sample2 - sample1) * frac;
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
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
|
||||
match voice.envelope_phase {
|
||||
|
|
@ -484,7 +742,8 @@ impl AudioNode for MultiSamplerNode {
|
|||
// Advance playhead
|
||||
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 {
|
||||
voice.is_active = false;
|
||||
break;
|
||||
|
|
@ -492,6 +751,7 @@ impl AudioNode for MultiSamplerNode {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.voices.clear();
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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<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, loop_start, loop_end, loop_mode)
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use std::time::Duration;
|
|||
pub struct MidiInputManager {
|
||||
connections: Arc<Mutex<Vec<ActiveMidiConnection>>>,
|
||||
active_track_id: Arc<Mutex<Option<TrackId>>>,
|
||||
#[allow(dead_code)]
|
||||
command_tx: Arc<Mutex<rtrb::Producer<Command>>>,
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2523,6 +2523,7 @@ dependencies = [
|
|||
"tauri-plugin-log",
|
||||
"tauri-plugin-shell",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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<Mutex<HashMap<String, oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
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<usize>,
|
||||
loop_end: Option<usize>,
|
||||
loop_mode: Option<String>,
|
||||
) -> 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<usize>,
|
||||
pub loop_end: Option<usize>,
|
||||
pub loop_mode: String,
|
||||
}
|
||||
|
||||
#[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);
|
||||
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();
|
||||
|
||||
// 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 {
|
||||
// Use preset serialization to get node data including layers
|
||||
// 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();
|
||||
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(),
|
||||
|
|
@ -1195,9 +1247,33 @@ pub async fn multi_sampler_get_layers(
|
|||
vec![]
|
||||
);
|
||||
|
||||
// Give the audio thread time to process
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
(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) {
|
||||
|
|
@ -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 {
|
||||
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<usize>,
|
||||
loop_end: Option<usize>,
|
||||
loop_mode: Option<String>,
|
||||
) -> 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
|
||||
|
|
|
|||
163
src/main.js
163
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) {
|
|||
</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">
|
||||
<button type="button" class="btn-cancel">Cancel</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 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
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue