Multi sample bulk import
This commit is contained in:
parent
35089f3b2e
commit
66c848e218
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(¬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<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(|(¬e, &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(|(¬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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue