diff --git a/engine/engine/src/js.rs b/engine/engine/src/js.rs index d3f29ac5..cdfa49b8 100644 --- a/engine/engine/src/js.rs +++ b/engine/engine/src/js.rs @@ -13,6 +13,7 @@ extern "C" { subgraphs_by_id_json: &str, ); pub fn add_view_context(id: &str, name: &str, subgraph_id: &str); + pub fn add_foreign_connectable(fc_json: &str); pub fn delete_view_context(id: &str); pub fn set_active_vc_id(new_id: &str); pub fn set_subgraphs(active_subgraph_id: &str, subgraphs_by_id_json: &str); diff --git a/engine/engine/src/view_context/manager.rs b/engine/engine/src/view_context/manager.rs index 9c2b91d0..6a4cac28 100644 --- a/engine/engine/src/view_context/manager.rs +++ b/engine/engine/src/view_context/manager.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use fxhash::FxHashMap; use serde::{Deserialize, Serialize}; +use serde_json::json; use uuid::Uuid; use crate::{ @@ -176,6 +177,13 @@ impl ViewContextManager { created_ix } + /// This triggers a fresh FC to get created on the frontend along with its associated node. + /// + /// The frontend will then commit the FCs back here + fn add_foreign_connectable(&mut self, fc: ForeignConnectable) { + js::add_foreign_connectable(&serde_json::to_string(&fc).unwrap()); + } + pub fn get_vc_by_id_mut(&mut self, uuid: Uuid) -> Option<&mut ViewContextEntry> { self .get_vc_position(uuid) @@ -347,20 +355,40 @@ impl ViewContextManager { .subgraphs_by_id .insert(new_subgraph_id, SubgraphDescriptor { id: new_subgraph_id, - name: format!("Subgraph {}", new_subgraph_id), + name: "New Subgraph".to_owned(), active_vc_id: new_graph_editor_vc_id, }); + js::set_subgraphs( + &self.active_subgraph_id.to_string(), + &serde_json::to_string(&self.subgraphs_by_id).unwrap(), + ); + + // Start out the new subgraph with a graph editor self.add_view_context( new_graph_editor_vc_id, "graph_editor".to_string(), mk_graph_editor(new_graph_editor_vc_id), new_subgraph_id, ); - self.save_all(); - js::set_subgraphs( - &self.active_subgraph_id.to_string(), - &serde_json::to_string(&self.subgraphs_by_id).unwrap(), - ); + + // Add ssubgraph portals to and from the subgraph so the user can navigate between them + self.add_foreign_connectable(ForeignConnectable { + _type: "customAudio/subgraphPortal".to_owned(), + id: String::new(), + serialized_state: Some( + json!({ "txSubgraphID": self.active_subgraph_id, "rxSubgraphID": new_subgraph_id }), + ), + subgraph_id: self.active_subgraph_id, + }); + self.add_foreign_connectable(ForeignConnectable { + _type: "customAudio/subgraphPortal".to_owned(), + id: String::new(), + serialized_state: Some( + json!({ "txSubgraphID": new_subgraph_id, "rxSubgraphID": self.active_subgraph_id }), + ), + subgraph_id: new_subgraph_id, + }); + new_subgraph_id } diff --git a/src/controlPanel/PlaceholderInput.tsx b/src/controlPanel/PlaceholderInput.tsx deleted file mode 100644 index 4edd89fc..00000000 --- a/src/controlPanel/PlaceholderInput.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { buildControlPanelAudioConnectables } from 'src/controlPanel/getConnectables'; -import { OverridableAudioParam } from 'src/graphEditor/nodes/util'; -import type { ConnectableDescriptor } from 'src/patchNetwork'; -import { updateConnectables } from 'src/patchNetwork/interface'; -import { actionCreators, dispatch, getState } from 'src/redux'; - -export class PlaceholderInput extends GainNode implements AudioNode { - private controlPanelVcId: string; - - constructor(ctx: AudioContext, controlPanelVcId: string) { - super(ctx); - this.controlPanelVcId = controlPanelVcId; - } - - connect( - destinationNode: AudioNode | AudioParam, - outputNumOrDescriptor?: number | ConnectableDescriptor, - _input?: number - ) { - if (destinationNode instanceof OverridableAudioParam) { - destinationNode.setIsOverridden(true); - } - if (!outputNumOrDescriptor || typeof outputNumOrDescriptor === 'number') { - throw new Error( - 'Must provide `ConnectableDescriptor` as second argument to `connect` for `PlaceholderInput`' - ); - } - const dstDescriptor = outputNumOrDescriptor; - - setTimeout(() => { - // Disconnect from the dummy "add a new control", create a new input for it, and re-connect it to that - - dispatch( - actionCreators.viewContextManager.DISCONNECT( - { vcId: this.controlPanelVcId, name: 'Add a new control...' }, - dstDescriptor - ) - ); - - let outputName = dstDescriptor.name; - while ( - getState().controlPanel.stateByPanelInstance[this.controlPanelVcId].controls.some( - control => control.name === outputName - ) - ) { - outputName += '_1'; - } - - dispatch( - actionCreators.controlPanel.ADD_CONTROL_PANEL_CONNECTION( - this.controlPanelVcId, - dstDescriptor.vcId, - outputName - ) - ); - const instanceState = getState().controlPanel.stateByPanelInstance[this.controlPanelVcId]; - updateConnectables( - this.controlPanelVcId, - buildControlPanelAudioConnectables(this.controlPanelVcId, instanceState) - ); - dispatch( - actionCreators.viewContextManager.CONNECT( - { vcId: this.controlPanelVcId, name: outputName }, - dstDescriptor - ) - ); - }); - - return destinationNode as any; - } - - disconnect(..._args: any) { - // no-op - } -} diff --git a/src/controlPanel/PlaceholderOutput.tsx b/src/controlPanel/PlaceholderOutput.tsx new file mode 100644 index 00000000..76db1cd9 --- /dev/null +++ b/src/controlPanel/PlaceholderOutput.tsx @@ -0,0 +1,98 @@ +import { OverridableAudioParam } from 'src/graphEditor/nodes/util'; +import type { AudioConnectables, ConnectableDescriptor, ConnectableType } from 'src/patchNetwork'; +import { updateConnectables } from 'src/patchNetwork/interface'; +import type { MIDINode } from 'src/patchNetwork/midiNode'; +import { actionCreators, dispatch, getState } from 'src/redux'; + +export class PlaceholderOutput extends GainNode implements AudioNode { + private vcId: string; + private getConnectables: () => AudioConnectables; + private addOutput: ( + outputName: string, + type: ConnectableType, + rxConnectableDescriptor: ConnectableDescriptor + ) => void; + public label: string; + + constructor( + ctx: AudioContext, + vcId: string, + getConnectables: () => AudioConnectables, + addOutput: ( + outputName: string, + type: ConnectableType, + rxConnectableDescriptor: ConnectableDescriptor + ) => void, + label = 'Add a new control...' + ) { + super(ctx); + this.vcId = vcId; + this.getConnectables = getConnectables; + this.addOutput = addOutput; + this.label = label; + } + + connect( + destinationNode: AudioNode | AudioParam | MIDINode, + inputNumOrDescriptor?: number | ConnectableDescriptor, + _input?: number + ) { + if (destinationNode instanceof OverridableAudioParam) { + destinationNode.setIsOverridden(true); + } + if (!inputNumOrDescriptor || typeof inputNumOrDescriptor === 'number') { + throw new Error( + 'Must provide `ConnectableDescriptor` as second argument to `connect` for `PlaceholderOutput`' + ); + } + const rxDescriptor = inputNumOrDescriptor; + + setTimeout(() => { + // Disconnect from the dummy "add a new control", create a new input for it, and re-connect it to that + + dispatch( + actionCreators.viewContextManager.DISCONNECT( + { vcId: this.vcId, name: this.label }, + rxDescriptor + ) + ); + + let outputName = rxDescriptor.name; + while ( + getState() + .viewContextManager.patchNetwork.connectables.get(this.vcId) + ?.inputs.has(outputName) + ) { + outputName += '_1'; + } + + const rxConnectables = getState().viewContextManager.patchNetwork.connectables.get( + rxDescriptor.vcId + ); + if (!rxConnectables) { + throw new Error(`No connectables found for vcId=${rxDescriptor.vcId}`); + } + const rxConnectable = rxConnectables.inputs.get(rxDescriptor.name); + if (!rxConnectable) { + throw new Error( + `No input named "${rxDescriptor.name}" found for vcId=${rxDescriptor.vcId}` + ); + } + this.addOutput(outputName, rxConnectable.type, rxDescriptor); + + updateConnectables(this.vcId, this.getConnectables()); + dispatch( + actionCreators.viewContextManager.CONNECT( + { vcId: this.vcId, name: outputName }, + rxDescriptor + ) + ); + }); + + return destinationNode as any; + } + + disconnect(..._args: any) { + // no-op + } +} diff --git a/src/controlPanel/getConnectables.ts b/src/controlPanel/getConnectables.ts index a0648cf6..fade0bbe 100644 --- a/src/controlPanel/getConnectables.ts +++ b/src/controlPanel/getConnectables.ts @@ -1,7 +1,14 @@ import { Map as ImmMap } from 'immutable'; -import { PlaceholderInput } from 'src/controlPanel/PlaceholderInput'; -import type { AudioConnectables, ConnectableInput, ConnectableOutput } from 'src/patchNetwork'; +import { PlaceholderOutput } from 'src/controlPanel/PlaceholderOutput'; +import type { + AudioConnectables, + ConnectableDescriptor, + ConnectableInput, + ConnectableOutput, + ConnectableType, +} from 'src/patchNetwork'; +import { actionCreators, dispatch, getState } from 'src/redux'; import type { ControlPanelInstanceState } from 'src/redux/modules/controlPanel'; import { UnimplementedError } from 'src/util'; @@ -22,7 +29,22 @@ export const buildControlPanelAudioConnectables = ( const outputs = existingConnections.set('Add a new control...', { type: 'number', - node: new PlaceholderInput(ctx, vcId), + node: new PlaceholderOutput( + ctx, + vcId, + () => { + const instanceState = getState().controlPanel.stateByPanelInstance[vcId]; + return buildControlPanelAudioConnectables(vcId, instanceState); + }, + (inputName: string, type: ConnectableType, rxConnectableDescriptor: ConnectableDescriptor) => + void dispatch( + actionCreators.controlPanel.ADD_CONTROL_PANEL_CONNECTION( + vcId, + rxConnectableDescriptor.vcId, + inputName + ) + ) + ), }); return { diff --git a/src/graphEditor/GraphEditor.tsx b/src/graphEditor/GraphEditor.tsx index 1c832c91..1486c8a8 100644 --- a/src/graphEditor/GraphEditor.tsx +++ b/src/graphEditor/GraphEditor.tsx @@ -3,8 +3,12 @@ * components of an audio composition. */ -import { LGraph, LGraphCanvas, type LGraphNode, LiteGraph } from 'litegraph.js'; +import { LGraph, LGraphCanvas, LiteGraph, type LGraphNode } from 'litegraph.js'; +import type { + LiteGraphConnectablesNode, + LiteGraph as LiteGraphInstance, +} from 'src/graphEditor/LiteGraphTypes'; import 'litegraph.js/css/litegraph.css'; import * as R from 'ramda'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -16,7 +20,7 @@ import { hide_graph_editor, setLGraphHandle } from 'src/graphEditor'; import { updateGraph } from 'src/graphEditor/graphDiffing'; import { LGAudioConnectables } from 'src/graphEditor/nodes/AudioConnectablesNode'; import FlatButton from 'src/misc/FlatButton'; -import { getState, type ReduxStore } from 'src/redux'; +import { actionCreators, dispatch, getState, type ReduxStore } from 'src/redux'; import { UnreachableError, filterNils, getEngine, tryParseJson } from 'src/util'; import { ViewContextDescriptors } from 'src/ViewContextManager/AddModulePicker'; import { @@ -26,6 +30,10 @@ import { } from 'src/ViewContextManager/VcHideStatusRegistry'; import { registerAllCustomNodes } from './nodes'; import type { AudioConnectables } from 'src/patchNetwork'; +import { audioNodeGetters, buildNewForeignConnectableID } from 'src/graphEditor/nodes/CustomAudio'; +import { removeNode } from 'src/patchNetwork/interface'; + +const ctx = new AudioContext(); LGraphCanvas.prototype.getCanvasMenuOptions = () => []; const oldGetNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions; @@ -41,7 +49,7 @@ LGraphCanvas.prototype.getNodeMenuOptions = function (node: LGraphNode) { 'Pin', 'Resize', ]; - const filteredOptions = options.filter(item => { + let filteredOptions = options.filter(item => { if (!item) { return true; } @@ -57,12 +65,25 @@ LGraphCanvas.prototype.getNodeMenuOptions = function (node: LGraphNode) { } // Remove duplicate subsequent nulls which map to dividers in the menu - return filteredOptions.filter((opt, i) => { + filteredOptions = filteredOptions.filter((opt, i) => { if (i > 0 && opt === null && filteredOptions[i - 1] === null) { return false; } return true; }); + + // Patch the remove option to delete the node directly from the patch network + const removeOption = filteredOptions.find(opt => opt?.content === 'Remove'); + if (!removeOption) { + throw new Error('Failed to find "Remove" option in node menu'); + } + + removeOption.callback = (_value, _options, _event, _parentMenu, node) => { + const vcId = node.id.toString(); + removeNode(vcId); + }; + + return filteredOptions; }; LGraphCanvas.prototype.showLinkMenu = function (link: any, e) { const options = ['Delete']; @@ -113,20 +134,26 @@ export const saveStateForInstance = (stateKey: string) => { const state = instance.serialize(); const selectedNodes: { [key: string]: any } = instance.list_of_graphcanvas?.[0]?.selected_nodes ?? {}; - state.selectedNodeVcId = Object.values(selectedNodes)[0]?.connectables?.vcId; + (state as any).selectedNodeVcId = Object.values(selectedNodes)[0]?.connectables?.vcId; localStorage.setItem(stateKey, JSON.stringify(state)); GraphEditorInstances.delete(stateKey); }; -const getLGNodeByVcId = (vcId: string) => { +const getLGNodesByVcId = (vcId: string) => { + const nodes = []; + for (const instance of GraphEditorInstances.values()) { - if (instance._nodes_by_id[vcId]) { - return instance._nodes_by_id[vcId]; + if ((instance as any as LiteGraphInstance)._nodes_by_id[vcId]) { + const node = (instance as any as LiteGraphInstance)._nodes_by_id[vcId]; + if (node) { + nodes.push(node); + } } } - return null; + + return nodes; }; const FlowingIntervalHandles = new Map(); @@ -136,10 +163,12 @@ export const setConnectionFlowingStatus = ( isFlowing: boolean ) => { if (!isFlowing) { - const node = getLGNodeByVcId(vcId); - const outputIx = node?.outputs?.findIndex(R.propEq(outputName, 'name')) ?? -1; - if (!!node && outputIx !== -1) { - node.clearTriggeredSlot(outputIx); + const nodes = getLGNodesByVcId(vcId); + for (const node of nodes) { + const outputIx = node?.outputs?.findIndex(R.propEq(outputName, 'name')) ?? -1; + if (!!node && outputIx !== -1) { + node.clearTriggeredSlot(outputIx); + } } const intervalHandle = FlowingIntervalHandles.get(vcId); @@ -153,21 +182,23 @@ export const setConnectionFlowingStatus = ( } const setFlowingCb = () => { - const node = getLGNodeByVcId(vcId); + const nodes = getLGNodesByVcId(vcId); - const outputIx = node?.outputs?.findIndex(R.propEq(outputName, 'name')) ?? -1; + for (const node of nodes) { + const outputIx = node?.outputs?.findIndex(R.propEq(outputName, 'name')) ?? -1; - if (node === null || outputIx === -1) { - const intervalHandle = FlowingIntervalHandles.get(vcId); - // Node or connection must have gone away - if (!R.isNil(intervalHandle)) { - clearInterval(intervalHandle); - FlowingIntervalHandles.delete(vcId); + if (node === null || outputIx === -1) { + const intervalHandle = FlowingIntervalHandles.get(vcId); + // Node or connection must have gone away + if (!R.isNil(intervalHandle)) { + clearInterval(intervalHandle); + FlowingIntervalHandles.delete(vcId); + } + return; } - return; - } - node.triggerSlot(outputIx); + node.triggerSlot(outputIx); + } }; const intervalHandle = setInterval(setFlowingCb, 1000); @@ -253,11 +284,7 @@ const buildSortedNodeEntries = () => { * * @param nodeType The node type from `buildSortedNodeEntries` */ -const createNode = ( - lGraphInstance: LGraph | null, - nodeType: string, - params?: Record | null -) => { +const createNode = (nodeType: string, subgraphId: string, params?: Record | null) => { const isVc = !nodeType.startsWith('customAudio/'); if (isVc) { const engine = getEngine(); @@ -270,27 +297,17 @@ const createNode = ( return; } - if (!lGraphInstance) { - return; - } - const node = LiteGraph.createNode(nodeType); - // Hacky way of providing some initial params for this node - (node as any).foreignNodeParams = params; - lGraphInstance.add(node); + const id = buildNewForeignConnectableID().toString(); + const node = new audioNodeGetters[nodeType]!.nodeGetter(ctx, id, params); + const connectables = node.buildConnectables(); + dispatch(actionCreators.viewContextManager.ADD_PATCH_NETWORK_NODE(id, connectables, subgraphId)); }; /** * Adds a new subgraph to the engine and creates a subgraph portal node in the current graph so that * the new subgraph can be moved into and connected to. */ -const addSubgraph = (graph: LGraph) => { - const addedSubgraphID = getEngine()!.add_subgraph(); - const curSubgraphID = getState().viewContextManager.activeSubgraphID; - createNode(graph, 'customAudio/subgraphPortal', { - txSubgraphID: curSubgraphID, - rxSubgraphID: addedSubgraphID, - }); -}; +const addSubgraph = () => getEngine()!.add_subgraph(); interface GraphControlsProps { lGraphInstance: LGraph | null; @@ -315,7 +332,8 @@ const GraphControls: React.FC = ({ lGraphInstance }) => { { type: 'button', label: 'add node', - action: () => createNode(lGraphInstance, selectedNodeType.current), + action: () => + createNode(selectedNodeType.current, getState().viewContextManager.activeSubgraphID), }, ]); }, [lGraphInstance, selectedNodeType]); @@ -354,9 +372,21 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { }, [setCurSelectedNodeInner] ); - const { patchNetwork, activeViewContexts, isLoaded } = useSelector( - (state: ReduxStore) => - R.pick(['patchNetwork', 'activeViewContexts', 'isLoaded'], state.viewContextManager), + const vcId = stateKey.split('_')[1]; + const { patchNetwork, activeViewContexts, isLoaded, subgraphID } = useSelector( + (state: ReduxStore) => { + const subgraphID = state.viewContextManager.activeViewContexts.find( + vc => vc.uuid === vcId + )?.subgraphId; + if (!subgraphID) { + throw new Error('Unable to determine subgraph ID for graph editor'); + } + + return { + ...R.pick(['patchNetwork', 'activeViewContexts', 'isLoaded'], state.viewContextManager), + subgraphID, + }; + }, shallowEqual ); @@ -374,7 +404,6 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { return () => window.removeEventListener('resize', onResize); }, []); - const vcId = stateKey.split('_')[1]; const smallViewDOMId = `graph-editor_${vcId}_small-view-dom-id`; useEffect(() => { @@ -473,7 +502,7 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { curSelectedNodeRef, }); }; - graph.onNodeRemoved = node => { + (graph as any).onNodeRemoved = (node: LGraphNode) => { handleNodeSelectAction({ smallViewDOMId, lgNode: node, @@ -502,9 +531,9 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { } const [, nodeType] = entry; if (nodeType === 'ADD_SUBGRAPH') { - addSubgraph(graph); + addSubgraph(); } else { - createNode(graph, nodeType); + createNode(nodeType, subgraphID); } }; @@ -521,7 +550,7 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { // Set an entry into the mapping so that we can get the current instance's state before unmounting GraphEditorInstances.set(stateKey, graph); })(); - }, [curSelectedNode, setCurSelectedNode, smallViewDOMId, stateKey, vcId]); + }, [curSelectedNode, setCurSelectedNode, smallViewDOMId, stateKey, subgraphID, vcId]); const lastPatchNetwork = useRef(null); useEffect(() => { @@ -529,7 +558,12 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { return; } - updateGraph(lGraphInstance, patchNetwork, activeViewContexts); + updateGraph( + lGraphInstance as any as LiteGraphInstance, + patchNetwork, + activeViewContexts, + subgraphID + ); lastPatchNetwork.current = patchNetwork; // If there is a currently selected node, it may have been de-selected as a result of being modified. Try @@ -538,7 +572,9 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { return; } - const node = lGraphInstance._nodes.find(node => node.connectables?.vcId === selectedNodeVCID); + const node = (lGraphInstance as any)._nodes.find( + (node: LiteGraphConnectablesNode) => node.connectables?.vcId === selectedNodeVCID + ); if (!node) { setSelectedNodeVCID(null); return; @@ -547,7 +583,14 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { setCurSelectedNode(node); lGraphInstance.list_of_graphcanvas?.[0]?.selectNodes([node]); lGraphInstance.list_of_graphcanvas?.[0]?.onNodeSelected?.(node); - }, [patchNetwork, lGraphInstance, activeViewContexts, selectedNodeVCID, setCurSelectedNode]); + }, [ + patchNetwork, + lGraphInstance, + activeViewContexts, + selectedNodeVCID, + setCurSelectedNode, + subgraphID, + ]); // Set node from serialized state when we first render useEffect(() => { @@ -569,17 +612,17 @@ const GraphEditor: React.FC<{ stateKey: string }> = ({ stateKey }) => { } if (state.selectedNodeVcId) { - const node = lGraphInstance._nodes.find( + const node = (lGraphInstance as any as LiteGraphInstance)._nodes.find( node => node.connectables?.vcId === state.selectedNodeVcId - ); + ) as any; setCurSelectedNode(node); setSelectedNodeVCID(state.selectedNodeVcId); lGraphInstance.list_of_graphcanvas?.[0]?.selectNodes([node]); - lGraphInstance.list_of_graphcanvas?.[0]?.onNodeSelected(node); + lGraphInstance.list_of_graphcanvas?.[0]?.onNodeSelected?.(node); } state.nodes.forEach(({ id, pos }) => { - const node = lGraphInstance._nodes_by_id[id]; + const node = (lGraphInstance as any as LiteGraphInstance)._nodes_by_id[id]; if (!node) { return; } diff --git a/src/graphEditor/graphDiffing.ts b/src/graphEditor/graphDiffing.ts index 365a14ed..251700fb 100644 --- a/src/graphEditor/graphDiffing.ts +++ b/src/graphEditor/graphDiffing.ts @@ -1,6 +1,6 @@ import { Option } from 'funfix-core'; -import { Map, Set } from 'immutable'; -import { LiteGraph } from 'litegraph.js'; +import { Map as ImmMap, Set as ImmSet } from 'immutable'; +import { type LGraph, LiteGraph } from 'litegraph.js'; import * as R from 'ramda'; import type { @@ -9,7 +9,7 @@ import type { LiteGraphNode, } from 'src/graphEditor/LiteGraphTypes'; import type { AudioConnectables, PatchNetwork } from 'src/patchNetwork'; -import type { ReduxStore } from 'src/redux'; +import { getState, type ReduxStore } from 'src/redux'; import type { ArrayElementOf } from 'src/util'; const createAudioConnectablesNode = ( @@ -22,7 +22,7 @@ const createAudioConnectablesNode = ( typeOverride || 'audio/audioConnectables', title, {} - ) as LiteGraphConnectablesNode; + ) as any as LiteGraphConnectablesNode; node.id = vcId.toString(); node.setConnectables(connectables); return node; @@ -46,30 +46,46 @@ const getVcTitle = ( export const updateGraph = ( graph: LiteGraphInstance, patchNetwork: PatchNetwork, - activeViewContexts: ReduxStore['viewContextManager']['activeViewContexts'] + activeViewContexts: ReduxStore['viewContextManager']['activeViewContexts'], + subgraphID: string ) => { - const { modifiedNodes, unchangedNodes, addedNodes } = [ - ...patchNetwork.connectables.entries(), - ].reduce( - (acc, [key, connectables]) => { - const pairNode = graph._nodes_by_id[key]; - - if (R.isNil(pairNode)) { - return { ...acc, addedNodes: acc.addedNodes.add(key) }; - } else if (connectables !== pairNode.connectables) { - return { ...acc, modifiedNodes: acc.modifiedNodes.add(key) }; + const allVcIDsInSubgraph = new Set(); + + const { modifiedNodes, unchangedNodes, addedNodes } = [...patchNetwork.connectables.entries()] + .filter( + ([vcId]) => + getState().viewContextManager.activeViewContexts.some( + vc => vc.uuid === vcId && vc.subgraphId === subgraphID + ) || + getState().viewContextManager.foreignConnectables.some( + fc => fc.id === vcId && fc.subgraphId === subgraphID + ) + ) + .reduce( + (acc, [key, connectables]) => { + allVcIDsInSubgraph.add(key); + const pairNode = graph._nodes_by_id[key]; + + if (R.isNil(pairNode)) { + return { ...acc, addedNodes: acc.addedNodes.add(key) }; + } else if (connectables !== pairNode.connectables) { + return { ...acc, modifiedNodes: acc.modifiedNodes.add(key) }; + } + + return { ...acc, unchangedNodes: acc.unchangedNodes.add(key) }; + }, + { + modifiedNodes: ImmSet(), + unchangedNodes: ImmSet(), + addedNodes: ImmSet(), } - - return { ...acc, unchangedNodes: acc.unchangedNodes.add(key) }; - }, - { modifiedNodes: Set(), unchangedNodes: Set(), addedNodes: Set() } - ); + ); // Any node present in the map that hasn't been accounted for already has been deleted - const deletedNodes: Set = Object.keys(graph._nodes_by_id).reduce( + const deletedNodes: ImmSet = Object.keys(graph._nodes_by_id).reduce( (acc, key) => ![modifiedNodes, unchangedNodes, addedNodes].find(set => set.has(key)) ? acc.add(key) : acc, - Set() as Set + ImmSet() ); // Now, we just have to handle all of these computed diffs to synchronize the LiteGraph graph with the patch network @@ -94,7 +110,7 @@ export const updateGraph = ( // If this is a brand new node, place it in the middle of the viewport if (!params) { // format: [ startx, starty, width, height ] - const visibleArea: Float32Array = graph.list_of_graphcanvas[0].visible_area; + const visibleArea = (graph as any as LGraph).list_of_graphcanvas[0].visible_area; if (visibleArea) { const centerX = visibleArea[0] + visibleArea[2] / 2; @@ -123,7 +139,7 @@ export const updateGraph = ( // Don't trigger patch network actions since these changes are purely presentational node.ignoreRemove = true; graph.remove(node); - createAndAddNode(key, { ignoreAdd: true, pos }); + createAndAddNode(key, { pos }); }); // At this point, all nodes should be created/removed and have up-to-date `AudioConnectables`. We must now run through the list @@ -132,11 +148,14 @@ export const updateGraph = ( // We start by looping through the list of connections and checking if they all exist. If they do not, we perform the connection now. // // Keep track of connections so that we can efficiently go back and check for missing connections later. - type ConnectionsMap = Map[]>; - const connectionsByNode: ConnectionsMap = patchNetwork.connections.reduce( + type ConnectionsMap = ImmMap[]>; + const subgraphLocalConnections = patchNetwork.connections.filter( + ([tx, rx]) => allVcIDsInSubgraph.has(tx.vcId) && allVcIDsInSubgraph.has(rx.vcId) + ); + const connectionsByNode: ConnectionsMap = subgraphLocalConnections.reduce( (acc, connection) => acc.set(connection[0].vcId, [...(acc.get(connection[0].vcId) || []), connection]), - Map() as ConnectionsMap + ImmMap() as ConnectionsMap ); const getNode = (id: string) => { @@ -186,7 +205,7 @@ export const updateGraph = ( } }); - patchNetwork.connections.forEach(connection => { + subgraphLocalConnections.forEach(connection => { // Check to see if we have an actual existing connection between the two nodes/ports and create one if we don't const srcNode = getNode(connection[0].vcId); @@ -227,5 +246,5 @@ export const updateGraph = ( if (!connectionExists) { srcNode.connect(srcSlotIx, dstNode, dstSlotIx); } - }, Map() as ConnectionsMap); + }, ImmMap() as ConnectionsMap); }; diff --git a/src/graphEditor/nodes/AudioConnectablesNode.ts b/src/graphEditor/nodes/AudioConnectablesNode.ts index 782c8093..074cea22 100644 --- a/src/graphEditor/nodes/AudioConnectablesNode.ts +++ b/src/graphEditor/nodes/AudioConnectablesNode.ts @@ -28,7 +28,7 @@ LGAudioConnectables.prototype.setConnectables = function ( this.connectables.vcId = this.id.toString(); if (connectables.node) { - this.title = connectables.node.name; + this.title = (connectables.node as any).name; } [...connectables.inputs.entries()].forEach(([name, input]) => { @@ -39,15 +39,15 @@ LGAudioConnectables.prototype.setConnectables = function ( if (!R.isNil(value)) { this.setProperty(name, value); } - this.addInput(name, input.type); + this.addInput(name, input.type === 'any' ? 0 : input.type); } else { - this.addInput(name, input.type); + this.addInput(name, input.type === 'any' ? 0 : input.type); } }); [...connectables.outputs.entries()].forEach(([name, output]) => { // TODO: Look up this type dynamically? - this.addOutput(name, output.type); + this.addOutput(name, output.type === 'any' ? 0 : output.type); }); }; diff --git a/src/graphEditor/nodes/CustomAudio/CustomAudio.ts b/src/graphEditor/nodes/CustomAudio/CustomAudio.ts index b48a2fef..745b5417 100644 --- a/src/graphEditor/nodes/CustomAudio/CustomAudio.ts +++ b/src/graphEditor/nodes/CustomAudio/CustomAudio.ts @@ -5,7 +5,7 @@ */ import { Option } from 'funfix-core'; import { Map } from 'immutable'; -import { LiteGraph } from 'litegraph.js'; +import { type LGraphNode, LiteGraph } from 'litegraph.js'; import * as R from 'ramda'; import type React from 'react'; @@ -39,7 +39,6 @@ import { VocoderNode } from 'src/graphEditor/nodes/CustomAudio/Vocoder/VocoderNo import WaveTable from 'src/graphEditor/nodes/CustomAudio/WaveTable/WaveTable'; import { OverridableAudioParam } from 'src/graphEditor/nodes/util'; import type { AudioConnectables, ConnectableInput, ConnectableOutput } from 'src/patchNetwork'; -import { addNode, removeNode } from 'src/patchNetwork/interface'; import { mkContainerCleanupHelper, mkContainerRenderHelper, mkLazyComponent } from 'src/reactUtils'; import { getState } from 'src/redux'; import type { SampleDescriptor } from 'src/sampleLibrary'; @@ -56,6 +55,10 @@ export interface ForeignNode { * The `ForeignNode` and its connectables by extension are the only things that are allowed to be stateful here. */ lgNode?: any; + /** + * Callback invoked when a LG node is generated for this nodee + */ + onAddedToLG?: (lgNode: LGraphNode) => void; /** * The underlying `AudioNode` that powers this custom node, if applicable. */ @@ -505,6 +508,15 @@ export const audioNodeGetters: { }, }; +/** + * FCs are expected to always have numeric IDs, and we count up from 1 + */ +export const buildNewForeignConnectableID = () => + [...getState().viewContextManager.patchNetwork.connectables.keys()] + .filter((id: string) => !Number.isNaN(+id)) + .map(id => +id) + .reduce((acc, id) => Math.max(acc, id), 0) + 1; + const registerCustomAudioNode = ( type: string, nodeGetter: (new ( @@ -516,11 +528,7 @@ const registerCustomAudioNode = ( ) => { function CustomAudioNode(this: any) { if (R.isNil(this.id)) { - this.id = - [...getState().viewContextManager.patchNetwork.connectables.keys()] - .filter((id: string) => !Number.isNaN(+id)) - .map(id => +id) - .reduce((acc, id) => Math.max(acc, id), 0) + 1; + this.id = buildNewForeignConnectableID(); } } @@ -551,10 +559,12 @@ const registerCustomAudioNode = ( // foreign node without holding a reference to this node, which is very helpful since we need to do that from the // patch network when changing state and we only have `AudioConnectables` there which only hold the foreign node. this.connectables.node.lgNode = this; + this.connectables.node.onAddedToLG?.(this); } else { const foreignNode = new nodeGetter(ctx, id, this.foreignNodeParams); // Set the same reference as above foreignNode.lgNode = this; + foreignNode.onAddedToLG?.(this); this.title = nodeGetter.typeName; const connectables = foreignNode.buildConnectables(); if (connectables.vcId !== id) { @@ -574,27 +584,16 @@ const registerCustomAudioNode = ( if (!R.isNil(value)) { this.setProperty(name, value); } - this.addInput(name, input.type); + (this as LGraphNode).addInput(name, input.type === 'any' ? (0 as any) : input.type); } else { - this.addInput(name, input.type); + (this as LGraphNode).addInput(name, input.type === 'any' ? (0 as any) : input.type); } }); [...connectables.outputs.entries()].forEach(([name, output]) => { - this.addOutput(name, output.type); + this.addOutput(name, output.type === 'any' ? (0 as any) : output.type); }); } - - if (!this.ignoreAdd) { - addNode(this.id.toString(), this.connectables); - } - }; - - CustomAudioNode.prototype.onRemoved = function (this: any) { - if (!this.ignoreRemove) { - removeNode(this.id.toString()); - this.onRemovedCustom?.(); - } }; // Whenever any of the properties of the LG node are changed, they trigger the value of the underlying diff --git a/src/graphEditor/nodes/CustomAudio/Subgraph/SubgraphPortalNode.ts b/src/graphEditor/nodes/CustomAudio/Subgraph/SubgraphPortalNode.ts index 4e1c829a..2526d060 100644 --- a/src/graphEditor/nodes/CustomAudio/Subgraph/SubgraphPortalNode.ts +++ b/src/graphEditor/nodes/CustomAudio/Subgraph/SubgraphPortalNode.ts @@ -1,19 +1,39 @@ import type { ForeignNode } from 'src/graphEditor/nodes/CustomAudio'; import type { OverridableAudioParam } from 'src/graphEditor/nodes/util'; -import type { AudioConnectables } from 'src/patchNetwork'; +import type { + AudioConnectables, + ConnectableDescriptor, + ConnectableInput, + ConnectableOutput, + ConnectableType, +} from 'src/patchNetwork'; import { Map as ImmMap } from 'immutable'; -import { getState } from 'src/redux'; import { getEngine } from 'src/util'; +import type { LGraphNode } from 'litegraph.js'; +import { getState } from 'src/redux'; +import DummyNode from 'src/graphEditor/nodes/DummyNode'; +import { PlaceholderOutput } from 'src/controlPanel/PlaceholderOutput'; +import { get, writable, type Writable } from 'svelte/store'; interface SubgraphPortalNodeState { txSubgraphID: string; rxSubgraphID: string; + registeredInputs: { [name: string]: { type: ConnectableType } }; + registeredOutputs: { [name: string]: { type: ConnectableType } }; } export class SubgraphPortalNode implements ForeignNode { - private vcId: string | undefined; + private vcId: string; private txSubgraphID!: string; private rxSubgraphID!: string; + private registeredInputs: Writable<{ + [name: string]: { type: ConnectableType; dummyNode: DummyNode }; + }> = writable({}); + private registeredOutputs: Writable<{ + [name: string]: { type: ConnectableType; dummyNode: DummyNode }; + }> = writable({}); + private dummyInput: DummyNode; + private dummyOutput: PlaceholderOutput; static typeName = 'Subgraph Portal'; static manuallyCreatable = false; @@ -24,12 +44,42 @@ export class SubgraphPortalNode implements ForeignNode { } = {}; constructor(ctx: AudioContext, vcId?: string, params?: Record | null) { + if (!vcId) { + throw new Error('`SubgraphPortalNode` must be created with a `vcId`'); + } this.vcId = vcId; this.deserialize(params); + + this.dummyOutput = new PlaceholderOutput( + ctx, + this.vcId, + () => this.buildConnectables(), + this.addOutput, + 'Add new output...' + ); + this.dummyInput = new DummyNode('Add new input...'); + } + + public onAddedToLG(lgNode: LGraphNode) { + const subgraph = getState().viewContextManager.subgraphsByID[this.rxSubgraphID]; + lgNode.title = subgraph.name; + lgNode.setSize([300, 100]); + lgNode.color = '#382636'; + lgNode.shape = 1; + lgNode.graph?.setDirtyCanvas(true, false); } public serialize(): SubgraphPortalNodeState { - return { txSubgraphID: this.txSubgraphID, rxSubgraphID: this.rxSubgraphID }; + return { + txSubgraphID: this.txSubgraphID, + rxSubgraphID: this.rxSubgraphID, + registeredInputs: Object.fromEntries( + Object.entries(get(this.registeredInputs)).map(([k, v]) => [k, { type: v.type }]) + ), + registeredOutputs: Object.fromEntries( + Object.entries(get(this.registeredOutputs)).map(([k, v]) => [k, { type: v.type }]) + ), + }; } private deserialize(params: Record | null | undefined) { @@ -46,18 +96,60 @@ export class SubgraphPortalNode implements ForeignNode { throw new Error('`SubgraphPortalNode` must be created with a `rxSubgraphID` param'); } this.rxSubgraphID = params.rxSubgraphID; + + if (params.registeredInputs) { + this.registeredInputs.set(params.registeredInputs); + } + if (params.registeredOutputs) { + this.registeredOutputs.set( + Object.fromEntries( + Object.entries( + params.registeredOutputs as SubgraphPortalNodeState['registeredOutputs'] + ).map(([k, v]) => [k, { type: v.type, dummyNode: new DummyNode(k) }]) + ) + ); + } } + private addOutput = ( + outputName: string, + type: ConnectableType, + rxConnectableDescriptor: ConnectableDescriptor + ) => { + this.registeredOutputs.update(outputs => ({ + ...outputs, + [outputName]: { + type, + dummyNode: new DummyNode(rxConnectableDescriptor.name), + }, + })); + }; + buildConnectables(): AudioConnectables & { node: ForeignNode } { + let outputs = ImmMap().set(this.dummyOutput.label, { + type: 'any', + node: this.dummyOutput, + }); + for (const [name, descriptor] of Object.entries(get(this.registeredOutputs))) { + outputs = outputs.set(name, { + type: 'any', + node: descriptor.dummyNode, + }); + } + return { - vcId: this.vcId!, - inputs: ImmMap(), - outputs: ImmMap(), + vcId: this.vcId, + inputs: ImmMap().set(this.dummyInput.name, { + type: 'any', + node: this.dummyOutput, + }), + outputs, node: this, }; } public onNodeDblClicked() { + console.log(this); getEngine()!.set_active_subgraph_id(this.rxSubgraphID); } } diff --git a/src/patchNetwork/interface.ts b/src/patchNetwork/interface.ts index 551c6c29..e43cdc99 100644 --- a/src/patchNetwork/interface.ts +++ b/src/patchNetwork/interface.ts @@ -11,9 +11,6 @@ export const connect = (from: ConnectableDescriptor, to: ConnectableDescriptor) export const disconnect = (from: ConnectableDescriptor, to: ConnectableDescriptor) => dispatch(actionCreators.viewContextManager.DISCONNECT(from, to)); -export const addNode = (vcId: string, connectables: AudioConnectables) => - dispatch(actionCreators.viewContextManager.ADD_PATCH_NETWORK_NODE(vcId, connectables)); - export const removeNode = (vcId: string) => dispatch(actionCreators.viewContextManager.REMOVE_PATCH_NETWORK_NODE(vcId)); diff --git a/src/patchNetwork/patchNetwork.ts b/src/patchNetwork/patchNetwork.ts index 4cc86df2..25e2070c 100644 --- a/src/patchNetwork/patchNetwork.ts +++ b/src/patchNetwork/patchNetwork.ts @@ -1,14 +1,14 @@ import { Option } from 'funfix-core'; import { Map } from 'immutable'; -import { PlaceholderInput } from 'src/controlPanel/PlaceholderInput'; +import { PlaceholderOutput } from 'src/controlPanel/PlaceholderOutput'; import { audioNodeGetters, type ForeignNode } from 'src/graphEditor/nodes/CustomAudio'; import { connectNodes, disconnectNodes, getConnectedPair } from 'src/redux/modules/vcmUtils'; import type { VCMState } from 'src/redux/modules/viewContextManager'; import { getEngine } from 'src/util'; import type { MIDINode } from './midiNode'; -export type ConnectableType = 'midi' | 'number' | 'customAudio'; +export type ConnectableType = 'midi' | 'number' | 'customAudio' | 'any'; export interface ConnectableInput { node: AudioParam | AudioNode | MIDINode; type: ConnectableType; @@ -41,6 +41,13 @@ export interface SubgraphDescriptor { activeVcId: string; } +export interface ForeignConnectable { + type: string; + id: string; + serializedState: any; + subgraphId: string; +} + export interface PatchNetwork { connectables: Map; connections: [ConnectableDescriptor, ConnectableDescriptor][]; @@ -53,11 +60,7 @@ export interface PatchNetwork { export const initPatchNetwork = ( oldPatchNetwork: PatchNetwork, viewContexts: VCMState['activeViewContexts'], - foreignConnectables: { - type: string; - id: string; - serializedState?: { [key: string]: any } | null; - }[], + foreignConnectables: ForeignConnectable[], connections: VCMState['patchNetwork']['connections'], ctx: AudioContext ): PatchNetwork => { @@ -130,7 +133,11 @@ export const initPatchNetwork = ( return false; } - if (connectedPair[0].type !== connectedPair[1].type) { + if ( + connectedPair[0].type !== connectedPair[1].type && + connectedPair[0].type !== 'any' && + connectedPair[1].type !== 'any' + ) { console.error( 'Invalid connection found when initializing patch network; mis-matched types: ', { ...connectedPair[0], name: from.name, vcId: from.vcId }, @@ -143,7 +150,7 @@ export const initPatchNetwork = ( try { (connectedPair[0].node as any).connect( connectedPair[1].node, - connectedPair[0].node instanceof PlaceholderInput ? to : undefined + connectedPair[0].node instanceof PlaceholderOutput ? to : undefined ); connectNodes(connectedPair[0].node, connectedPair[1].node, to); } catch (err) { diff --git a/src/redux/modules/vcmUtils.ts b/src/redux/modules/vcmUtils.ts index 313a3a31..84df4d25 100644 --- a/src/redux/modules/vcmUtils.ts +++ b/src/redux/modules/vcmUtils.ts @@ -4,7 +4,7 @@ import * as R from 'ramda'; import { shallowEqual } from 'react-redux'; import type { Unsubscribe } from 'redux'; -import { PlaceholderInput } from 'src/controlPanel/PlaceholderInput'; +import { PlaceholderOutput } from 'src/controlPanel/PlaceholderOutput'; import { OverridableAudioNode, OverridableAudioParam } from 'src/graphEditor/nodes/util'; import DefaultComposition from 'src/init-composition.json'; import type { @@ -37,10 +37,19 @@ export const commitForeignConnectables = ( throw new Error(`Foreign connectable with non-numeric \`vcId\` found: "${vcId}"`); } + const subgraphId = + getState().viewContextManager.activeViewContexts.find(vc => vc.uuid === vcId) + ?.subgraphId ?? + getState().viewContextManager.foreignConnectables.find(fc => fc.id === vcId)?.subgraphId; + if (!subgraphId) { + throw new Error(`vcId=${vcId} was not found in any view context or foreign connectable`); + } + return { id: vcId.toString(), type: node.nodeType, serializedState: node.serialize ? node.serialize() : null, + subgraphId, }; }) ) @@ -59,7 +68,7 @@ export const connectNodes = ( dst.setIsOverridden(false); } - (src as any).connect(dst, src instanceof PlaceholderInput ? dstDescriptor : undefined); + (src as any).connect(dst, src instanceof PlaceholderOutput ? dstDescriptor : undefined); }; export const disconnectNodes = ( @@ -73,7 +82,7 @@ export const disconnectNodes = ( } try { - (src as any).disconnect(dst, src instanceof PlaceholderInput ? dstDescriptor : undefined); + (src as any).disconnect(dst, src instanceof PlaceholderOutput ? dstDescriptor : undefined); } catch (err) { if ( err instanceof DOMException && diff --git a/src/redux/modules/viewContextManager.ts b/src/redux/modules/viewContextManager.ts index ecdb03e1..3cfb12f8 100644 --- a/src/redux/modules/viewContextManager.ts +++ b/src/redux/modules/viewContextManager.ts @@ -6,6 +6,7 @@ import * as R from 'ramda'; import type { AudioConnectables, ConnectableDescriptor, + ForeignConnectable, PatchNetwork, SubgraphDescriptor, } from 'src/patchNetwork/patchNetwork'; @@ -19,6 +20,7 @@ import { getEngine } from 'src/util'; export interface VCMState { activeViewContexts: { name: string; uuid: string; title?: string; subgraphId: string }[]; + foreignConnectables: ForeignConnectable[]; activeViewContextId: string; activeSubgraphID: string; patchNetwork: PatchNetwork; @@ -36,14 +38,10 @@ const actionGroups = { }), SET_VCM_STATE: buildActionGroup({ actionCreator: ( - newState: Pick & { - foreignConnectables: { - type: string; - id: string; - subgraphId: string; - params?: { [key: string]: any } | null; - }[]; - }, + newState: Pick< + VCMState, + 'activeViewContextId' | 'activeViewContexts' | 'subgraphsByID' | 'foreignConnectables' + >, getPatchNetworkReturnVal: PatchNetwork, activeSubgraphID: string ) => ({ @@ -99,7 +97,11 @@ const actionGroups = { } const [fromConnectable, toConnectable] = connectedPair; - if (fromConnectable.type !== toConnectable.type) { + if ( + fromConnectable.type !== toConnectable.type && + fromConnectable.type !== 'any' && + toConnectable.type !== 'any' + ) { console.warn( 'Tried to connect two connectables of different types: ', fromConnectable, @@ -192,12 +194,13 @@ const actionGroups = { }, }), ADD_PATCH_NETWORK_NODE: buildActionGroup({ - actionCreator: (vcId: string, connectables: AudioConnectables | null) => ({ + actionCreator: (vcId: string, connectables: AudioConnectables | null, subgraphId: string) => ({ type: 'ADD_PATCH_NETWORK_NODE', vcId, connectables, + subgraphId, }), - subReducer: (state: VCMState, { vcId, connectables }) => { + subReducer: (state: VCMState, { vcId, connectables, subgraphId }) => { if (!connectables || state.patchNetwork.connectables.has(vcId)) { return state; } @@ -214,7 +217,21 @@ const actionGroups = { }; maybeUpdateVCM(engine, state.patchNetwork, newPatchNetwork); - return { ...state, patchNetwork: newPatchNetwork }; + return { + ...state, + patchNetwork: newPatchNetwork, + foreignConnectables: connectables.node + ? [ + ...state.foreignConnectables, + { + type: connectables.node.nodeType, + id: vcId, + subgraphId, + serializedState: connectables.node.serialize ? connectables.node.serialize() : null, + }, + ] + : state.foreignConnectables, + }; }, }), REMOVE_PATCH_NETWORK_NODE: buildActionGroup({ @@ -256,7 +273,11 @@ const actionGroups = { }; maybeUpdateVCM(engine, state.patchNetwork, newPatchNetwork); - return { ...state, patchNetwork: newPatchNetwork }; + return { + ...state, + patchNetwork: newPatchNetwork, + foreignConnectables: state.foreignConnectables.filter(fc => fc.id !== vcId), + }; }, }), UPDATE_CONNECTABLES: buildActionGroup({ @@ -385,6 +406,23 @@ const actionGroups = { activeViewContexts: [...state.activeViewContexts, { uuid, name, subgraphId: subgraphID }], }), }), + ADD_FOREIGN_CONNECTABLE: buildActionGroup({ + actionCreator: (id: string, fc: ForeignConnectable) => ({ + type: 'ADD_FOREIGN_CONNECTABLE', + id, + fc, + }), + subReducer: (state: VCMState, { id, fc }) => { + if (state.foreignConnectables.some(f => f.id === id)) { + throw new Error(`Tried to add foreign connectable with ID ${id} but one already exists`); + } + + return { + ...state, + foreignConnectables: [...state.foreignConnectables, { ...fc, id }], + }; + }, + }), DELETE_VIEW_CONTEXT: buildActionGroup({ actionCreator: (uuid: string) => ({ type: 'DELETE_VIEW_CONTEXT', uuid }), subReducer: (state: VCMState, { uuid }) => ({ @@ -427,6 +465,7 @@ const actionGroups = { const initialState: VCMState = { activeViewContexts: [], + foreignConnectables: [], activeViewContextId: '', activeSubgraphID: '', patchNetwork: { diff --git a/src/vcInterop.ts b/src/vcInterop.ts index 5f5cec44..f65ed464 100644 --- a/src/vcInterop.ts +++ b/src/vcInterop.ts @@ -1,11 +1,16 @@ import { initPatchNetwork } from 'src/patchNetwork'; -import type { ConnectableDescriptor, SubgraphDescriptor } from 'src/patchNetwork'; +import type { + ConnectableDescriptor, + ForeignConnectable, + SubgraphDescriptor, +} from 'src/patchNetwork'; import { initializeDefaultVCMState } from 'src/redux/modules/vcmUtils'; import type { VCMState } from 'src/redux/modules/viewContextManager'; import type { SampleDescriptor } from 'src/sampleLibrary'; import { getEngine, tryParseJson } from 'src/util'; import { onVcHideStatusChange } from 'src/ViewContextManager/VcHideStatusRegistry'; import { actionCreators, dispatch, getState } from './redux'; +import { audioNodeGetters, buildNewForeignConnectableID } from 'src/graphEditor/nodes/CustomAudio'; const ctx = new AudioContext(); @@ -29,9 +34,7 @@ export const init_view_contexts = ( 'Failed to parse provided connections out of JSON' ); - const foreignConnectables = tryParseJson< - { type: string; id: string; subgraphId: string; serializedState: string }[] - >( + const foreignConnectables = tryParseJson( foreignConnectablesJson, [], 'Failed to parse foreign nodes JSON; using an empty list but that will probably create invalid connections.' @@ -47,15 +50,8 @@ export const init_view_contexts = ( const newVCMState: Pick< VCMState, - 'activeViewContextId' | 'activeViewContexts' | 'subgraphsByID' - > & { - foreignConnectables: { - type: string; - id: string; - subgraphId: string; - params?: { [key: string]: any } | null; - }[]; - } = { + 'activeViewContextId' | 'activeViewContexts' | 'subgraphsByID' | 'foreignConnectables' + > = { activeViewContextId, activeViewContexts: activeViewContexts.map(({ minimal_def, ...rest }) => ({ ...minimal_def, @@ -69,7 +65,7 @@ export const init_view_contexts = ( const patchNetwork = initPatchNetwork( getState().viewContextManager.patchNetwork, newVCMState.activeViewContexts, - newVCMState.foreignConnectables, + foreignConnectables, connections, ctx ); @@ -82,7 +78,22 @@ export const add_view_context = (id: string, name: string, subgraphID: string) = const engine = getEngine()!; // Must exist because this gets called *from the engine*. dispatch(actionCreators.viewContextManager.ADD_VIEW_CONTEXT(id, name, subgraphID)); dispatch( - actionCreators.viewContextManager.ADD_PATCH_NETWORK_NODE(id, engine.get_vc_connectables(id)) + actionCreators.viewContextManager.ADD_PATCH_NETWORK_NODE( + id, + engine.get_vc_connectables(id), + subgraphID + ) + ); +}; + +export const add_foreign_connectable = (fcJSON: string) => { + const fc: ForeignConnectable = JSON.parse(fcJSON); + console.log({ fc }); + const id = buildNewForeignConnectableID().toString(); + const node = new audioNodeGetters[fc.type]!.nodeGetter(ctx, id, fc.serializedState); + const connectables = node.buildConnectables(); + dispatch( + actionCreators.viewContextManager.ADD_PATCH_NETWORK_NODE(id, connectables, fc.subgraphId) ); }; @@ -111,7 +122,6 @@ export const set_subgraphs = (activeSubgraphID: string, subgraphsByIdJSON: strin {}, 'Failed to parse subgraphs JSON; using an empty list but that will probably create invalid connections.' ); - console.log('Setting subgraphs', activeSubgraphID, subgraphsByID); dispatch(actionCreators.viewContextManager.SET_SUBGRAPHS(activeSubgraphID, subgraphsByID)); };