From 66c848e218f5f157f72adabaee08e1f9138da89f Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 20 Feb 2026 01:59:37 -0500 Subject: [PATCH] Multi sample bulk import --- daw-backend/src/audio/engine.rs | 20 + .../audio/node_graph/nodes/multi_sampler.rs | 10 + daw-backend/src/command/types.rs | 2 + .../src/export/audio_exporter.rs | 4 +- .../lightningbeam-editor/src/main.rs | 3 + .../src/panes/node_graph/graph_data.rs | 22 + .../src/panes/node_graph/mod.rs | 65 ++ .../lightningbeam-editor/src/sample_import.rs | 810 ++++++++++++++++++ .../src/sample_import_dialog.rs | 242 ++++++ 9 files changed, 1176 insertions(+), 2 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/sample_import.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b07d840..35c2782 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1840,6 +1840,21 @@ impl Engine { } } + Command::MultiSamplerClearLayers(track_id, node_id) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + multi_sampler_node.clear_layers(); + } + } + } + } + Command::AutomationAddKeyframe(track_id, node_id, time, value, interpolation_str, ease_out, ease_in) => { use crate::audio::node_graph::nodes::{AutomationInputNode, AutomationKeyframe, InterpolationType}; @@ -3329,6 +3344,11 @@ impl EngineController { let _ = self.command_tx.push(Command::MultiSamplerRemoveLayer(track_id, node_id, layer_index)); } + /// Clear all layers from a MultiSampler node + pub fn multi_sampler_clear_layers(&mut self, track_id: TrackId, node_id: u32) { + let _ = self.command_tx.push(Command::MultiSamplerClearLayers(track_id, node_id)); + } + /// Send a synchronous query and wait for the response /// This blocks until the audio thread processes the query /// Generic method that works with any Query/QueryResponse pair diff --git a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs index a482100..bda7a3d 100644 --- a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs @@ -458,6 +458,16 @@ impl MultiSamplerNode { Ok(()) } + /// Remove all layers + pub fn clear_layers(&mut self) { + self.layers.clear(); + self.layer_infos.clear(); + // Stop all active voices + for voice in &mut self.voices { + voice.is_active = false; + } + } + /// Find the best matching layer for a given note and velocity fn find_layer(&self, note: u8, velocity: u8) -> Option { self.layers diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 42ee4e8..ccb7adc 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -194,6 +194,8 @@ pub enum Command { MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option, Option, LoopMode), /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) MultiSamplerRemoveLayer(TrackId, u32, usize), + /// Clear all layers from a MultiSampler node (track_id, node_id) + MultiSamplerClearLayers(TrackId, u32), // Automation Input Node commands /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs index 4d2e6e4..928d7f8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs @@ -431,7 +431,7 @@ mod tests { let mut settings = AudioExportSettings::default(); settings.sample_rate = 0; // Invalid - let project = Project::new(); + let project = Project::new(44100); let pool = AudioPool::new(); let midi_pool = MidiClipPool::new(); let cancel_flag = Arc::new(AtomicBool::new(false)); @@ -452,7 +452,7 @@ mod tests { #[test] fn test_export_audio_cancellation() { let settings = AudioExportSettings::default(); - let mut project = Project::new(); + let mut project = Project::new(44100); let pool = AudioPool::new(); let midi_pool = MidiClipPool::new(); let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index d93552c..fafdc16 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -40,6 +40,9 @@ use effect_thumbnails::EffectThumbnailGenerator; mod debug_overlay; +mod sample_import; +mod sample_import_dialog; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 160a4a3..f285d9f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -224,6 +224,8 @@ pub enum PendingSamplerLoad { MultiFromFolder { node_id: NodeId, folder_id: uuid::Uuid }, /// Open a file/folder dialog to load into a MultiSampler MultiFromFilesystem { node_id: NodeId, backend_node_id: u32 }, + /// Open a folder dialog for batch import with heuristic mapping + MultiFromFolderFilesystem { node_id: NodeId, backend_node_id: u32 }, } /// Custom graph state - can track selected nodes, etc. @@ -261,6 +263,8 @@ pub struct GraphState { pub draw_vms: HashMap, /// Pending param changes from draw block (node_id, param_index, new_value) pub pending_draw_param_changes: Vec<(NodeId, u32, f32)>, + /// Active sample import dialog (folder import with heuristic mapping) + pub sample_import_dialog: Option, } impl Default for GraphState { @@ -283,6 +287,7 @@ impl Default for GraphState { pending_script_sample_load: None, draw_vms: HashMap::new(), pending_draw_param_changes: Vec::new(), + sample_import_dialog: None, } } } @@ -661,6 +666,14 @@ impl NodeTemplateTrait for NodeTemplate { } NodeTemplate::MultiSampler => { graph.add_input_param(node_id, "MIDI In".into(), DataType::Midi, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); + graph.add_input_param(node_id, "Gain".into(), DataType::CV, + ValueType::float_param(1.0, 0.0, 2.0, "", 0, None), InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Attack".into(), DataType::CV, + ValueType::float_param(0.01, 0.001, 1.0, " s", 1, None), InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Release".into(), DataType::CV, + ValueType::float_param(0.1, 0.01, 5.0, " s", 2, None), InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Transpose".into(), DataType::CV, + ValueType::float_param(0.0, -24.0, 24.0, " st", 3, None), InputParamKind::ConstantOnly, true); graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio); } NodeTemplate::Reverb => { @@ -1123,6 +1136,15 @@ impl NodeDataTrait for NodeData { } close_popup = true; } + if is_multi { + if ui.button("Import Folder...").clicked() { + user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFolderFilesystem { + node_id, + backend_node_id, + }); + close_popup = true; + } + } }); if close_popup { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index c719c95..2ffe3cd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -793,6 +793,23 @@ impl NodeGraphPane { } } } + graph_data::PendingSamplerLoad::MultiFromFolderFilesystem { node_id, backend_node_id } => { + if let Some(path) = rfd::FileDialog::new().pick_folder() { + match crate::sample_import::scan_folder(&path) { + Ok(samples) => { + let scan_result = crate::sample_import::build_import_layers(samples, &path); + let track_id = backend_track_id; + let dialog = crate::sample_import_dialog::SampleImportDialog::new( + path, scan_result, track_id, backend_node_id, node_id, + ); + self.user_state.sample_import_dialog = Some(dialog); + } + Err(e) => { + eprintln!("Failed to scan folder '{}': {}", path.display(), e); + } + } + } + } } } @@ -2584,6 +2601,54 @@ impl crate::panes::PaneRenderer for NodeGraphPane { self.handle_pending_sampler_load(load, shared); } + // Render sample import dialog if active + if let Some(dialog) = &mut self.user_state.sample_import_dialog { + let still_open = dialog.show(ui.ctx()); + if !still_open { + // Dialog closed — check if confirmed + let dialog = self.user_state.sample_import_dialog.take().unwrap(); + if dialog.confirmed { + let backend_track_id = dialog.track_id; + let backend_node_id = dialog.backend_node_id; + let node_id = dialog.node_id; + let loop_mode = dialog.loop_mode; + let enabled_layers: Vec<_> = dialog.scan_result.layers.iter() + .filter(|l| l.enabled) + .collect(); + let layer_count = enabled_layers.len(); + let folder_name = dialog.folder_path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Folder".to_string()); + + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + // Clear existing layers before importing new ones + controller.multi_sampler_clear_layers(backend_track_id, backend_node_id); + for layer in &enabled_layers { + controller.multi_sampler_add_layer( + backend_track_id, + backend_node_id, + layer.path.to_string_lossy().to_string(), + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + None, None, // loop points auto-detected by backend + loop_mode, + ); + } + } + + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some( + format!("{} ({} layers)", folder_name, layer_count) + ); + } + } + } + } + // Handle pending script sample load requests from bottom_ui() if let Some(load) = self.user_state.pending_script_sample_load.take() { self.handle_pending_script_sample_load(load, shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs b/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs new file mode 100644 index 0000000..4b9f553 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs @@ -0,0 +1,810 @@ +//! Sample filename parsing and folder scanning for MultiSampler import. +//! +//! Handles various naming conventions found in sample libraries: +//! - Note-octave: `a#2`, `C4`, `Gb3` (Horns, Philharmonia) +//! - Octave_note: `2_A`, `3_Gb`, `4_Bb` (NoBudgetOrch) +//! - Dynamic velocity markers: `ff`, `mp`, `p`, `f` +//! - Round-robin variants: `rr1`, `rr2`, or `_2` suffix +//! - Loop hints from filename (`-loop`, `sus`) and folder path (`Sustain/`, `Pizzicato/`) + +use std::path::{Path, PathBuf}; +use std::collections::HashMap; +use daw_backend::audio::node_graph::nodes::LoopMode; + +// ─── Audio file extensions ─────────────────────────────────────────────────── + +const AUDIO_EXTENSIONS: &[&str] = &["wav", "aif", "aiff", "flac", "mp3", "ogg"]; + +fn is_audio_file(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTENSIONS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +// ─── Note name ↔ MIDI conversion ───────────────────────────────────────────── + +/// Parse a note letter + optional accidental into a semitone offset (0=C, 11=B). +/// Returns (semitone, chars_consumed). +fn parse_note_letter(s: &str) -> Option<(u8, usize)> { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return None; + } + let letter = bytes[0].to_ascii_uppercase(); + let base = match letter { + b'C' => 0, + b'D' => 2, + b'E' => 4, + b'F' => 5, + b'G' => 7, + b'A' => 9, + b'B' => 11, + _ => return None, + }; + if bytes.len() > 1 && bytes[1] == b'#' { + Some(((base + 1) % 12, 2)) + } else if bytes.len() > 1 && bytes[1] == b'b' { + Some(((base + 11) % 12, 2)) + } else { + Some((base, 1)) + } +} + +/// Convert a note name like "C4", "A#3", "Bb2" to a MIDI note number. +pub fn note_name_to_midi(note: &str, octave: i8) -> Option { + let (semitone, _) = parse_note_letter(note)?; + let midi = (octave as i32 + 1) * 12 + semitone as i32; + if (0..=127).contains(&midi) { + Some(midi as u8) + } else { + None + } +} + +/// Format a MIDI note number as a note name (e.g., 60 → "C4"). +pub fn midi_to_note_name(midi: u8) -> String { + const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + let octave = (midi as i32 / 12) - 1; + let name = NAMES[midi as usize % 12]; + format!("{}{}", name, octave) +} + +// ─── Filename parsing ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub enum LoopHint { + Auto, + OneShot, + Loop, +} + +#[derive(Debug, Clone)] +pub struct ParsedSample { + pub path: PathBuf, + pub filename: String, + pub detected_note: Option, + pub velocity_marker: Option, + pub rr_index: Option, + pub is_percussion: bool, + pub loop_hint: LoopHint, +} + +/// Try to find a note-octave pattern like "a#2", "C4", "Gb3" in a token. +/// Returns (midi_note, token_is_consumed) if found. +fn try_note_octave(token: &str) -> Option { + let bytes = token.as_bytes(); + if bytes.is_empty() { + return None; + } + // Must start with a note letter + let first = bytes[0].to_ascii_uppercase(); + if !matches!(first, b'A'..=b'G') { + return None; + } + let (semitone, consumed) = parse_note_letter(token)?; + let rest = &token[consumed..]; + // Next must be an octave digit (optionally preceded by -) + let octave_str = rest; + let octave: i8 = octave_str.parse().ok()?; + if (-1..=9).contains(&octave) { + let midi = (octave as i32 + 1) * 12 + semitone as i32; + if (0..=127).contains(&midi) { + return Some(midi as u8); + } + } + None +} + +/// Try to find an octave_note pattern like "2_A", "3_Gb" across two adjacent tokens. +/// token1 is the octave number, token2 is the note name. +fn try_octave_note(octave_token: &str, note_token: &str) -> Option { + let octave: i8 = octave_token.parse().ok()?; + if !(-1..=9).contains(&octave) { + return None; + } + // note_token should be just a note letter + optional accidental, no octave digit + let (semitone, consumed) = parse_note_letter(note_token)?; + // Remaining after note should be empty (pure note token) + if consumed != note_token.len() { + return None; + } + let midi = (octave as i32 + 1) * 12 + semitone as i32; + if (0..=127).contains(&midi) { + Some(midi as u8) + } else { + None + } +} + +/// Dynamic markings sorted by loudness. +const DYNAMICS: &[&str] = &["ppp", "pp", "p", "mp", "mf", "f", "ff", "fff"]; + +/// Check if a token is a dynamic marking (exact match, case-insensitive). +fn is_dynamic_marker(token: &str) -> bool { + let lower = token.to_lowercase(); + DYNAMICS.contains(&lower.as_str()) +} + +/// Get the sort order for a velocity marker (lower = softer). +pub fn velocity_marker_order(marker: &str) -> u8 { + let lower = marker.to_lowercase(); + match lower.as_str() { + "ppp" => 0, + "pp" => 1, + "p" => 2, + "mp" => 3, + "mf" => 4, + "f" => 5, + "ff" => 6, + "fff" => 7, + _ => { + // Numeric markers: v1, v2, v3... + if let Some(rest) = lower.strip_prefix('v') { + if let Ok(n) = rest.parse::() { + return n.saturating_add(10); // offset to separate from dynamics + } + } + 128 // unknown, sort last + } + } +} + +/// Check if a token is a round-robin marker like "rr1", "rr2". +fn parse_rr_marker(token: &str) -> Option { + let lower = token.to_lowercase(); + lower.strip_prefix("rr")?.parse().ok() +} + +/// Detect loop hints from filename tokens and folder path. +fn detect_loop_hint(tokens: &[&str], full_path: &Path) -> LoopHint { + // Check filename tokens + for token in tokens { + let lower = token.to_lowercase(); + if lower == "loop" { + return LoopHint::Loop; + } + if matches!(lower.as_str(), "sus" | "sustain") { + return LoopHint::Loop; + } + if matches!(lower.as_str(), "stac" | "stc" | "staccato" | "piz" | "pizz" | "pizzicato") { + return LoopHint::OneShot; + } + } + // Check folder path components + for component in full_path.components() { + if let std::path::Component::Normal(name) = component { + let name_lower = name.to_string_lossy().to_lowercase(); + if matches!(name_lower.as_str(), "sustain" | "vibrato" | "tremolo") { + return LoopHint::Loop; + } + if matches!(name_lower.as_str(), "pizzicato" | "staccato") { + return LoopHint::OneShot; + } + } + } + LoopHint::Auto +} + +/// Tokenize a filename stem on common delimiters. +fn tokenize(stem: &str) -> Vec<&str> { + stem.split(|c: char| c == '-' || c == '_' || c == '.' || c == ' ') + .filter(|s| !s.is_empty()) + .collect() +} + +/// Parse a sample filename to extract note, velocity, round-robin, and loop hint info. +/// `folder_path` is used for loop/articulation context from parent directory names. +pub fn parse_sample_filename(path: &Path, folder_path: &Path) -> ParsedSample { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Strip extension to get stem + let stem = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.clone()); + + let tokens = tokenize(&stem); + let loop_hint = detect_loop_hint(&tokens, path); + + let mut detected_note: Option = None; + let mut velocity_marker: Option = None; + let mut rr_index: Option = None; + let mut note_token_indices: Vec = Vec::new(); + + // Pass 1: Find notes using note-octave format (e.g., "a#2", "C4") + // Use last match as it's most reliable + for (i, token) in tokens.iter().enumerate() { + if let Some(midi) = try_note_octave(token) { + detected_note = Some(midi); + note_token_indices.clear(); + note_token_indices.push(i); + } + } + + // Pass 2: If no note-octave found, try octave_note format (e.g., "2" + "A", "3" + "Gb") + if detected_note.is_none() && tokens.len() >= 2 { + for i in 0..tokens.len() - 1 { + if let Some(midi) = try_octave_note(tokens[i], tokens[i + 1]) { + detected_note = Some(midi); + note_token_indices.clear(); + note_token_indices.push(i); + note_token_indices.push(i + 1); + } + } + } + + // Pass 3: Find velocity markers and round-robin + for (i, token) in tokens.iter().enumerate() { + if note_token_indices.contains(&i) { + continue; + } + + // Round-robin: "rr1", "rr2" + if let Some(rr) = parse_rr_marker(token) { + rr_index = Some(rr); + continue; + } + + // Dynamic markers: "ff", "mp", "p", "f" etc. (must be exact token match) + if is_dynamic_marker(token) { + velocity_marker = Some(token.to_lowercase()); + continue; + } + + // Numeric velocity: "v1", "v2" + let lower = token.to_lowercase(); + if lower.starts_with('v') && lower[1..].parse::().is_ok() { + velocity_marker = Some(lower); + continue; + } + } + + // Pass 4: For octave_note format, check if trailing bare number after note is RR variant + // e.g., "5_C_2" → tokens ["5", "C", "2"] — "2" is RR, not a note + if detected_note.is_some() && rr_index.is_none() && note_token_indices.len() == 2 { + let after_note = note_token_indices[1] + 1; + if after_note < tokens.len() { + let candidate = tokens[after_note]; + // If it's a bare small number and NOT a dynamic marker, treat as RR + if let Ok(n) = candidate.parse::() { + if n >= 1 && n <= 20 && !is_dynamic_marker(candidate) { + rr_index = Some(n); + } + } + } + } + + // Pass 5: Check for suffix velocity in octave_note format + // e.g., "3_A_f.wav" → the "f" after note could be velocity + // Only apply if we used octave_note format and velocity wasn't already found + if velocity_marker.is_none() && note_token_indices.len() == 2 { + let after_note = note_token_indices[1] + 1; + if after_note < tokens.len() { + let candidate = tokens[after_note]; + if is_dynamic_marker(candidate) && rr_index.as_ref().map_or(true, |&rr| { + // If rr was parsed from this position, don't also treat it as velocity + after_note < tokens.len() - 1 || rr == 0 + }) { + velocity_marker = Some(candidate.to_lowercase()); + } + } + } + + ParsedSample { + path: path.to_path_buf(), + filename, + detected_note, + velocity_marker, + rr_index, + is_percussion: false, // set later in percussion pass + loop_hint, + } +} + +// ─── GM Drum Map ───────────────────────────────────────────────────────────── + +/// GM drum note assignments for common percussion instrument names. +const GM_DRUM_MAP: &[(&[&str], u8)] = &[ + (&["kick", "bass_drum", "bassdrum", "bdrum"], 36), + (&["rimshot", "rim"], 37), + (&["snare"], 38), + (&["clap", "handclap"], 39), + (&["hihat", "hi_hat", "hh"], 42), + (&["tom"], 45), + (&["crash"], 49), + (&["ride"], 51), + (&["cymbal"], 52), + (&["tamtam", "tam_tam", "gong"], 52), + (&["tambourine", "tamb"], 54), + (&["cowbell"], 56), + (&["bongo"], 60), + (&["conga"], 63), + (&["shaker"], 70), + (&["woodblock"], 76), + (&["triangle"], 81), + (&["bar_chimes", "chime", "chimes"], 84), + (&["castanets"], 85), +]; + +/// Try to match a filename/path against GM drum instrument names. +fn gm_drum_note(filename: &str, relative_path: &str) -> Option { + let search = format!("{}/{}", relative_path, filename).to_lowercase(); + for (names, midi) in GM_DRUM_MAP { + for name in *names { + if search.contains(name) { + return Some(*midi); + } + } + } + None +} + +// ─── Folder scanning ───────────────────────────────────────────────────────── + +/// Recursively collect audio files from a folder. +fn collect_audio_files(dir: &Path, files: &mut Vec) -> std::io::Result<()> { + if !dir.is_dir() { + return Ok(()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_audio_files(&path, files)?; + } else if is_audio_file(&path) { + files.push(path); + } + } + Ok(()) +} + +/// Scan a folder for audio samples, parse filenames, and apply percussion detection. +pub fn scan_folder(folder_path: &Path) -> std::io::Result> { + let mut files = Vec::new(); + collect_audio_files(folder_path, &mut files)?; + + let mut samples: Vec = files.iter() + .map(|path| parse_sample_filename(path, folder_path)) + .collect(); + + // Percussion pass: for samples with no detected note, try GM drum mapping + let mut used_drum_notes: Vec = Vec::new(); + for sample in &mut samples { + if sample.detected_note.is_some() { + continue; + } + let relative = sample.path.strip_prefix(folder_path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(drum_note) = gm_drum_note(&sample.filename, &relative) { + // Avoid duplicate drum note assignments — if already taken, offset + let mut note = drum_note; + while used_drum_notes.contains(¬e) && note < 127 { + note += 1; + } + sample.detected_note = Some(note); + sample.is_percussion = true; + used_drum_notes.push(note); + } + } + + // For remaining unmapped percussion: assign sequential notes from 36 + let mut next_drum = 36u8; + for sample in &mut samples { + if sample.detected_note.is_some() { + continue; + } + // Skip notes already used + while used_drum_notes.contains(&next_drum) && next_drum < 127 { + next_drum += 1; + } + if next_drum <= 127 { + sample.detected_note = Some(next_drum); + sample.is_percussion = true; + used_drum_notes.push(next_drum); + next_drum += 1; + } + } + + // Sort by note, then velocity, then RR index + samples.sort_by(|a, b| { + a.detected_note.cmp(&b.detected_note) + .then_with(|| { + let va = a.velocity_marker.as_deref().map(velocity_marker_order).unwrap_or(128); + let vb = b.velocity_marker.as_deref().map(velocity_marker_order).unwrap_or(128); + va.cmp(&vb) + }) + .then_with(|| a.rr_index.cmp(&b.rr_index)) + }); + + Ok(samples) +} + +// ─── Import layer building ─────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct ImportLayer { + pub path: PathBuf, + pub filename: String, + pub root_key: u8, + pub key_min: u8, + pub key_max: u8, + pub velocity_min: u8, + pub velocity_max: u8, + pub enabled: bool, + pub is_percussion: bool, +} + +pub struct FolderScanResult { + pub layers: Vec, + pub unmapped: Vec, + pub loop_mode: LoopMode, + pub velocity_markers: Vec, + pub velocity_ranges: Vec<(String, u8, u8)>, + pub detected_articulation: Option, +} + +/// Compute auto key ranges for a sorted list of unique MIDI notes. +/// Each note gets the range from midpoint-to-previous to midpoint-to-next. +fn auto_key_ranges(notes: &[u8]) -> Vec<(u8, u8)> { + if notes.is_empty() { + return Vec::new(); + } + if notes.len() == 1 { + return vec![(0, 127)]; + } + let mut ranges = Vec::with_capacity(notes.len()); + for i in 0..notes.len() { + let min = if i == 0 { + 0 + } else { + ((notes[i - 1] as u16 + notes[i] as u16 + 1) / 2) as u8 + }; + let max = if i == notes.len() - 1 { + 127 + } else { + ((notes[i] as u16 + notes[i + 1] as u16) / 2) as u8 + }; + ranges.push((min, max)); + } + ranges +} + +/// Compute velocity ranges by evenly splitting 0-127 among sorted markers. +fn auto_velocity_ranges(markers: &[String]) -> Vec<(String, u8, u8)> { + if markers.is_empty() { + return Vec::new(); + } + if markers.len() == 1 { + return vec![(markers[0].clone(), 0, 127)]; + } + let n = markers.len(); + let step = 128.0 / n as f32; + markers.iter().enumerate().map(|(i, m)| { + let min = (i as f32 * step).round() as u8; + let max = if i == n - 1 { 127 } else { ((i + 1) as f32 * step).round() as u8 - 1 }; + (m.clone(), min, max) + }).collect() +} + +/// Detect global loop mode from all parsed samples' loop hints. +fn detect_global_loop_mode(samples: &[ParsedSample]) -> LoopMode { + let mut loop_count = 0; + let mut oneshot_count = 0; + for s in samples { + match s.loop_hint { + LoopHint::Loop => loop_count += 1, + LoopHint::OneShot => oneshot_count += 1, + LoopHint::Auto => {} + } + } + if loop_count > oneshot_count { + LoopMode::Continuous + } else if oneshot_count > 0 { + LoopMode::OneShot + } else { + LoopMode::OneShot // default when no hints + } +} + +/// Detect articulation from folder path. +fn detect_articulation(folder_path: &Path) -> Option { + for component in folder_path.components().rev() { + if let std::path::Component::Normal(name) = component { + let lower = name.to_string_lossy().to_lowercase(); + match lower.as_str() { + "sustain" | "vibrato" | "tremolo" | "pizzicato" | "staccato" | + "legato" | "marcato" | "spiccato" | "arco" => { + return Some(name.to_string_lossy().to_string()); + } + _ => {} + } + } + } + None +} + +/// Build import layers from parsed samples with auto key ranges and velocity mapping. +pub fn build_import_layers(samples: Vec, folder_path: &Path) -> FolderScanResult { + let loop_mode = detect_global_loop_mode(&samples); + let detected_articulation = detect_articulation(folder_path); + + // Separate mapped vs unmapped + let mut mapped: Vec = Vec::new(); + let mut unmapped: Vec = Vec::new(); + for s in samples { + if s.detected_note.is_some() { + mapped.push(s); + } else { + unmapped.push(s); + } + } + + // Collect unique velocity markers (sorted by loudness) + let mut velocity_markers: Vec = mapped.iter() + .filter_map(|s| s.velocity_marker.clone()) + .collect::>() + .into_iter() + .collect(); + velocity_markers.sort_by_key(|m| velocity_marker_order(m)); + + let velocity_ranges = auto_velocity_ranges(&velocity_markers); + + // Build velocity lookup: marker → (min, max) + let vel_map: HashMap = velocity_ranges.iter() + .map(|(m, min, max)| (m.clone(), (*min, *max))) + .collect(); + + // Collect unique notes for auto key range computation + let mut unique_notes: Vec = mapped.iter() + .filter_map(|s| s.detected_note) + .collect::>() + .into_iter() + .collect(); + unique_notes.sort(); + + let key_ranges = auto_key_ranges(&unique_notes); + let note_to_range: HashMap = unique_notes.iter() + .zip(key_ranges.iter()) + .map(|(¬e, &range)| (note, range)) + .collect(); + + // Build layers + let layers: Vec = mapped.iter().map(|s| { + let root_key = s.detected_note.unwrap(); + let (key_min, key_max) = note_to_range.get(&root_key).copied().unwrap_or((0, 127)); + let (vel_min, vel_max) = s.velocity_marker.as_ref() + .and_then(|m| vel_map.get(m)) + .copied() + .unwrap_or((0, 127)); + + ImportLayer { + path: s.path.clone(), + filename: s.filename.clone(), + root_key, + key_min, + key_max, + velocity_min: vel_min, + velocity_max: vel_max, + enabled: true, + is_percussion: s.is_percussion, + } + }).collect(); + + FolderScanResult { + layers, + unmapped, + loop_mode, + velocity_markers, + velocity_ranges, + detected_articulation, + } +} + +/// Recompute key ranges for layers based on their current root_key values. +/// Only affects enabled, non-percussion layers. +pub fn recalc_key_ranges(layers: &mut [ImportLayer]) { + let mut unique_notes: Vec = layers.iter() + .filter(|l| l.enabled && !l.is_percussion) + .map(|l| l.root_key) + .collect::>() + .into_iter() + .collect(); + unique_notes.sort(); + + let ranges = auto_key_ranges(&unique_notes); + let note_to_range: HashMap = unique_notes.iter() + .zip(ranges.iter()) + .map(|(¬e, &range)| (note, range)) + .collect(); + + for layer in layers.iter_mut() { + if !layer.enabled || layer.is_percussion { + continue; + } + if let Some(&(min, max)) = note_to_range.get(&layer.root_key) { + layer.key_min = min; + layer.key_max = max; + } + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_note_name_to_midi() { + assert_eq!(note_name_to_midi("C", 4), Some(60)); + assert_eq!(note_name_to_midi("A", 4), Some(69)); + assert_eq!(note_name_to_midi("A#", 3), Some(58)); + assert_eq!(note_name_to_midi("Bb", 2), Some(46)); + assert_eq!(note_name_to_midi("C", -1), Some(0)); + assert_eq!(note_name_to_midi("G", 9), Some(127)); + } + + #[test] + fn test_note_octave_format() { + // Horns: horns-sus-ff-a#2-PB-loop.wav + let p = parse_sample_filename( + Path::new("/samples/horns-sus-ff-a#2-PB-loop.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(46)); // A#2 + assert_eq!(p.velocity_marker, Some("ff".to_string())); + assert_eq!(p.loop_hint, LoopHint::Loop); + + // Philharmonia: viola_A#3-staccato-rr1-PB.wav + let p = parse_sample_filename( + Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(58)); // A#3 + assert_eq!(p.rr_index, Some(1)); + assert_eq!(p.loop_hint, LoopHint::OneShot); + + // Bare note: A1.mp3 + let p = parse_sample_filename( + Path::new("/samples/A1.mp3"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(33)); // A1 + } + + #[test] + fn test_octave_note_format() { + // NoBudgetOrch: 2_A-PB.wav + let p = parse_sample_filename( + Path::new("/samples/2_A-PB.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(45)); // A2 + + // 3_Gb-PB.wav + let p = parse_sample_filename( + Path::new("/samples/3_Gb-PB.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(54)); // Gb3 + + // 1_Bb.wav + let p = parse_sample_filename( + Path::new("/samples/1_Bb.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(34)); // Bb1 + } + + #[test] + fn test_velocity_suffix() { + // NoBudgetOrch TubularBells: 3_A_f.wav + let p = parse_sample_filename( + Path::new("/samples/3_A_f.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(57)); // A3 + assert_eq!(p.velocity_marker, Some("f".to_string())); + + // 3_C_p.wav + let p = parse_sample_filename( + Path::new("/samples/3_C_p.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(48)); // C3 + assert_eq!(p.velocity_marker, Some("p".to_string())); + } + + #[test] + fn test_rr_detection() { + // NoBudgetOrch: 5_C_2-PB.wav → C5, rr2 + let p = parse_sample_filename( + Path::new("/samples/5_C_2-PB.wav"), + Path::new("/samples"), + ); + assert_eq!(p.detected_note, Some(72)); // C5 + assert_eq!(p.rr_index, Some(2)); + + // rr marker: viola_A#3-staccato-rr1-PB.wav + let p = parse_sample_filename( + Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"), + Path::new("/samples"), + ); + assert_eq!(p.rr_index, Some(1)); + } + + #[test] + fn test_loop_hints_from_folder() { + let p = parse_sample_filename( + Path::new("/libs/Cello/Sustain/2_A.wav"), + Path::new("/libs/Cello/Sustain"), + ); + assert_eq!(p.loop_hint, LoopHint::Loop); + + let p = parse_sample_filename( + Path::new("/libs/Cello/Pizzicato/2_A-PB.wav"), + Path::new("/libs/Cello/Pizzicato"), + ); + assert_eq!(p.loop_hint, LoopHint::OneShot); + } + + #[test] + fn test_gm_drum_mapping() { + assert_eq!(gm_drum_note("snare-lh-ff-PB.wav", "Percussion"), Some(38)); + assert_eq!(gm_drum_note("bass_drum-f-PB.wav", "Percussion"), Some(36)); + assert_eq!(gm_drum_note("castanets_mf1-PB.wav", "Percussion"), Some(85)); + } + + #[test] + fn test_auto_key_ranges() { + let notes = vec![36, 48, 60, 72]; + let ranges = auto_key_ranges(¬es); + assert_eq!(ranges[0], (0, 42)); // 36: 0 to (36+48)/2=42 + assert_eq!(ranges[1], (43, 54)); // 48: 43 to (48+60)/2=54 + assert_eq!(ranges[2], (55, 66)); // 60: 55 to (60+72)/2=66 + assert_eq!(ranges[3], (67, 127)); // 72: 67 to 127 + } + + #[test] + fn test_auto_velocity_ranges() { + let markers = vec!["p".to_string(), "f".to_string()]; + let ranges = auto_velocity_ranges(&markers); + assert_eq!(ranges[0], ("p".to_string(), 0, 63)); + assert_eq!(ranges[1], ("f".to_string(), 64, 127)); + } + + #[test] + fn test_velocity_marker_order() { + assert!(velocity_marker_order("p") < velocity_marker_order("f")); + assert!(velocity_marker_order("pp") < velocity_marker_order("mp")); + assert!(velocity_marker_order("mf") < velocity_marker_order("ff")); + assert!(velocity_marker_order("v1") < velocity_marker_order("v2")); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs new file mode 100644 index 0000000..dcf258b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs @@ -0,0 +1,242 @@ +//! Import dialog for MultiSampler folder import. +//! +//! Shows a preview of parsed samples with editable note mappings, velocity ranges, +//! and loop mode before committing the import. + +use eframe::egui; +use egui_node_graph2::NodeId; +use std::path::PathBuf; + +use crate::sample_import::{ + FolderScanResult, ImportLayer, midi_to_note_name, recalc_key_ranges, +}; +use daw_backend::audio::node_graph::nodes::LoopMode; + +pub struct SampleImportDialog { + pub folder_path: PathBuf, + pub scan_result: FolderScanResult, + pub loop_mode: LoopMode, + pub auto_key_ranges: bool, + pub confirmed: bool, + pub should_close: bool, + pub track_id: u32, + pub backend_node_id: u32, + pub node_id: NodeId, +} + +impl SampleImportDialog { + pub fn new( + folder_path: PathBuf, + scan_result: FolderScanResult, + track_id: u32, + backend_node_id: u32, + node_id: NodeId, + ) -> Self { + let loop_mode = scan_result.loop_mode; + Self { + folder_path, + scan_result, + loop_mode, + auto_key_ranges: true, + confirmed: false, + should_close: false, + track_id, + backend_node_id, + node_id, + } + } + + /// Returns true while the dialog is still open. + pub fn show(&mut self, ctx: &egui::Context) -> bool { + let mut open = true; + let mut should_import = false; + let mut should_cancel = false; + let mut recalc = false; + + egui::Window::new("Import Samples") + .open(&mut open) + .resizable(true) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .default_width(700.0) + .default_height(500.0) + .show(ctx, |ui| { + // Folder info + ui.label(format!("Folder: {}", self.folder_path.display())); + + let enabled_count = self.scan_result.layers.iter().filter(|l| l.enabled).count(); + let unique_notes: std::collections::HashSet = self.scan_result.layers.iter() + .filter(|l| l.enabled) + .map(|l| l.root_key) + .collect(); + let vel_count = self.scan_result.velocity_markers.len(); + ui.label(format!( + "Found: {} samples, {} notes, {} velocity layer{}", + enabled_count, + unique_notes.len(), + vel_count, + if vel_count != 1 { "s" } else { "" }, + )); + ui.add_space(4.0); + + // Global controls + ui.horizontal(|ui| { + ui.label("Loop mode:"); + egui::ComboBox::from_id_salt("loop_mode") + .selected_text(match self.loop_mode { + LoopMode::OneShot => "One Shot", + LoopMode::Continuous => "Continuous", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.loop_mode, LoopMode::OneShot, "One Shot"); + ui.selectable_value(&mut self.loop_mode, LoopMode::Continuous, "Continuous"); + }); + + ui.add_space(16.0); + if ui.checkbox(&mut self.auto_key_ranges, "Auto key ranges").changed() { + if self.auto_key_ranges { + recalc = true; + } + } + }); + ui.add_space(4.0); + + // Velocity mapping table + if !self.scan_result.velocity_ranges.is_empty() { + ui.collapsing("Velocity Mapping", |ui| { + egui::Grid::new("vel_grid").striped(true).show(ui, |ui| { + ui.label(egui::RichText::new("Marker").strong()); + ui.label(egui::RichText::new("Min").strong()); + ui.label(egui::RichText::new("Max").strong()); + ui.end_row(); + + for (marker, min, max) in &mut self.scan_result.velocity_ranges { + ui.label(&*marker); + ui.add(egui::DragValue::new(min).range(0..=127).speed(1)); + ui.add(egui::DragValue::new(max).range(0..=127).speed(1)); + ui.end_row(); + } + }); + }); + ui.add_space(4.0); + } + + // Layers table + ui.separator(); + ui.label(egui::RichText::new("Layers").strong()); + let available_height = ui.available_height() - 40.0; // reserve space for buttons + egui::ScrollArea::vertical() + .max_height(available_height.max(100.0)) + .show(ui, |ui| { + egui::Grid::new("layers_grid") + .striped(true) + .min_col_width(20.0) + .show(ui, |ui| { + // Header + ui.label(""); // checkbox column + ui.label(egui::RichText::new("File").strong()); + ui.label(egui::RichText::new("Root").strong()); + ui.label(egui::RichText::new("Key Range").strong()); + ui.label(egui::RichText::new("Vel Range").strong()); + ui.end_row(); + + for i in 0..self.scan_result.layers.len() { + let layer = &mut self.scan_result.layers[i]; + if ui.checkbox(&mut layer.enabled, "").changed() && self.auto_key_ranges { + recalc = true; + } + + // Filename (truncated) + let name = if layer.filename.len() > 40 { + format!("...{}", &layer.filename[layer.filename.len()-37..]) + } else { + layer.filename.clone() + }; + ui.label(&name).on_hover_text(&layer.filename); + + // Root note + let mut root = layer.root_key as i32; + if ui.add(egui::DragValue::new(&mut root) + .range(0..=127) + .speed(1) + .custom_formatter(|v, _| midi_to_note_name(v as u8)) + ).changed() { + layer.root_key = root as u8; + if self.auto_key_ranges { + recalc = true; + } + } + + // Key range + if self.auto_key_ranges { + ui.label(format!("{}-{}", midi_to_note_name(layer.key_min), midi_to_note_name(layer.key_max))); + } else { + let mut kmin = layer.key_min as i32; + let mut kmax = layer.key_max as i32; + ui.horizontal(|ui| { + if ui.add(egui::DragValue::new(&mut kmin).range(0..=127).speed(1) + .custom_formatter(|v, _| midi_to_note_name(v as u8)) + ).changed() { + layer.key_min = kmin as u8; + } + ui.label("-"); + if ui.add(egui::DragValue::new(&mut kmax).range(0..=127).speed(1) + .custom_formatter(|v, _| midi_to_note_name(v as u8)) + ).changed() { + layer.key_max = kmax as u8; + } + }); + } + + // Velocity range + ui.label(format!("{}-{}", layer.velocity_min, layer.velocity_max)); + ui.end_row(); + } + }); + + // Unmapped section + if !self.scan_result.unmapped.is_empty() { + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("Unmapped ({})", self.scan_result.unmapped.len())).strong()); + for sample in &self.scan_result.unmapped { + ui.label(format!(" {}", sample.filename)); + } + } + }); + + // Buttons + ui.add_space(4.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + should_cancel = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let import_text = format!("Import {} layers", enabled_count); + if ui.add_enabled(enabled_count > 0, egui::Button::new(&import_text)).clicked() { + should_import = true; + } + }); + }); + }); + + if recalc { + recalc_key_ranges(&mut self.scan_result.layers); + } + + if should_import { + self.confirmed = true; + self.should_close = true; + } + if should_cancel || !open { + self.should_close = true; + } + + !self.should_close + } + + /// Get the enabled layers for import. + pub fn enabled_layers(&self) -> Vec<&ImportLayer> { + self.scan_result.layers.iter().filter(|l| l.enabled).collect() + } +}