Add looped instrument samples and auto-detection of loop points

This commit is contained in:
Skyler Lehmkuhl 2025-11-12 08:53:08 -05:00
parent 3296d3ab6e
commit 30aa639460
13 changed files with 644 additions and 79 deletions

View File

@ -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

View File

@ -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,
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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;
}
}
}

1
src-tauri/Cargo.lock generated
View File

@ -2523,6 +2523,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-shell",
"tiny_http",
"tokio",
"tracing",
"tracing-subscriber",
]

View File

@ -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"

View File

@ -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

View File

@ -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
});
});

View File

@ -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;
}