From 5d84522a74075914e5667d5c25149192e53979ae Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 12 Nov 2025 11:23:46 -0500 Subject: [PATCH] add clear node graph button --- src-tauri/src/audio.rs | 43 ++++ src-tauri/src/lib.rs | 44 ++++ src/actions/index.js | 52 +++++ src/main.js | 491 ++++++++++++++++++++++++++++++++++++++++- src/nodeTypes.js | 3 +- src/styles.css | 17 ++ src/widgets.js | 6 + 7 files changed, 647 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 0bbc8a2..3482517 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -959,6 +959,49 @@ pub async fn graph_load_preset( } } +#[tauri::command] +pub async fn graph_load_preset_from_json( + state: tauri::State<'_, Arc>>, + track_id: u32, + preset_json: String, +) -> Result<(), String> { + use daw_backend::GraphPreset; + use std::io::Write; + + let mut audio_state = state.lock().unwrap(); + + // Parse the preset JSON to count nodes + let preset = GraphPreset::from_json(&preset_json) + .map_err(|e| format!("Failed to parse preset: {}", e))?; + + // Update the node ID counter to account for nodes in the preset + let node_count = preset.nodes.len() as u32; + audio_state.next_graph_node_id = node_count; + + if let Some(controller) = &mut audio_state.controller { + // Write JSON to a temporary file + let temp_path = std::env::temp_dir().join(format!("lb_temp_preset_{}.json", track_id)); + let mut file = std::fs::File::create(&temp_path) + .map_err(|e| format!("Failed to create temp file: {}", e))?; + file.write_all(preset_json.as_bytes()) + .map_err(|e| format!("Failed to write temp file: {}", e))?; + drop(file); + + // Load from the temp file + controller.graph_load_preset(track_id, temp_path.to_string_lossy().to_string()); + + // Clean up temp file (after a delay to allow loading) + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let _ = std::fs::remove_file(temp_path); + }); + + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + #[derive(serde::Serialize)] pub struct PresetInfo { pub name: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87da349..14b03fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,47 @@ fn error(msg: String) { error!("{}",msg); } +#[tauri::command] +async fn open_folder_dialog(app: AppHandle, title: String) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let folder = app.dialog() + .file() + .set_title(&title) + .blocking_pick_folder(); + + Ok(folder.map(|path| path.to_string())) +} + +#[tauri::command] +async fn read_folder_files(path: String) -> Result, String> { + use std::fs; + + let entries = fs::read_dir(&path) + .map_err(|e| format!("Failed to read directory: {}", e))?; + + let audio_extensions = vec!["wav", "aif", "aiff", "flac", "mp3", "ogg"]; + + let mut files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + if path.is_file() { + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if audio_extensions.contains(&ext_str.as_str()) { + if let Some(filename) = path.file_name() { + files.push(filename.to_string_lossy().to_string()); + } + } + } + } + } + + Ok(files) +} + use tauri::PhysicalSize; #[tauri::command] @@ -241,6 +282,7 @@ pub fn run() { audio::graph_set_output_node, audio::graph_save_preset, audio::graph_load_preset, + audio::graph_load_preset_from_json, audio::graph_list_presets, audio::graph_delete_preset, audio::graph_get_state, @@ -265,6 +307,8 @@ pub fn run() { video::video_get_frame, video::video_get_frames_batch, video::video_set_cache_size, + open_folder_dialog, + read_folder_files, video::video_get_pool_info, video::video_ipc_benchmark, video::video_get_transcode_status, diff --git a/src/actions/index.js b/src/actions/index.js index 5f671f1..311d6e7 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -2612,4 +2612,56 @@ export const actions = { } }, }, + + clearNodeGraph: { + execute: async (action) => { + // Get the current graph state to find all node IDs + const graphStateJson = await invoke('graph_get_state', { trackId: action.trackId }); + const graphState = JSON.parse(graphStateJson); + + // Remove all nodes from backend + for (const node of graphState.nodes) { + try { + await invoke("graph_remove_node", { + trackId: action.trackId, + nodeId: node.id, + }); + } catch (e) { + console.error(`Failed to remove node ${node.id}:`, e); + } + } + + // Reload the graph from backend + if (context.reloadNodeEditor) { + await context.reloadNodeEditor(); + } + + // Update minimap + if (context.updateMinimap) { + setTimeout(() => context.updateMinimap(), 100); + } + }, + rollback: async (action) => { + // Restore the entire graph from the saved preset JSON + try { + await invoke("graph_load_preset_from_json", { + trackId: action.trackId, + presetJson: action.savedGraphJson, + }); + + // Reload the graph editor to show the restored nodes + if (context.reloadNodeEditor) { + await context.reloadNodeEditor(); + } + + // Update minimap + if (context.updateMinimap) { + setTimeout(() => context.updateMinimap(), 100); + } + } catch (e) { + console.error('Failed to restore graph:', e); + alert('Failed to restore graph: ' + e); + } + }, + }, }; diff --git a/src/main.js b/src/main.js index cfc6c0f..bc38ebc 100644 --- a/src/main.js +++ b/src/main.js @@ -7104,9 +7104,50 @@ function nodeEditor() { const header = document.createElement("div"); header.className = "node-editor-header"; // Initial header will be updated by updateBreadcrumb() after track info is available - header.innerHTML = '
Node Graph
'; + header.innerHTML = ` +
Node Graph
+ + `; container.appendChild(header); + // Add clear button handler + const clearBtn = header.querySelector('.node-graph-clear-btn'); + clearBtn.addEventListener('click', async () => { + try { + // Get current track + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + console.error('No track selected'); + alert('Please select a track first'); + return; + } + const trackId = trackInfo.trackId; + + // Get the full backend graph state as JSON + const graphStateJson = await invoke('graph_get_state', { trackId }); + const graphState = JSON.parse(graphStateJson); + + if (!graphState.nodes || graphState.nodes.length === 0) { + return; // Nothing to clear + } + + // Create and execute the action + redoStack.length = 0; // Clear redo stack + const action = { + trackId, + savedGraphJson: graphStateJson // Save the entire graph state as JSON + }; + undoStack.push({ name: 'clearNodeGraph', action }); + await actions.clearNodeGraph.execute(action); + updateMenu(); + + console.log('Cleared node graph (undoable)'); + } catch (e) { + console.error('Failed to clear node graph:', e); + alert('Failed to clear node graph: ' + e); + } + }); + // Create the Drawflow canvas const editorDiv = document.createElement("div"); editorDiv.id = "drawflow"; @@ -7529,6 +7570,9 @@ function nodeEditor() { // Update minimap on pan/zoom drawflowDiv.addEventListener('wheel', () => setTimeout(updateMinimap, 10)); + // Store updateMinimap in context so it can be called from actions + context.updateMinimap = updateMinimap; + // Initial minimap render setTimeout(updateMinimap, 200); @@ -8814,6 +8858,35 @@ function nodeEditor() { } }); } + + // Handle Import Folder button for MultiSampler + const importFolderBtn = nodeElement.querySelector(".import-folder-btn"); + if (importFolderBtn) { + importFolderBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + await showFolderImportDialog(currentTrackId, nodeData.data.backendId, nodeId); + } catch (err) { + console.error("Failed to import folder:", err); + showError(`Failed to import folder: ${err}`); + } + }); + } }, 100); } @@ -8860,8 +8933,12 @@ function nodeEditor() { nodeId: nodeData.data.backendId }); - const layersList = document.querySelector(`#sample-layers-list-${nodeId}`); - const layersContainer = document.querySelector(`#sample-layers-container-${nodeId}`); + // Find the node element and query within it for the layers list + const nodeElement = document.querySelector(`#node-${nodeId}`); + if (!nodeElement) return; + + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); if (!layersList) return; @@ -9998,10 +10075,10 @@ function nodeEditor() { } if (nodeType === 'MultiSampler' && serializedNode.sample_data && serializedNode.sample_data.type === 'multi_sampler') { - console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element with backend ID ${serializedNode.id}`); - // Use backend ID (serializedNode.id) since that's what was used in getHTML - const layersList = nodeElement.querySelector(`#sample-layers-list-${serializedNode.id}`); - const layersContainer = nodeElement.querySelector(`#sample-layers-container-${serializedNode.id}`); + console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element`); + // Query for elements by prefix to avoid ID mismatch issues + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); console.log(`[reloadGraph] layersList:`, layersList); console.log(`[reloadGraph] layersContainer:`, layersContainer); @@ -10049,7 +10126,43 @@ function nodeEditor() { const drawflowNodeId = parseInt(btn.dataset.drawflowNode); const layerIndex = parseInt(btn.dataset.index); const layer = layers[layerIndex]; - await showLayerEditDialog(drawflowNodeId, layerIndex, layer); + + // Show dialog with current layer settings + const layerConfig = await showLayerConfigDialog(layer.file_path, { + keyMin: layer.key_min, + keyMax: layer.key_max, + rootKey: layer.root_key, + velocityMin: layer.velocity_min, + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode + }); + + if (layerConfig) { + const nodeData = editor.getNodeFromId(drawflowNodeId); + const currentTrackId = getCurrentMidiTrack(); + if (nodeData && currentTrackId !== null) { + try { + await invoke("multi_sampler_update_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: layerIndex, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + await refreshSampleLayersList(drawflowNodeId); + } catch (err) { + showError(`Failed to update layer: ${err}`); + } + } + } }); }); @@ -10764,6 +10877,368 @@ function midiToNoteName(midiNote) { return `${noteName}${octave}`; } +// Parse note name from string (e.g., "A#3" -> 58) +function noteNameToMidi(noteName) { + const noteMap = { + 'C': 0, 'C#': 1, 'Db': 1, + 'D': 2, 'D#': 3, 'Eb': 3, + 'E': 4, + 'F': 5, 'F#': 6, 'Gb': 6, + 'G': 7, 'G#': 8, 'Ab': 8, + 'A': 9, 'A#': 10, 'Bb': 10, + 'B': 11 + }; + + // Match note + optional accidental + octave + const match = noteName.match(/^([A-G][#b]?)(-?\d+)$/i); + if (!match) return null; + + const note = match[1].toUpperCase(); + const octave = parseInt(match[2]); + + if (!(note in noteMap)) return null; + + return (octave + 1) * 12 + noteMap[note]; +} + +// Parse filename to extract note and velocity layer +function parseSampleFilename(filename) { + // Remove extension + const nameWithoutExt = filename.replace(/\.(wav|aif|aiff|flac|mp3|ogg)$/i, ''); + + // Try to find note patterns (e.g., A#3, Bb2, C4) + const notePattern = /([A-G][#b]?)(-?\d+)/gi; + const noteMatches = [...nameWithoutExt.matchAll(notePattern)]; + + if (noteMatches.length === 0) return null; + + // Use the last note match (usually most reliable) + const noteMatch = noteMatches[noteMatches.length - 1]; + const noteStr = noteMatch[1] + noteMatch[2]; + const midiNote = noteNameToMidi(noteStr); + + if (midiNote === null) return null; + + // Try to find velocity indicators + // Common patterns: v1, v2, v3, pp, p, mp, mf, f, ff, fff + const velPatterns = [ + { regex: /v(\d+)/i, type: 'numeric' }, + { regex: /\b(ppp|pp|p|mp|mf|f|ff|fff)\b/i, type: 'dynamic' } + ]; + + let velocityMarker = null; + let velocityType = null; + + for (const pattern of velPatterns) { + const match = nameWithoutExt.match(pattern.regex); + if (match) { + velocityMarker = match[1]; + velocityType = pattern.type; + break; + } + } + + return { + note: noteStr, + midiNote, + velocityMarker, + velocityType, + filename + }; +} + +// Group samples by note and velocity +function groupSamples(samples) { + const groups = {}; + const velocityLayers = new Set(); + + for (const sample of samples) { + const parsed = parseSampleFilename(sample); + if (!parsed) continue; + + const key = parsed.midiNote; + if (!groups[key]) { + groups[key] = { + note: parsed.note, + midiNote: parsed.midiNote, + layers: [] + }; + } + + groups[key].layers.push({ + filename: parsed.filename, + velocityMarker: parsed.velocityMarker, + velocityType: parsed.velocityType + }); + + if (parsed.velocityMarker) { + velocityLayers.add(parsed.velocityMarker); + } + } + + return { groups, velocityLayers: Array.from(velocityLayers).sort() }; +} + +// Show folder import dialog +async function showFolderImportDialog(trackId, nodeId, drawflowNodeId) { + // Select folder + const folderPath = await invoke("open_folder_dialog", { + title: "Select Sample Folder" + }); + + if (!folderPath) return; + + // Read files from folder + const files = await invoke("read_folder_files", { + path: folderPath + }); + + if (!files || files.length === 0) { + alert("No audio files found in folder"); + return; + } + + // Parse and group samples + const { groups, velocityLayers } = groupSamples(files); + const noteGroups = Object.values(groups).sort((a, b) => a.midiNote - b.midiNote); + + if (noteGroups.length === 0) { + alert("Could not detect note names in filenames"); + return; + } + + // Show configuration dialog + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'dialog'; + dialog.style.width = '600px'; + dialog.style.maxWidth = '90vw'; + dialog.style.maxHeight = '80vh'; + dialog.style.padding = '20px'; + dialog.style.backgroundColor = '#2a2a2a'; + dialog.style.border = '1px solid #444'; + dialog.style.borderRadius = '8px'; + dialog.style.color = '#e0e0e0'; + + let velocityMapping = {}; + + // Initialize default velocity mappings + if (velocityLayers.length > 0) { + const step = Math.floor(127 / velocityLayers.length); + velocityLayers.forEach((marker, idx) => { + velocityMapping[marker] = { + min: idx * step, + max: (idx + 1) * step - 1 + }; + }); + // Ensure last layer goes to 127 + if (velocityLayers.length > 0) { + velocityMapping[velocityLayers[velocityLayers.length - 1]].max = 127; + } + } + + dialog.innerHTML = ` +

Import Sample Folder

+
+ Folder: ${folderPath}
+ Found: ${noteGroups.length} notes, ${velocityLayers.length} velocity layer(s) +
+ + ${velocityLayers.length > 0 ? ` +
+ Velocity Mapping: + + + + + + + + + + ${velocityLayers.map(marker => ` + + + + + + `).join('')} + +
MarkerMin VelocityMax Velocity
${marker}
+
+ ` : ''} + +
+ Preview: +
    + ${noteGroups.slice(0, 20).map(group => ` +
  • ${group.note} (MIDI ${group.midiNote}): ${group.layers.length} sample(s) + ${group.layers.length <= 3 ? `
      ${group.layers.map(l => l.filename).join('
      ')}
    ` : ''} +
  • + `).join('')} + ${noteGroups.length > 20 ? `
  • ... and ${noteGroups.length - 20} more notes
  • ` : ''} +
+
+ +
+ +
+ +
+ + +
+ `; + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // Update velocity mapping when inputs change + const velInputs = dialog.querySelectorAll('.vel-min, .vel-max'); + velInputs.forEach(input => { + input.addEventListener('input', () => { + const marker = input.dataset.marker; + const isMin = input.classList.contains('vel-min'); + const value = parseInt(input.value); + + if (isMin) { + velocityMapping[marker].min = value; + } else { + velocityMapping[marker].max = value; + } + }); + }); + + dialog.querySelector('#btn-cancel').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(); + }); + + dialog.querySelector('#btn-import').addEventListener('click', async () => { + const autoKeyRanges = dialog.querySelector('#auto-key-ranges').checked; + + try { + // Build layer list + const layersToImport = []; + + for (let i = 0; i < noteGroups.length; i++) { + const group = noteGroups[i]; + + // Calculate key range + let keyMin, keyMax; + if (autoKeyRanges) { + // Split range between adjacent notes + const prevNote = i > 0 ? noteGroups[i - 1].midiNote : 0; + const nextNote = i < noteGroups.length - 1 ? noteGroups[i + 1].midiNote : 127; + + keyMin = i === 0 ? 0 : Math.ceil((prevNote + group.midiNote) / 2); + keyMax = i === noteGroups.length - 1 ? 127 : Math.floor((group.midiNote + nextNote) / 2); + } else { + keyMin = group.midiNote; + keyMax = group.midiNote; + } + + // Add each velocity layer for this note + for (const layer of group.layers) { + let velMin = 0, velMax = 127; + + if (layer.velocityMarker && velocityMapping[layer.velocityMarker]) { + velMin = velocityMapping[layer.velocityMarker].min; + velMax = velocityMapping[layer.velocityMarker].max; + } + + layersToImport.push({ + filePath: `${folderPath}/${layer.filename}`, + keyMin, + keyMax, + rootKey: group.midiNote, + velocityMin: velMin, + velocityMax: velMax + }); + } + } + + // Import all layers + dialog.querySelector('#btn-import').disabled = true; + dialog.querySelector('#btn-import').textContent = 'Importing...'; + + for (const layer of layersToImport) { + await invoke("multi_sampler_add_layer", { + trackId, + nodeId, + filePath: layer.filePath, + keyMin: layer.keyMin, + keyMax: layer.keyMax, + rootKey: layer.rootKey, + velocityMin: layer.velocityMin, + velocityMax: layer.velocityMax, + loopStart: null, + loopEnd: null, + loopMode: "Continuous" + }); + } + + // Refresh the layers list by re-fetching from backend + try { + const layers = await invoke("multi_sampler_get_layers", { + trackId, + nodeId + }); + + // Find the node element and update the layers list + const nodeElement = document.querySelector(`#node-${drawflowNodeId}`); + if (nodeElement) { + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + + if (layersList) { + if (layers.length === 0) { + layersList.innerHTML = 'No layers loaded'; + } else { + layersList.innerHTML = layers.map((layer, index) => { + const filename = layer.file_path.split('/').pop().split('\\').pop(); + const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`; + const rootNote = midiToNoteName(layer.root_key); + const velRange = `${layer.velocity_min}-${layer.velocity_max}`; + + return ` + + ${filename} + ${keyRange} + ${rootNote} + ${velRange} + +
+ + +
+ + + `; + }).join(''); + } + } + } + } catch (refreshErr) { + console.error("Failed to refresh layers list:", refreshErr); + } + + document.body.removeChild(overlay); + resolve(); + } catch (err) { + alert(`Failed to import: ${err}`); + dialog.querySelector('#btn-import').disabled = false; + dialog.querySelector('#btn-import').textContent = 'Import'; + } + }); + }); +} + // Show dialog to configure MultiSampler layer zones function showLayerConfigDialog(filePath, existingConfig = null) { return new Promise((resolve) => { diff --git a/src/nodeTypes.js b/src/nodeTypes.js index ccbed92..a2c78f8 100644 --- a/src/nodeTypes.js +++ b/src/nodeTypes.js @@ -883,7 +883,8 @@ export const nodeTypes = {
- + +
diff --git a/src/styles.css b/src/styles.css index 2fee420..7a04d2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1244,6 +1244,7 @@ button { border-bottom: 1px solid #3d3d3d; display: flex; align-items: center; + justify-content: space-between; padding: 0 16px; z-index: 200; user-select: none; @@ -1284,6 +1285,22 @@ button { border-color: #5d5d5d; } +.node-graph-clear-btn { + padding: 4px 12px; + background: #d32f2f; + border: 1px solid #b71c1c; + border-radius: 3px; + color: white; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.node-graph-clear-btn:hover { + background: #e53935; + border-color: #c62828; +} + .exit-template-btn:active { background: #5d5d5d; } diff --git a/src/widgets.js b/src/widgets.js index 3d28178..192f8c0 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -4909,6 +4909,12 @@ class VirtualPiano extends Widget { // Handle piano keys const baseNote = this.keyboardMap[key]; if (baseNote !== undefined) { + // Check if this key is already pressed (prevents duplicate note-ons from OS key repeat quirks) + if (this.activeKeyPresses.has(key)) { + e.preventDefault(); + return; + } + // Note: octave offset is applied by shifting the visible piano range // so we play the base note directly const note = baseNote + (this.octaveOffset * 12);