Multi sample bulk import

This commit is contained in:
Skyler Lehmkuhl 2026-02-20 01:59:37 -05:00
parent 35089f3b2e
commit 66c848e218
9 changed files with 1176 additions and 2 deletions

View File

@ -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::<MultiSamplerNode>() {
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

View File

@ -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<usize> {
self.layers

View File

@ -194,6 +194,8 @@ pub enum Command {
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),
/// 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)

View File

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

View File

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

View File

@ -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<NodeId, beamdsp::DrawVM>,
/// 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<crate::sample_import_dialog::SampleImportDialog>,
}
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 {

View File

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

View File

@ -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<u8> {
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<u8>,
pub velocity_marker: Option<String>,
pub rr_index: Option<u8>,
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<u8> {
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<u8> {
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::<u8>() {
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<u8> {
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<u8> = None;
let mut velocity_marker: Option<String> = None;
let mut rr_index: Option<u8> = None;
let mut note_token_indices: Vec<usize> = 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::<u8>().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::<u8>() {
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<u8> {
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<PathBuf>) -> 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<Vec<ParsedSample>> {
let mut files = Vec::new();
collect_audio_files(folder_path, &mut files)?;
let mut samples: Vec<ParsedSample> = 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<u8> = 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(&note) && 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<ImportLayer>,
pub unmapped: Vec<ParsedSample>,
pub loop_mode: LoopMode,
pub velocity_markers: Vec<String>,
pub velocity_ranges: Vec<(String, u8, u8)>,
pub detected_articulation: Option<String>,
}
/// 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<String> {
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<ParsedSample>, 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<ParsedSample> = Vec::new();
let mut unmapped: Vec<ParsedSample> = 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<String> = mapped.iter()
.filter_map(|s| s.velocity_marker.clone())
.collect::<std::collections::HashSet<_>>()
.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<String, (u8, u8)> = 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<u8> = mapped.iter()
.filter_map(|s| s.detected_note)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_notes.sort();
let key_ranges = auto_key_ranges(&unique_notes);
let note_to_range: HashMap<u8, (u8, u8)> = unique_notes.iter()
.zip(key_ranges.iter())
.map(|(&note, &range)| (note, range))
.collect();
// Build layers
let layers: Vec<ImportLayer> = 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<u8> = layers.iter()
.filter(|l| l.enabled && !l.is_percussion)
.map(|l| l.root_key)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_notes.sort();
let ranges = auto_key_ranges(&unique_notes);
let note_to_range: HashMap<u8, (u8, u8)> = unique_notes.iter()
.zip(ranges.iter())
.map(|(&note, &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(&notes);
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"));
}
}

View File

@ -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<u8> = 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()
}
}