From 9d6eaa5bba6451bb68d0d42139d67f77cc18db18 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 28 Oct 2025 08:51:53 -0400 Subject: [PATCH] Node graph improvements and fixes --- daw-backend/src/audio/engine.rs | 31 ++ .../audio/node_graph/nodes/oscilloscope.rs | 42 +- daw-backend/src/command/types.rs | 4 + src-tauri/src/audio.rs | 16 + src-tauri/src/lib.rs | 1 + src/drawflow.css | 5 + src/main.js | 412 +++++++++++++++++- src/nodeTypes.js | 15 +- src/styles.css | 58 ++- 9 files changed, 555 insertions(+), 29 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 77344f4..06815c2 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1204,6 +1204,15 @@ impl Engine { QueryResponse::GraphState(Err(format!("Track {} not found or is not a MIDI track", track_id))) } } + Query::GetOscilloscopeData(track_id, node_id, sample_count) => { + match self.project.get_oscilloscope_data(track_id, node_id, sample_count) { + Some(data) => QueryResponse::OscilloscopeData(Ok(data)), + None => QueryResponse::OscilloscopeData(Err(format!( + "Failed to get oscilloscope data from track {} node {}", + track_id, node_id + ))), + } + } }; // Send response back @@ -1759,4 +1768,26 @@ impl EngineController { Err("Query timeout".to_string()) } + + /// Query oscilloscope data from a node + pub fn query_oscilloscope_data(&mut self, track_id: TrackId, node_id: u32, sample_count: usize) -> Result, String> { + // Send query + if let Err(_) = self.query_tx.push(Query::GetOscilloscopeData(track_id, node_id, sample_count)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::OscilloscopeData(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } } diff --git a/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs index d5b97b6..5dc533e 100644 --- a/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs +++ b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs @@ -13,6 +13,7 @@ pub enum TriggerMode { FreeRunning = 0, RisingEdge = 1, FallingEdge = 2, + VoltPerOctave = 3, } impl TriggerMode { @@ -20,6 +21,7 @@ impl TriggerMode { match value.round() as i32 { 1 => TriggerMode::RisingEdge, 2 => TriggerMode::FallingEdge, + 3 => TriggerMode::VoltPerOctave, _ => TriggerMode::FreeRunning, } } @@ -80,6 +82,9 @@ pub struct OscilloscopeNode { trigger_mode: TriggerMode, trigger_level: f32, // -1.0 to 1.0 last_sample: f32, // For edge detection + voct_value: f32, // Current V/oct input value + sample_counter: usize, // Counter for V/oct triggering + trigger_period: usize, // Period in samples for V/oct triggering // Shared buffer for reading from Tauri commands buffer: Arc>, @@ -95,6 +100,7 @@ impl OscilloscopeNode { let inputs = vec![ NodePort::new("Audio In", SignalType::Audio, 0), + NodePort::new("V/oct", SignalType::CV, 1), ]; let outputs = vec![ @@ -103,7 +109,7 @@ impl OscilloscopeNode { let parameters = vec![ Parameter::new(PARAM_TIME_SCALE, "Time Scale", 10.0, 1000.0, 100.0, ParameterUnit::Time), - Parameter::new(PARAM_TRIGGER_MODE, "Trigger", 0.0, 2.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_TRIGGER_MODE, "Trigger", 0.0, 3.0, 0.0, ParameterUnit::Generic), Parameter::new(PARAM_TRIGGER_LEVEL, "Trigger Level", -1.0, 1.0, 0.0, ParameterUnit::Generic), ]; @@ -113,6 +119,9 @@ impl OscilloscopeNode { trigger_mode: TriggerMode::FreeRunning, trigger_level: 0.0, last_sample: 0.0, + voct_value: 0.0, + sample_counter: 0, + trigger_period: 480, // Default to ~100Hz at 48kHz buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), inputs, outputs, @@ -141,6 +150,12 @@ impl OscilloscopeNode { } } + /// Convert V/oct to frequency in Hz (matches oscillator convention) + /// 0V = A4 (440 Hz), ±1V per octave + fn voct_to_frequency(voct: f32) -> f32 { + 440.0 * 2.0_f32.powf(voct) + } + /// Check if trigger condition is met fn is_triggered(&self, current_sample: f32) -> bool { match self.trigger_mode { @@ -151,6 +166,10 @@ impl OscilloscopeNode { TriggerMode::FallingEdge => { self.last_sample >= self.trigger_level && current_sample < self.trigger_level } + TriggerMode::VoltPerOctave => { + // Trigger at the start of each period + self.sample_counter == 0 + } } } } @@ -196,7 +215,7 @@ impl AudioNode for OscilloscopeNode { outputs: &mut [&mut [f32]], _midi_inputs: &[&[MidiEvent]], _midi_outputs: &mut [&mut Vec], - _sample_rate: u32, + sample_rate: u32, ) { if inputs.is_empty() || outputs.is_empty() { return; @@ -206,6 +225,20 @@ impl AudioNode for OscilloscopeNode { let output = &mut outputs[0]; let len = input.len().min(output.len()); + // Read V/oct input if available and update trigger period + if inputs.len() > 1 && !inputs[1].is_empty() { + self.voct_value = inputs[1][0]; // Use first sample of V/oct input + let frequency = Self::voct_to_frequency(self.voct_value); + // Calculate period in samples, clamped to reasonable range + let period_samples = (sample_rate as f32 / frequency).max(1.0); + self.trigger_period = period_samples as usize; + } + + // Update sample counter for V/oct triggering + if self.trigger_mode == TriggerMode::VoltPerOctave { + self.sample_counter = (self.sample_counter + len) % self.trigger_period; + } + // Pass through audio (copy input to output) output[..len].copy_from_slice(&input[..len]); @@ -222,6 +255,8 @@ impl AudioNode for OscilloscopeNode { fn reset(&mut self) { self.last_sample = 0.0; + self.voct_value = 0.0; + self.sample_counter = 0; self.clear_buffer(); } @@ -240,6 +275,9 @@ impl AudioNode for OscilloscopeNode { trigger_mode: self.trigger_mode, trigger_level: self.trigger_level, last_sample: 0.0, + voct_value: 0.0, + sample_counter: 0, + trigger_period: 480, buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), inputs: self.inputs.clone(), outputs: self.outputs.clone(), diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 302da08..411194d 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -207,6 +207,8 @@ pub enum Query { GetGraphState(TrackId), /// Get a voice allocator's template graph state as JSON (track_id, voice_allocator_id) GetTemplateState(TrackId, u32), + /// Get oscilloscope data from a node (track_id, node_id, sample_count) + GetOscilloscopeData(TrackId, u32, usize), } /// Responses to synchronous queries @@ -214,4 +216,6 @@ pub enum Query { pub enum QueryResponse { /// Graph state as JSON string GraphState(Result), + /// Oscilloscope data samples + OscilloscopeData(Result, String>), } diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index b44d6c0..2857eb0 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1131,6 +1131,22 @@ pub async fn multi_sampler_remove_layer( } } +#[tauri::command] +pub async fn get_oscilloscope_data( + state: tauri::State<'_, Arc>>, + track_id: u32, + node_id: u32, + sample_count: usize, +) -> Result, String> { + let mut audio_state = state.lock().unwrap(); + + if let Some(controller) = &mut audio_state.controller { + controller.query_oscilloscope_data(track_id, node_id, sample_count) + } else { + Err("Audio not initialized".to_string()) + } +} + #[derive(serde::Serialize, Clone)] #[serde(tag = "type")] pub enum SerializedAudioEvent { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1264d5a..98b02ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -233,6 +233,7 @@ pub fn run() { audio::multi_sampler_get_layers, audio::multi_sampler_update_layer, audio::multi_sampler_remove_layer, + audio::get_oscilloscope_data, ]) // .manage(window_counter) .build(tauri::generate_context!()) diff --git a/src/drawflow.css b/src/drawflow.css index fa3bf09..e5fb0f3 100644 --- a/src/drawflow.css +++ b/src/drawflow.css @@ -118,6 +118,11 @@ padding-bottom: var(--dfNodePaddingBottom); -webkit-box-shadow: var(--dfNodeBoxShadowHL) var(--dfNodeBoxShadowVL) var(--dfNodeBoxShadowBR) var(--dfNodeBoxShadowS) var(--dfNodeBoxShadowColor); box-shadow: var(--dfNodeBoxShadowHL) var(--dfNodeBoxShadowVL) var(--dfNodeBoxShadowBR) var(--dfNodeBoxShadowS) var(--dfNodeBoxShadowColor); + user-select: none; +} + +.drawflow .drawflow-node * { + user-select: none; } .drawflow .drawflow-node:hover { diff --git a/src/main.js b/src/main.js index 572f59d..b3615a6 100644 --- a/src/main.js +++ b/src/main.js @@ -6093,6 +6093,26 @@ function nodeEditor() { const container = document.createElement("div"); container.id = "node-editor-container"; + // Prevent text selection during drag operations + container.addEventListener('selectstart', (e) => { + // Allow selection on input elements + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + e.preventDefault(); + }); + container.addEventListener('mousedown', (e) => { + // Don't prevent default on inputs, textareas, or palette items (draggable) + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + // Don't prevent default on palette items or their children + if (e.target.closest('.node-palette-item') || e.target.closest('.node-category-item')) { + return; + } + e.preventDefault(); + }); + // Track editing context: null = main graph, {voiceAllocatorId, voiceAllocatorName} = editing template let editingContext = null; @@ -6207,6 +6227,97 @@ function nodeEditor() { // Store editor reference in context context.nodeEditor = editor; + // Add reconnection support: dragging from a connected input disconnects and starts new connection + drawflowDiv.addEventListener('mousedown', (e) => { + console.log('Mousedown on drawflow, target:', e.target); + + // Check if clicking on an input port + const inputPort = e.target.closest('.input'); + console.log('Found input port:', inputPort); + + if (inputPort) { + // Get the node and port information - the drawflow-node div has the id + const drawflowNode = inputPort.closest('.drawflow-node'); + console.log('Found drawflow node:', drawflowNode, 'ID:', drawflowNode?.id); + if (!drawflowNode) return; + + const nodeId = parseInt(drawflowNode.id.replace('node-', '')); + console.log('Node ID:', nodeId); + + // Access the node data directly from the current module + const moduleName = editor.module; + const node = editor.drawflow.drawflow[moduleName]?.data[nodeId]; + console.log('Node data:', node); + if (!node) return; + + // Get the port class (input_1, input_2, etc.) + const portClasses = Array.from(inputPort.classList); + const portClass = portClasses.find(c => c.startsWith('input_')); + console.log('Port class:', portClass, 'All classes:', portClasses); + if (!portClass) return; + + // Check if this input has any connections + const inputConnections = node.inputs[portClass]; + console.log('Input connections:', inputConnections); + if (inputConnections && inputConnections.connections && inputConnections.connections.length > 0) { + // Get the first connection (inputs should only have one connection) + const connection = inputConnections.connections[0]; + console.log('Found connection to disconnect:', connection); + + // Prevent default to avoid interfering with the drag + e.stopPropagation(); + e.preventDefault(); + + // Remove the connection + editor.removeSingleConnection( + connection.node, + nodeId, + connection.input, + portClass + ); + + // Now trigger Drawflow's connection drag from the output that was connected + // We need to simulate starting a drag from the output port + const outputNodeElement = document.getElementById(`node-${connection.node}`); + console.log('Output node element:', outputNodeElement); + if (outputNodeElement) { + const outputPort = outputNodeElement.querySelector(`.${connection.input}`); + console.log('Output port:', outputPort, 'class:', connection.input); + if (outputPort) { + // Dispatch a synthetic mousedown event on the output port + // This will trigger Drawflow's normal connection start logic + setTimeout(() => { + const rect = outputPort.getBoundingClientRect(); + const syntheticEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + button: 0 + }); + outputPort.dispatchEvent(syntheticEvent); + + // Then immediately dispatch a mousemove to the original cursor position + // to start dragging the connection line + setTimeout(() => { + const mousemoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + clientX: e.clientX, + clientY: e.clientY, + button: 0 + }); + document.dispatchEvent(mousemoveEvent); + }, 0); + }, 0); + } + } + } + } + }, true); // Use capture phase to intercept before Drawflow + // Add trackpad/mousewheel scrolling support for panning drawflowDiv.addEventListener('wheel', (e) => { // Don't scroll if hovering over palette or other UI elements @@ -6268,7 +6379,22 @@ function nodeEditor() { const item = e.target.closest(".node-palette-item"); if (item) { const nodeType = item.getAttribute("data-node-type"); - addNode(nodeType, 100, 100, null); + + // Calculate center of visible canvas viewport + const rect = drawflowDiv.getBoundingClientRect(); + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + const zoom = editor.zoom || 1; + + // Approximate node dimensions (nodes have min-width: 160px, typical height ~150px) + const nodeWidth = 160; + const nodeHeight = 150; + + // Center position in world coordinates, offset by half node size + const centerX = (rect.width / 2 - canvasX) / zoom - nodeWidth / 2; + const centerY = (rect.height / 2 - canvasY) / zoom - nodeHeight / 2; + + addNode(nodeType, centerX, centerY, null); } }); @@ -6311,13 +6437,31 @@ function nodeEditor() { // Get drop position relative to the editor const rect = drawflowDiv.getBoundingClientRect(); - const precanvasX = editor.precanvas?.x || 0; - const precanvasY = editor.precanvas?.y || 0; - const zoom = editor.zoom || 1; - const x = (e.clientX - rect.left - precanvasX) / zoom; - const y = (e.clientY - rect.top - precanvasY) / zoom; - console.log('Position calculation:', { clientX: e.clientX, clientY: e.clientY, rectLeft: rect.left, rectTop: rect.top, precanvasX, precanvasY, zoom, x, y }); + // Use canvas_x and canvas_y which are set by the wheel scroll handler + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + const zoom = editor.zoom || 1; + + // Approximate node dimensions (nodes have min-width: 160px, typical height ~150px) + const nodeWidth = 160; + const nodeHeight = 150; + + // Calculate position accounting for canvas pan offset, centered on cursor + const x = (e.clientX - rect.left - canvasX) / zoom - nodeWidth / 2; + const y = (e.clientY - rect.top - canvasY) / zoom - nodeHeight / 2; + + console.log('Position calculation:', JSON.stringify({ + clientX: e.clientX, + clientY: e.clientY, + rectLeft: rect.left, + rectTop: rect.top, + canvasX, + canvasY, + zoom, + x, + y + })); // Check if dropping into an expanded VoiceAllocator let parentNodeId = null; @@ -6385,6 +6529,9 @@ function nodeEditor() { return; } + // Stop oscilloscope visualization if this was an Oscilloscope node + stopOscilloscopeVisualization(nodeId); + // Clean up parent-child tracking const parentId = nodeParents.get(nodeId); nodeParents.delete(nodeId); @@ -6423,6 +6570,24 @@ function nodeEditor() { html ); + // Update all IDs in the HTML to use drawflowNodeId instead of nodeId + // This ensures parameter setup can find the correct elements + if (nodeId !== drawflowNodeId) { + setTimeout(() => { + const nodeElement = document.getElementById(`node-${drawflowNodeId}`); + if (nodeElement) { + // Update all elements with IDs containing the old nodeId + const elementsWithIds = nodeElement.querySelectorAll('[id*="-' + nodeId + '"]'); + elementsWithIds.forEach(el => { + const oldId = el.id; + const newId = oldId.replace('-' + nodeId, '-' + drawflowNodeId); + el.id = newId; + console.log(`Updated element ID: ${oldId} -> ${newId}`); + }); + } + }, 10); + } + // Track parent-child relationship if (parentNodeId !== null) { nodeParents.set(drawflowNodeId, parentNodeId); @@ -6526,6 +6691,18 @@ function nodeEditor() { } } + // If this is an Oscilloscope node, start the visualization + if (nodeType === "Oscilloscope") { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + console.log(`Starting oscilloscope visualization for node ${drawflowNodeId} (backend ID: ${backendNodeId})`); + // Wait for DOM to update before starting visualization + setTimeout(() => { + startOscilloscopeVisualization(drawflowNodeId, currentTrackId, backendNodeId); + }, 100); + } + } + // If this is a VoiceAllocator, automatically create template I/O nodes inside it if (nodeType === "VoiceAllocator") { setTimeout(() => { @@ -6613,10 +6790,8 @@ function nodeEditor() { inputs.forEach((input, index) => { if (index < nodeDef.inputs.length) { const portDef = nodeDef.inputs[index]; - const connector = input.querySelector(".input_0, .input_1, .input_2, .input_3"); - if (connector) { - connector.classList.add(getPortClass(portDef.type)); - } + // Add connector styling class directly to the input element + input.classList.add(getPortClass(portDef.type)); // Add label const label = document.createElement("span"); label.textContent = portDef.name; @@ -6629,10 +6804,8 @@ function nodeEditor() { outputs.forEach((output, index) => { if (index < nodeDef.outputs.length) { const portDef = nodeDef.outputs[index]; - const connector = output.querySelector(".output_0, .output_1, .output_2, .output_3"); - if (connector) { - connector.classList.add(getPortClass(portDef.type)); - } + // Add connector styling class directly to the output element + output.classList.add(getPortClass(portDef.type)); // Add label const label = document.createElement("span"); label.textContent = portDef.name; @@ -6661,15 +6834,32 @@ function nodeEditor() { const paramId = parseInt(e.target.getAttribute("data-param")); const value = parseFloat(e.target.value); + console.log(`[setupNodeParameters] Slider input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`); + // Update display const nodeData = editor.getNodeFromId(nodeId); if (nodeData) { const nodeDef = nodeTypes[nodeData.name]; + console.log(`[setupNodeParameters] Found node type: ${nodeData.name}, parameters:`, nodeDef?.parameters); if (nodeDef && nodeDef.parameters[paramId]) { const param = nodeDef.parameters[paramId]; + console.log(`[setupNodeParameters] Looking for span: #${param.name}-${nodeId}`); const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`); + console.log(`[setupNodeParameters] Found span:`, displaySpan); if (displaySpan) { - displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2); + // Special formatting for oscilloscope trigger mode + if (param.name === 'trigger_mode') { + const modes = ['Free', 'Rising', 'Falling', 'V/oct']; + displaySpan.textContent = modes[Math.round(value)] || 'Free'; + } else { + displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2); + } + } + + // Update oscilloscope time scale if this is a time_scale parameter + if (param.name === 'time_scale' && oscilloscopeTimeScales) { + oscilloscopeTimeScales.set(nodeId, value); + console.log(`Updated oscilloscope time scale for node ${nodeId}: ${value}ms`); } } @@ -7008,6 +7198,32 @@ function nodeEditor() { console.log("Types match - proceeding with connection"); + // Auto-switch Oscilloscope to V/oct trigger mode when connecting to V/oct input + if (inputNode.name === 'Oscilloscope' && inputPort === 1) { + console.log(`Auto-switching Oscilloscope node ${connection.input_id} to V/oct trigger mode`); + // Set trigger_mode parameter (id: 1) to value 3 (V/oct) + const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`); + const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`); + if (triggerModeSlider) { + triggerModeSlider.value = 3; + if (triggerModeSpan) { + triggerModeSpan.textContent = 'V/oct'; + } + // Update backend parameter + if (inputNode.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + invoke("graph_set_parameter", { + trackId: currentTrackId, + nodeId: inputNode.data.backendId, + paramId: 1, + value: 3.0 + }).catch(err => console.error("Failed to set V/oct trigger mode:", err)); + } + } + } + } + // Style the connection based on signal type setTimeout(() => { const connectionElement = document.querySelector( @@ -7123,6 +7339,31 @@ function nodeEditor() { const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1; const inputPort = parseInt(connection.input_class.replace("input_", "")) - 1; + // Auto-switch Oscilloscope back to Free mode when disconnecting V/oct input + if (inputNode.name === 'Oscilloscope' && inputPort === 1) { + console.log(`Auto-switching Oscilloscope node ${connection.input_id} back to Free trigger mode`); + const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`); + const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`); + if (triggerModeSlider) { + triggerModeSlider.value = 0; + if (triggerModeSpan) { + triggerModeSpan.textContent = 'Free'; + } + // Update backend parameter + if (inputNode.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + invoke("graph_set_parameter", { + trackId: currentTrackId, + nodeId: inputNode.data.backendId, + paramId: 1, + value: 0.0 + }).catch(err => console.error("Failed to set Free trigger mode:", err)); + } + } + } + } + // Send to backend if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) { const currentTrackId = getCurrentMidiTrack(); @@ -7483,6 +7724,11 @@ function nodeEditor() { } } + // For Oscilloscope nodes, start the visualization + if (nodeType === 'Oscilloscope' && serializedNode.id && trackId) { + startOscilloscopeVisualization(drawflowId, trackId, serializedNode.id); + } + resolve(); }, 100); })); @@ -7501,6 +7747,28 @@ function nodeEditor() { `output_${conn.from_port + 1}`, `input_${conn.to_port + 1}` ); + + // Style the connection based on signal type + // We need to look up the node type and get the output port signal type + setupPromises.push(new Promise(resolve => { + setTimeout(() => { + const outputNode = editor.getNodeFromId(outputDrawflowId); + if (outputNode) { + const nodeType = outputNode.data.nodeType; + const nodeDef = nodeTypes[nodeType]; + if (nodeDef && conn.from_port < nodeDef.outputs.length) { + const signalType = nodeDef.outputs[conn.from_port].type; + const connectionElement = document.querySelector( + `.connection.node_in_node-${inputDrawflowId}.node_out_node-${outputDrawflowId}` + ); + if (connectionElement) { + connectionElement.classList.add(`connection-${signalType}`); + } + } + } + resolve(); + }, 10); + })); } } @@ -8525,6 +8793,118 @@ async function loadMIDIFile(trackId, path, startTime) { } } +// ========== Oscilloscope Visualization ========== + +// Store oscilloscope update intervals by node ID +const oscilloscopeIntervals = new Map(); +// Store oscilloscope time scales by node ID +const oscilloscopeTimeScales = new Map(); + +// Start oscilloscope visualization for a node +function startOscilloscopeVisualization(nodeId, trackId, backendNodeId) { + // Clear any existing interval for this node + stopOscilloscopeVisualization(nodeId); + + // Find the canvas by traversing from the node element + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) { + console.warn(`Node element not found for node ${nodeId}`); + return; + } + + const canvas = nodeElement.querySelector('canvas[id^="oscilloscope-canvas-"]'); + if (!canvas) { + console.warn(`Oscilloscope canvas not found in node ${nodeId}`); + return; + } + + console.log(`Found oscilloscope canvas for node ${nodeId}:`, canvas.id); + + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + + // Initialize time scale to default (100ms) + if (!oscilloscopeTimeScales.has(nodeId)) { + oscilloscopeTimeScales.set(nodeId, 100); + } + + // Update function to fetch and draw oscilloscope data + const updateOscilloscope = async () => { + try { + // Calculate samples needed based on time scale + // Assuming 48kHz sample rate + const timeScaleMs = oscilloscopeTimeScales.get(nodeId) || 100; + const sampleRate = 48000; + const samplesNeeded = Math.floor((timeScaleMs / 1000) * sampleRate); + // Cap at 2 seconds worth of samples to avoid excessive memory usage + const sampleCount = Math.min(samplesNeeded, sampleRate * 2); + + // Fetch oscilloscope data + const data = await invoke('get_oscilloscope_data', { + trackId: trackId, + nodeId: backendNodeId, + sampleCount: sampleCount + }); + + // Clear canvas + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, width, height); + + // Draw grid lines + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + + // Horizontal grid lines + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + + // Draw waveform + if (data && data.length > 0) { + ctx.strokeStyle = '#4CAF50'; + ctx.lineWidth = 2; + ctx.beginPath(); + + const xStep = width / data.length; + for (let i = 0; i < data.length; i++) { + const x = i * xStep; + // Map sample value from [-1, 1] to canvas height + const y = height / 2 - (data[i] * height / 2); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + } catch (error) { + console.error('Failed to update oscilloscope:', error); + } + }; + + // Initial update + updateOscilloscope(); + + // Update every 50ms (20 FPS) + const interval = setInterval(updateOscilloscope, 50); + oscilloscopeIntervals.set(nodeId, interval); +} + +// Stop oscilloscope visualization for a node +function stopOscilloscopeVisualization(nodeId) { + const interval = oscilloscopeIntervals.get(nodeId); + if (interval) { + clearInterval(interval); + oscilloscopeIntervals.delete(nodeId); + } +} + +// ========== End Oscilloscope Visualization ========== + async function testAudio() { console.log("Starting rust") await init(); diff --git a/src/nodeTypes.js b/src/nodeTypes.js index e282794..bcb6bc4 100644 --- a/src/nodeTypes.js +++ b/src/nodeTypes.js @@ -310,28 +310,29 @@ export const nodeTypes = { category: NodeCategory.UTILITY, description: 'Visual audio signal monitor (pass-through)', inputs: [ - { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + { name: 'Audio In', type: SignalType.AUDIO, index: 0 }, + { name: 'V/oct', type: SignalType.CV, index: 1 } ], outputs: [ { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } ], parameters: [ { id: 0, name: 'time_scale', label: 'Time Scale', min: 10, max: 1000, default: 100, unit: 'ms' }, - { id: 1, name: 'trigger_mode', label: 'Trigger', min: 0, max: 2, default: 0, unit: '' }, + { id: 1, name: 'trigger_mode', label: 'Trigger', min: 0, max: 3, default: 0, unit: '' }, { id: 2, name: 'trigger_level', label: 'Trigger Level', min: -1, max: 1, default: 0, unit: '' } ], getHTML: (nodeId) => `
Oscilloscope
+
- - + +
- - + +
-
Pass-through monitor
` }, diff --git a/src/styles.css b/src/styles.css index 1746182..b76c1fb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -206,6 +206,19 @@ button { z-index: 1; display: flex; align-items: center; + user-select: none; +} + +.header * { + user-select: none; +} + +.pane { + user-select: none; +} + +.vertical-grid, .horizontal-grid { + user-select: none; } .icon { @@ -1182,6 +1195,7 @@ button { height: 100%; position: relative; background: var(--node-bg); + user-select: none; } /* Node editor header and breadcrumb */ @@ -1197,6 +1211,11 @@ button { align-items: center; padding: 0 16px; z-index: 200; + user-select: none; +} + +.node-editor-header * { + user-select: none; } .context-breadcrumb { @@ -1247,6 +1266,11 @@ button { max-height: calc(100% - 100px); overflow-y: auto; z-index: 100; + user-select: none; +} + +.node-palette * { + user-select: none; } .node-palette h3 { @@ -1454,7 +1478,8 @@ button { /* Signal Type Connectors */ /* Audio ports - Blue circles (matches audio clips) */ -.connector-audio { +.drawflow .drawflow-node .input.connector-audio, +.drawflow .drawflow-node .output.connector-audio { width: 14px !important; height: 14px !important; border-radius: 50% !important; @@ -1462,8 +1487,16 @@ button { border: 2px solid #1565C0 !important; } +.drawflow .drawflow-node .input.connector-audio:hover, +.drawflow .drawflow-node .output.connector-audio:hover { + background: #42A5F5 !important; + border: 2px solid #1976D2 !important; + border-radius: 50% !important; +} + /* MIDI ports - Green squares (matches MIDI clips) */ -.connector-midi { +.drawflow .drawflow-node .input.connector-midi, +.drawflow .drawflow-node .output.connector-midi { width: 14px !important; height: 14px !important; border-radius: 2px !important; @@ -1471,14 +1504,30 @@ button { border: 2px solid #2E7D32 !important; } +.drawflow .drawflow-node .input.connector-midi:hover, +.drawflow .drawflow-node .output.connector-midi:hover { + background: #66BB6A !important; + border: 2px solid #388E3C !important; + border-radius: 2px !important; +} + /* CV ports - Orange diamonds */ -.connector-cv { +.drawflow .drawflow-node .input.connector-cv, +.drawflow .drawflow-node .output.connector-cv { width: 12px !important; height: 12px !important; background: #FF9800 !important; border: 2px solid #E65100 !important; + border-radius: 0 !important; + transform: rotate(45deg) !important; +} + +.drawflow .drawflow-node .input.connector-cv:hover, +.drawflow .drawflow-node .output.connector-cv:hover { + background: #FFA726 !important; + border: 2px solid #EF6C00 !important; + border-radius: 0 !important; transform: rotate(45deg) !important; - border-radius: 2px !important; } /* Connection line styling - Override Drawflow defaults */ @@ -1655,6 +1704,7 @@ button { display: flex; flex-direction: column; gap: 8px; + user-select: none; } .preset-item {