Node graph improvements and fixes

This commit is contained in:
Skyler Lehmkuhl 2025-10-28 08:51:53 -04:00
parent d2354e4864
commit 9d6eaa5bba
9 changed files with 555 additions and 29 deletions

View File

@ -1204,6 +1204,15 @@ impl Engine {
QueryResponse::GraphState(Err(format!("Track {} not found or is not a MIDI track", track_id))) 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 // Send response back
@ -1759,4 +1768,26 @@ impl EngineController {
Err("Query timeout".to_string()) 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<Vec<f32>, 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())
}
} }

View File

@ -13,6 +13,7 @@ pub enum TriggerMode {
FreeRunning = 0, FreeRunning = 0,
RisingEdge = 1, RisingEdge = 1,
FallingEdge = 2, FallingEdge = 2,
VoltPerOctave = 3,
} }
impl TriggerMode { impl TriggerMode {
@ -20,6 +21,7 @@ impl TriggerMode {
match value.round() as i32 { match value.round() as i32 {
1 => TriggerMode::RisingEdge, 1 => TriggerMode::RisingEdge,
2 => TriggerMode::FallingEdge, 2 => TriggerMode::FallingEdge,
3 => TriggerMode::VoltPerOctave,
_ => TriggerMode::FreeRunning, _ => TriggerMode::FreeRunning,
} }
} }
@ -80,6 +82,9 @@ pub struct OscilloscopeNode {
trigger_mode: TriggerMode, trigger_mode: TriggerMode,
trigger_level: f32, // -1.0 to 1.0 trigger_level: f32, // -1.0 to 1.0
last_sample: f32, // For edge detection 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 // Shared buffer for reading from Tauri commands
buffer: Arc<Mutex<CircularBuffer>>, buffer: Arc<Mutex<CircularBuffer>>,
@ -95,6 +100,7 @@ impl OscilloscopeNode {
let inputs = vec![ let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0), NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("V/oct", SignalType::CV, 1),
]; ];
let outputs = vec![ let outputs = vec![
@ -103,7 +109,7 @@ impl OscilloscopeNode {
let parameters = vec![ let parameters = vec![
Parameter::new(PARAM_TIME_SCALE, "Time Scale", 10.0, 1000.0, 100.0, ParameterUnit::Time), 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), 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_mode: TriggerMode::FreeRunning,
trigger_level: 0.0, trigger_level: 0.0,
last_sample: 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))), buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
inputs, inputs,
outputs, 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 /// Check if trigger condition is met
fn is_triggered(&self, current_sample: f32) -> bool { fn is_triggered(&self, current_sample: f32) -> bool {
match self.trigger_mode { match self.trigger_mode {
@ -151,6 +166,10 @@ impl OscilloscopeNode {
TriggerMode::FallingEdge => { TriggerMode::FallingEdge => {
self.last_sample >= self.trigger_level && current_sample < self.trigger_level 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]], outputs: &mut [&mut [f32]],
_midi_inputs: &[&[MidiEvent]], _midi_inputs: &[&[MidiEvent]],
_midi_outputs: &mut [&mut Vec<MidiEvent>], _midi_outputs: &mut [&mut Vec<MidiEvent>],
_sample_rate: u32, sample_rate: u32,
) { ) {
if inputs.is_empty() || outputs.is_empty() { if inputs.is_empty() || outputs.is_empty() {
return; return;
@ -206,6 +225,20 @@ impl AudioNode for OscilloscopeNode {
let output = &mut outputs[0]; let output = &mut outputs[0];
let len = input.len().min(output.len()); 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) // Pass through audio (copy input to output)
output[..len].copy_from_slice(&input[..len]); output[..len].copy_from_slice(&input[..len]);
@ -222,6 +255,8 @@ impl AudioNode for OscilloscopeNode {
fn reset(&mut self) { fn reset(&mut self) {
self.last_sample = 0.0; self.last_sample = 0.0;
self.voct_value = 0.0;
self.sample_counter = 0;
self.clear_buffer(); self.clear_buffer();
} }
@ -240,6 +275,9 @@ impl AudioNode for OscilloscopeNode {
trigger_mode: self.trigger_mode, trigger_mode: self.trigger_mode,
trigger_level: self.trigger_level, trigger_level: self.trigger_level,
last_sample: 0.0, last_sample: 0.0,
voct_value: 0.0,
sample_counter: 0,
trigger_period: 480,
buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))),
inputs: self.inputs.clone(), inputs: self.inputs.clone(),
outputs: self.outputs.clone(), outputs: self.outputs.clone(),

View File

@ -207,6 +207,8 @@ pub enum Query {
GetGraphState(TrackId), GetGraphState(TrackId),
/// Get a voice allocator's template graph state as JSON (track_id, voice_allocator_id) /// Get a voice allocator's template graph state as JSON (track_id, voice_allocator_id)
GetTemplateState(TrackId, u32), GetTemplateState(TrackId, u32),
/// Get oscilloscope data from a node (track_id, node_id, sample_count)
GetOscilloscopeData(TrackId, u32, usize),
} }
/// Responses to synchronous queries /// Responses to synchronous queries
@ -214,4 +216,6 @@ pub enum Query {
pub enum QueryResponse { pub enum QueryResponse {
/// Graph state as JSON string /// Graph state as JSON string
GraphState(Result<String, String>), GraphState(Result<String, String>),
/// Oscilloscope data samples
OscilloscopeData(Result<Vec<f32>, String>),
} }

View File

@ -1131,6 +1131,22 @@ pub async fn multi_sampler_remove_layer(
} }
} }
#[tauri::command]
pub async fn get_oscilloscope_data(
state: tauri::State<'_, Arc<Mutex<AudioState>>>,
track_id: u32,
node_id: u32,
sample_count: usize,
) -> Result<Vec<f32>, 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)] #[derive(serde::Serialize, Clone)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum SerializedAudioEvent { pub enum SerializedAudioEvent {

View File

@ -233,6 +233,7 @@ pub fn run() {
audio::multi_sampler_get_layers, audio::multi_sampler_get_layers,
audio::multi_sampler_update_layer, audio::multi_sampler_update_layer,
audio::multi_sampler_remove_layer, audio::multi_sampler_remove_layer,
audio::get_oscilloscope_data,
]) ])
// .manage(window_counter) // .manage(window_counter)
.build(tauri::generate_context!()) .build(tauri::generate_context!())

View File

@ -118,6 +118,11 @@
padding-bottom: var(--dfNodePaddingBottom); padding-bottom: var(--dfNodePaddingBottom);
-webkit-box-shadow: var(--dfNodeBoxShadowHL) var(--dfNodeBoxShadowVL) var(--dfNodeBoxShadowBR) var(--dfNodeBoxShadowS) var(--dfNodeBoxShadowColor); -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); 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 { .drawflow .drawflow-node:hover {

View File

@ -6093,6 +6093,26 @@ function nodeEditor() {
const container = document.createElement("div"); const container = document.createElement("div");
container.id = "node-editor-container"; 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 // Track editing context: null = main graph, {voiceAllocatorId, voiceAllocatorName} = editing template
let editingContext = null; let editingContext = null;
@ -6207,6 +6227,97 @@ function nodeEditor() {
// Store editor reference in context // Store editor reference in context
context.nodeEditor = editor; 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 // Add trackpad/mousewheel scrolling support for panning
drawflowDiv.addEventListener('wheel', (e) => { drawflowDiv.addEventListener('wheel', (e) => {
// Don't scroll if hovering over palette or other UI elements // 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"); const item = e.target.closest(".node-palette-item");
if (item) { if (item) {
const nodeType = item.getAttribute("data-node-type"); 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 // Get drop position relative to the editor
const rect = drawflowDiv.getBoundingClientRect(); 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 // Check if dropping into an expanded VoiceAllocator
let parentNodeId = null; let parentNodeId = null;
@ -6385,6 +6529,9 @@ function nodeEditor() {
return; return;
} }
// Stop oscilloscope visualization if this was an Oscilloscope node
stopOscilloscopeVisualization(nodeId);
// Clean up parent-child tracking // Clean up parent-child tracking
const parentId = nodeParents.get(nodeId); const parentId = nodeParents.get(nodeId);
nodeParents.delete(nodeId); nodeParents.delete(nodeId);
@ -6423,6 +6570,24 @@ function nodeEditor() {
html 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 // Track parent-child relationship
if (parentNodeId !== null) { if (parentNodeId !== null) {
nodeParents.set(drawflowNodeId, parentNodeId); 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 this is a VoiceAllocator, automatically create template I/O nodes inside it
if (nodeType === "VoiceAllocator") { if (nodeType === "VoiceAllocator") {
setTimeout(() => { setTimeout(() => {
@ -6613,10 +6790,8 @@ function nodeEditor() {
inputs.forEach((input, index) => { inputs.forEach((input, index) => {
if (index < nodeDef.inputs.length) { if (index < nodeDef.inputs.length) {
const portDef = nodeDef.inputs[index]; const portDef = nodeDef.inputs[index];
const connector = input.querySelector(".input_0, .input_1, .input_2, .input_3"); // Add connector styling class directly to the input element
if (connector) { input.classList.add(getPortClass(portDef.type));
connector.classList.add(getPortClass(portDef.type));
}
// Add label // Add label
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = portDef.name; label.textContent = portDef.name;
@ -6629,10 +6804,8 @@ function nodeEditor() {
outputs.forEach((output, index) => { outputs.forEach((output, index) => {
if (index < nodeDef.outputs.length) { if (index < nodeDef.outputs.length) {
const portDef = nodeDef.outputs[index]; const portDef = nodeDef.outputs[index];
const connector = output.querySelector(".output_0, .output_1, .output_2, .output_3"); // Add connector styling class directly to the output element
if (connector) { output.classList.add(getPortClass(portDef.type));
connector.classList.add(getPortClass(portDef.type));
}
// Add label // Add label
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = portDef.name; label.textContent = portDef.name;
@ -6661,15 +6834,32 @@ function nodeEditor() {
const paramId = parseInt(e.target.getAttribute("data-param")); const paramId = parseInt(e.target.getAttribute("data-param"));
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
console.log(`[setupNodeParameters] Slider input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
// Update display // Update display
const nodeData = editor.getNodeFromId(nodeId); const nodeData = editor.getNodeFromId(nodeId);
if (nodeData) { if (nodeData) {
const nodeDef = nodeTypes[nodeData.name]; const nodeDef = nodeTypes[nodeData.name];
console.log(`[setupNodeParameters] Found node type: ${nodeData.name}, parameters:`, nodeDef?.parameters);
if (nodeDef && nodeDef.parameters[paramId]) { if (nodeDef && nodeDef.parameters[paramId]) {
const param = nodeDef.parameters[paramId]; const param = nodeDef.parameters[paramId];
console.log(`[setupNodeParameters] Looking for span: #${param.name}-${nodeId}`);
const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`); const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`);
console.log(`[setupNodeParameters] Found span:`, displaySpan);
if (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"); 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 // Style the connection based on signal type
setTimeout(() => { setTimeout(() => {
const connectionElement = document.querySelector( const connectionElement = document.querySelector(
@ -7123,6 +7339,31 @@ function nodeEditor() {
const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1; const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1;
const inputPort = parseInt(connection.input_class.replace("input_", "")) - 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 // Send to backend
if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) { if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack(); 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(); resolve();
}, 100); }, 100);
})); }));
@ -7501,6 +7747,28 @@ function nodeEditor() {
`output_${conn.from_port + 1}`, `output_${conn.from_port + 1}`,
`input_${conn.to_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() { async function testAudio() {
console.log("Starting rust") console.log("Starting rust")
await init(); await init();

View File

@ -310,28 +310,29 @@ export const nodeTypes = {
category: NodeCategory.UTILITY, category: NodeCategory.UTILITY,
description: 'Visual audio signal monitor (pass-through)', description: 'Visual audio signal monitor (pass-through)',
inputs: [ 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: [ outputs: [
{ name: 'Audio Out', type: SignalType.AUDIO, index: 0 } { name: 'Audio Out', type: SignalType.AUDIO, index: 0 }
], ],
parameters: [ parameters: [
{ id: 0, name: 'time_scale', label: 'Time Scale', min: 10, max: 1000, default: 100, unit: 'ms' }, { 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: '' } { id: 2, name: 'trigger_level', label: 'Trigger Level', min: -1, max: 1, default: 0, unit: '' }
], ],
getHTML: (nodeId) => ` getHTML: (nodeId) => `
<div class="node-content"> <div class="node-content">
<div class="node-title">Oscilloscope</div> <div class="node-title">Oscilloscope</div>
<canvas id="oscilloscope-canvas-${nodeId}" width="200" height="80" style="width: 200px; height: 80px; background: #1a1a1a; border: 1px solid #444; border-radius: 2px; display: block; margin: 4px 0;"></canvas>
<div class="node-param"> <div class="node-param">
<label>Time: <span id="time-${nodeId}">100</span>ms</label> <label>Time: <span id="time_scale-${nodeId}">100</span>ms</label>
<input type="range" data-node="${nodeId}" data-param="0" min="10" max="1000" value="100" step="10"> <input type="range" class="node-slider" data-node="${nodeId}" data-param="0" min="10" max="1000" value="100" step="10">
</div> </div>
<div class="node-param"> <div class="node-param">
<label>Trigger: <span id="trig-${nodeId}">Free</span></label> <label>Trigger: <span id="trigger_mode-${nodeId}">Free</span></label>
<input type="range" data-node="${nodeId}" data-param="1" min="0" max="2" value="0" step="1"> <input type="range" class="node-slider" data-node="${nodeId}" data-param="1" min="0" max="3" value="0" step="1">
</div> </div>
<div class="node-info" style="margin-top: 4px; font-size: 10px;">Pass-through monitor</div>
</div> </div>
` `
}, },

View File

@ -206,6 +206,19 @@ button {
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
user-select: none;
}
.header * {
user-select: none;
}
.pane {
user-select: none;
}
.vertical-grid, .horizontal-grid {
user-select: none;
} }
.icon { .icon {
@ -1182,6 +1195,7 @@ button {
height: 100%; height: 100%;
position: relative; position: relative;
background: var(--node-bg); background: var(--node-bg);
user-select: none;
} }
/* Node editor header and breadcrumb */ /* Node editor header and breadcrumb */
@ -1197,6 +1211,11 @@ button {
align-items: center; align-items: center;
padding: 0 16px; padding: 0 16px;
z-index: 200; z-index: 200;
user-select: none;
}
.node-editor-header * {
user-select: none;
} }
.context-breadcrumb { .context-breadcrumb {
@ -1247,6 +1266,11 @@ button {
max-height: calc(100% - 100px); max-height: calc(100% - 100px);
overflow-y: auto; overflow-y: auto;
z-index: 100; z-index: 100;
user-select: none;
}
.node-palette * {
user-select: none;
} }
.node-palette h3 { .node-palette h3 {
@ -1454,7 +1478,8 @@ button {
/* Signal Type Connectors */ /* Signal Type Connectors */
/* Audio ports - Blue circles (matches audio clips) */ /* Audio ports - Blue circles (matches audio clips) */
.connector-audio { .drawflow .drawflow-node .input.connector-audio,
.drawflow .drawflow-node .output.connector-audio {
width: 14px !important; width: 14px !important;
height: 14px !important; height: 14px !important;
border-radius: 50% !important; border-radius: 50% !important;
@ -1462,8 +1487,16 @@ button {
border: 2px solid #1565C0 !important; 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) */ /* MIDI ports - Green squares (matches MIDI clips) */
.connector-midi { .drawflow .drawflow-node .input.connector-midi,
.drawflow .drawflow-node .output.connector-midi {
width: 14px !important; width: 14px !important;
height: 14px !important; height: 14px !important;
border-radius: 2px !important; border-radius: 2px !important;
@ -1471,14 +1504,30 @@ button {
border: 2px solid #2E7D32 !important; 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 */ /* CV ports - Orange diamonds */
.connector-cv { .drawflow .drawflow-node .input.connector-cv,
.drawflow .drawflow-node .output.connector-cv {
width: 12px !important; width: 12px !important;
height: 12px !important; height: 12px !important;
background: #FF9800 !important; background: #FF9800 !important;
border: 2px solid #E65100 !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; transform: rotate(45deg) !important;
border-radius: 2px !important;
} }
/* Connection line styling - Override Drawflow defaults */ /* Connection line styling - Override Drawflow defaults */
@ -1655,6 +1704,7 @@ button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
user-select: none;
} }
.preset-item { .preset-item {