Ver Fonte

Merge pull request #63 from j-p-higgins/refactor_code

Refactor code
Jonathan Higgins há 6 meses atrás
pai
commit
45767189eb

+ 1 - 1
scenes/main/audio_settings.tscn

@@ -1,6 +1,6 @@
 [gd_scene load_steps=2 format=3 uid="uid://dta7rfalv4uvd"]
 [gd_scene load_steps=2 format=3 uid="uid://dta7rfalv4uvd"]
 
 
-[ext_resource type="Script" uid="uid://c7krcoq5poxdn" path="res://scenes/main/audiosettings.gd" id="2_7qbns"]
+[ext_resource type="Script" uid="uid://c7krcoq5poxdn" path="res://scenes/main/scripts/audiosettings.gd" id="2_7qbns"]
 
 
 [node name="AudioSettings" type="Window"]
 [node name="AudioSettings" type="Window"]
 title = "Audio Settings"
 title = "Audio Settings"

+ 0 - 1888
scenes/main/control.gd

@@ -1,1888 +0,0 @@
-extends Control
-
-var mainmenu_visible : bool = false #used to test if mainmenu is open
-var effect_position = Vector2(40,40) #tracks mouse position for node placement offset
-@onready var graph_edit = $GraphEdit
-var selected_nodes = {} #used to track which nodes in the GraphEdit are selected
-var cdpprogs_location #stores the cdp programs location from user prefs for easy access
-var delete_intermediate_outputs # tracks state of delete intermediate outputs toggle
-@onready var console_output: RichTextLabel = $Console/ConsoleOutput
-var final_output_dir
-var copied_nodes_data = [] #stores node data on ctrl+c
-var copied_connections = [] #stores all connections on ctrl+c
-var undo_redo := UndoRedo.new() 
-var output_audio_player #tracks the node that is the current output player for linking
-var input_audio_player #tracks node that is the current input player for linking
-var outfile = "no file" #tracks dir of output file from cdp process
-var currentfile = "none" #tracks dir of currently loaded file for saving
-var changesmade = false #tracks if user has made changes to the currently loaded save file
-var savestate # tracks what the user is trying to do when savechangespopup is called
-var helpfile #tracks which help file the user was trying to load when savechangespopup is called
-var outfilename #links to the user name for outputfile field
-var foldertoggle #links to the reuse folder button
-var lastoutputfolder = "none" #tracks last output folder, this can in future be used to replace global.outfile but i cba right now
-var process_successful #tracks if the last run process was successful
-var help_data := {} #stores help data for each node to display in help popup
-var HelpWindowScene = preload("res://scenes/main/help_window.tscn")
-var uiscale = 1.0 #tracks scaling for retina screens
-var process_info = {} #tracks the data of the currently running process
-var process_running := false #tracks if a process is currently running
-var process_cancelled = false #checks if the currently running process has been cancelled
-
-# Called when the node enters the scene tree for the first time.
-func _ready() -> void:
-	Nodes.hide()
-	$mainmenu.hide()
-	#$"mainmenu/select_effect/Time Domain".show()
-	#$"mainmenu/select_effect/Time Domain/Distort".show()
-	#$"mainmenu/select_effect/Frequency Domain/Convert".show()
-	$NoLocationPopup.hide()
-	$Console.hide()
-	$NoInputPopup.hide()
-	$MultipleConnectionsPopup.hide()
-	$AudioSettings.hide()
-	$AudioDevicePopup.hide()
-	$SearchMenu.hide()
-	$Settings.hide()
-	$ProgressWindow.hide()
-	
-	$SaveDialog.access = FileDialog.ACCESS_FILESYSTEM
-	$SaveDialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
-	$SaveDialog.filters = ["*.thd"]
-	
-	$LoadDialog.access = FileDialog.ACCESS_FILESYSTEM
-	$LoadDialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
-	$LoadDialog.filters = ["*.thd"]
-	
-	
-	#Goes through all nodes in scene and checks for buttons in the make_node_buttons group
-	#Associates all buttons with the _on_button_pressed fuction and passes the button as an argument
-	for child in get_tree().get_nodes_in_group("make_node_buttons"):
-		if child is Button:
-			child.pressed.connect(_on_button_pressed.bind(child))
-	
-	get_node("SearchMenu").make_node.connect(_make_node_from_search_menu)
-	get_node("mainmenu").make_node.connect(_make_node_from_search_menu)
-	get_node("mainmenu").open_help.connect(show_help_for_node)
-	get_node("Settings").open_cdp_location.connect(show_cdp_location)
-	get_node("Settings").console_on_top.connect(change_console_settings)
-	
-	check_user_preferences()
-	get_tree().set_auto_accept_quit(false) #disable closing the app with the x and instead handle it internally
-	
-	#Check export config for version number and set about menu to current version
-	#Assumes version of mac + linux builds is the same as windows
-	#Requires manual update for alpha and beta builds but once the -beta is removed will be fully automatic so long as version is updated on export
-	var export_config = ConfigFile.new()
-	export_config.load("res://export_presets.cfg")
-	$MenuBar/About.set_item_text(0, "SoundThread v" + export_config.get_value("preset.0.options", "application/product_version", "version unknown") + "-beta") 
-	
-	#checks if display is hidpi and scales ui accordingly hidpi - 144
-	if DisplayServer.screen_get_dpi(0) >= 144:
-		uiscale = 2.0
-		get_window().content_scale_factor = uiscale
-		#goes through popup_windows group and scales all popups and resizes them
-		for window in get_tree().get_nodes_in_group("popup_windows"):
-			window.size = window.size * uiscale
-			window.content_scale_factor = uiscale
-
-	#checks if user has opened a file from the system file menu and loads it
-	var args = OS.get_cmdline_args()
-	for arg in args:
-		var path = arg.strip_edges()
-		if FileAccess.file_exists(path) and path.get_extension().to_lower() == "thd":
-			load_graph_edit(path)
-			break
-	
-	var file = FileAccess.open("res://scenes/main/process_help.json", FileAccess.READ)
-	if file:
-		help_data = JSON.parse_string(file.get_as_text())
-	
-	new_patch()
-	check_cdp_location_set()
-	
-func new_patch():
-	#clear old patch
-	graph_edit.clear_connections()
-
-	for node in graph_edit.get_children():
-		if node is GraphNode:
-			node.queue_free()
-	
-	await get_tree().process_frame  # Wait for nodes to actually be removed
-	
-	graph_edit.scroll_offset = Vector2(0, 0)
-	
-		#Generate input and output nodes
-	var effect: GraphNode = Nodes.get_node(NodePath("inputfile")).duplicate()
-	effect.name = "inputfile"
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(self, "show_help_for_node"))
-	effect.position_offset = Vector2(20,80)
-	
-	effect = Nodes.get_node(NodePath("outputfile")).duplicate()
-	effect.name = "outputfile"
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(self, "show_help_for_node"))
-	effect.position_offset = Vector2((DisplayServer.screen_get_size().x - 480) / uiscale, 80)
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
-	
-	changesmade = false #so it stops trying to save unchanged empty files
-	Global.infile = "no_file" #resets input to stop processes running with old files
-	get_window().title = "SoundThread"
-	link_output()
-	
-	
-
-func link_output():
-	#links various buttons and function in the input nodes - this is called after they are created so that it still works on new and loading files
-	for control in get_tree().get_nodes_in_group("outputnode"): #check all items in outputnode group
-		if control.get_meta("outputfunction") == "deleteintermediate": #link delete intermediate files toggle to script
-			control.toggled.connect(_toggle_delete)
-			control.button_pressed = true
-		elif control.get_meta("outputfunction") == "runprocess": #link runprocess button
-			control.button_down.connect(_run_process)
-		elif control.get_meta("outputfunction") == "recycle": #link recycle button
-			control.button_down.connect(_recycle_outfile)
-		elif control.get_meta("outputfunction") == "audioplayer": #link output audio player
-			output_audio_player = control
-		elif control.get_meta("outputfunction") == "filename":
-			control.text = "outfile"
-			outfilename = control
-		elif control.get_meta("outputfunction") == "reusefolder":
-			foldertoggle = control
-			foldertoggle.button_pressed = true
-		elif control.get_meta("outputfunction") == "openfolder":
-			control.button_down.connect(_open_output_folder)
-
-	for control in get_tree().get_nodes_in_group("inputnode"):
-		if control.get_meta("inputfunction") == "audioplayer": #link input for recycle function
-			print("input player found")
-			input_audio_player = control
-
-func check_user_preferences():
-	var interface_settings = ConfigHandler.load_interface_settings()
-	var audio_settings = ConfigHandler.load_audio_settings()
-	var audio_devices = AudioServer.get_output_device_list()
-	$Console.always_on_top = interface_settings.console_on_top
-	if audio_devices.has(audio_settings.device):
-		AudioServer.set_output_device(audio_settings.device)
-	else:
-		$AudioDevicePopup.popup_centered()
-	
-	match interface_settings.theme:
-		0:
-			RenderingServer.set_default_clear_color(Color("#2f4f4e"))
-		1:
-			RenderingServer.set_default_clear_color(Color("#000807"))
-		2:
-			RenderingServer.set_default_clear_color(Color("#98d4d2"))
-		3:
-			RenderingServer.set_default_clear_color(Color(interface_settings.theme_custom_colour))
-func show_cdp_location():
-	$CdpLocationDialog.show()
-	
-func check_cdp_location_set():
-	#checks if the location has been set and prompts user to set it
-	var cdpprogs_settings = ConfigHandler.load_cdpprogs_settings()
-	if cdpprogs_settings.location == "no_location":
-		$NoLocationPopup.popup_centered()
-	else:
-		#if location is set, stores it in a variable
-		cdpprogs_location = str(cdpprogs_settings.location)
-		print(cdpprogs_location)
-
-func _on_ok_button_button_down() -> void:
-	#after user has read dialog on where to find cdp progs this loads the file browser
-	$NoLocationPopup.hide()
-	if OS.get_name() == "Windows":
-		$CdpLocationDialog.current_dir = "C:/"
-	else:
-		$CdpLocationDialog.current_dir = OS.get_environment("HOME")
-	$CdpLocationDialog.show()
-
-func _on_cdp_location_dialog_dir_selected(dir: String) -> void:
-	#saves default location for cdp programs in config file
-	ConfigHandler.save_cdpprogs_settings(dir)
-	cdpprogs_location = dir
-
-func _on_cdp_location_dialog_canceled() -> void:
-	#cycles around the set location prompt if user cancels the file dialog
-	check_cdp_location_set()
-	
-# Called every frame. 'delta' is the elapsed time since the previous frame.
-func _process(delta: float) -> void:
-	#showmenu()
-	pass
-	
-
-func _input(event):
-	if event.is_action_pressed("copy_node"):
-		copy_selected_nodes()
-		get_viewport().set_input_as_handled()
-
-	elif event.is_action_pressed("paste_node"):
-		simulate_mouse_click() #hacky fix to stop tooltips getting stuck
-		await get_tree().process_frame
-		paste_copied_nodes()
-		get_viewport().set_input_as_handled()
-	elif event.is_action_pressed("undo"):
-		undo_redo.undo()
-	elif event.is_action_pressed("redo"):
-		undo_redo.redo()
-	elif event.is_action_pressed("save"):
-		if currentfile == "none":
-			savestate = "saveas"
-			$SaveDialog.popup_centered()
-		else:
-			save_graph_edit(currentfile)
-	elif event.is_action_pressed("open_explore"):
-		open_explore()
-	
-
-func show_help_for_node(node_name: String, node_title: String):
-	#check if there is already a help window open for this node and pop it up instead of making a new one
-	for child in get_tree().current_scene.get_children():
-		if child is Window and child.title == "Help - " + node_title:
-			# Found existing window, bring it to front
-			if child.is_visible():
-				child.hide()
-				child.popup()
-			else:
-				child.popup()
-			return
-	
-	if help_data.has(node_name):
-		#looks up the help data from the json and stores it in info
-		var info = help_data[node_name]
-		#makes an instance of the help_window scene
-		var help_window = HelpWindowScene.instantiate()
-		help_window.title = "Help - " + node_title
-		help_window.get_node("HelpTitle").text = node_title
-		
-		var output = ""
-		output += info.get("short_description", "") + "\n\n"
-		
-		var parameters = info.get("parameters", {})
-		#checks if there are parameters and if there are places them in a table
-		if parameters.size() > 0:
-			output += "[table=3]\n"
-			output += "[cell][b]Parameter Name[/b][/cell][cell][b]Description[/b][/cell][cell][b]Automatable[/b][/cell]\n"
-			for key in parameters.keys(): #scans through all parameters
-				var param = parameters[key]
-				var name = param.get("paramname", "")
-				var desc = param.get("paramdescription", "")
-				var automatable = param.get("automatable", false)
-				var autom_text = "[center]✓[/center]" if automatable else "[center]𐄂[/center]" #replaces true and false with ticks and crosses
-				output += "[cell]%s[/cell][cell]%s[/cell][cell]%s[/cell]\n" % [name, desc, autom_text] #places each param detail into cells of the table
-			output += "[/table]\n\n" #ends the table
-		
-		output += "[b]Functionality[/b]\n"
-		var description_text = info.get("description", "")
-		output += description_text.strip_edges()
-		#check if this is a cdp process or a utility and display the cdp process if it is one
-		var category = info.get("category", "")
-		if category != "utility":
-			output += "\n\n[b]CDP Process[/b]\nThis node runs the CDP Process: " + node_name.replace("_", " ")
-		
-		help_window.get_node("HelpText").bbcode_text = output
-		help_window.get_node("HelpText").scroll_to_line(0) #scrolls to the first line of the help file just incase
-		
-		# Add to the current scene tree to show it
-		get_tree().current_scene.add_child(help_window)
-		if help_window.content_scale_factor < uiscale:
-			help_window.size = help_window.size * uiscale
-			help_window.content_scale_factor = uiscale
-		
-		help_window.popup() 
-		
-	else:
-		# If no help available, even though there always should be, show a window saying no help found
-		var help_window = HelpWindowScene.instance()
-		help_window.title = "Help - " + node_title
-		help_window.get_node("HelpTitle").text = node_title
-		help_window.get_node("HelpText").bbcode_text = "No help found."
-		get_tree().current_scene.add_child(help_window)
-		help_window.popup()
-
-
-func simulate_mouse_click():
-	#simulates clicking the middle mouse button in order to hide any visible tooltips
-	var click_pos = get_viewport().get_mouse_position()
-
-	var down_event := InputEventMouseButton.new()
-	down_event.button_index = MOUSE_BUTTON_MIDDLE
-	down_event.pressed = true
-	down_event.position = click_pos
-	Input.parse_input_event(down_event)
-
-	var up_event := InputEventMouseButton.new()
-	up_event.button_index = MOUSE_BUTTON_MIDDLE
-	up_event.pressed = false
-	up_event.position = click_pos
-	Input.parse_input_event(up_event)
-
-func _make_node_from_search_menu(command: String):
-	#close menu
-	$SearchMenu.hide()
-	
-	#Find node with matching name to button and create a version of it in the graph edit
-	#and position it close to the origin right click to open the menu
-	var effect: GraphNode = Nodes.get_node(NodePath(command)).duplicate()
-	effect.name = command
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(self, "show_help_for_node"))
-	effect.set_position_offset((effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
-	_register_inputs_in_node(effect) #link sliders for changes tracking
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
-
-	changesmade = true
-
-	# Remove node with UndoRedo
-	undo_redo.create_action("Add Node")
-	undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
-	undo_redo.add_undo_method(Callable(effect, "queue_free"))
-	undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-	
-
-func _on_button_pressed(button: Button):
-	#close menu
-	$mainmenu.hide()
-	mainmenu_visible = false
-	
-	#Find node with matching name to button and create a version of it in the graph edit
-	#and position it close to the origin right click to open the menu
-	var effect: GraphNode = Nodes.get_node(NodePath(button.name)).duplicate()
-	effect.name = button.name
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(self, "show_help_for_node"))
-	effect.set_position_offset((effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
-	_register_inputs_in_node(effect) #link sliders for changes tracking
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
-
-	changesmade = true
-
-
-	# Remove node with UndoRedo
-	undo_redo.create_action("Add Node")
-	undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
-	undo_redo.add_undo_method(Callable(effect, "queue_free"))
-	undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-
-func _on_graph_edit_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
-	#get_node("GraphEdit").connect_node(from_node, from_port, to_node, to_port)
-	var graph_edit = get_node("GraphEdit")
-	var to_graph_node = graph_edit.get_node(NodePath(to_node))
-
-	# Get the type of the input port using GraphNode's built-in method
-	var port_type = to_graph_node.get_input_port_type(to_port)
-
-	# If port type is 1 and already has a connection, reject the request
-	if port_type == 1:
-		var connections = graph_edit.get_connection_list()
-		var existing_connections = 0
-
-		for conn in connections:
-			if conn.to_node == to_node and conn.to_port == to_port:
-				existing_connections += 1
-				if existing_connections >= 1:
-					var interface_settings = ConfigHandler.load_interface_settings()
-					if interface_settings.disable_pvoc_warning == false:
-						$MultipleConnectionsPopup.popup_centered()
-					return
-
-	# If no conflict, allow the connection
-	graph_edit.connect_node(from_node, from_port, to_node, to_port)
-	changesmade = true
-
-func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
-	get_node("GraphEdit").disconnect_node(from_node, from_port, to_node, to_port)
-	changesmade = true
-
-func _on_graph_edit_node_selected(node: Node) -> void:
-	selected_nodes[node] = true
-
-func _on_graph_edit_node_deselected(node: Node) -> void:
-	selected_nodes[node] = false
-
-func _unhandled_key_input(event: InputEvent) -> void:
-	if event is InputEventKey and event.pressed and not event.echo:
-		if event.keycode == KEY_BACKSPACE:
-			_on_graph_edit_delete_nodes_request(PackedStringArray(selected_nodes.keys().filter(func(k): return selected_nodes[k])))
-			pass
-
-func _on_graph_edit_delete_nodes_request(nodes: Array[StringName]) -> void:
-	var graph_edit = get_node("GraphEdit")
-	undo_redo.create_action("Delete Nodes (Undo only)")
-
-	for node in selected_nodes.keys():
-		if selected_nodes[node]:
-			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
-				print("can't delete input or output")
-			else:
-				# Store duplicate and state for undo
-				var node_data = node.duplicate()
-				var position = node.position_offset
-
-				# Store all connections for undo
-				var conns = []
-				for con in graph_edit.get_connection_list():
-					if con["to_node"] == node.name or con["from_node"] == node.name:
-						conns.append(con)
-
-				# Delete
-				remove_connections_to_node(node)
-				node.queue_free()
-				changesmade = true
-
-				# Register undo restore
-				undo_redo.add_undo_method(Callable(graph_edit, "add_child").bind(node_data, true))
-				undo_redo.add_undo_method(Callable(node_data, "set_position_offset").bind(position))
-				for con in conns:
-					undo_redo.add_undo_method(Callable(graph_edit, "connect_node").bind(
-						con["from_node"], con["from_port"],
-						con["to_node"], con["to_port"]
-					))
-				undo_redo.add_undo_method(Callable(self, "set_node_selected").bind(node_data, true))
-				undo_redo.add_undo_method(Callable(self, "_track_changes"))
-				undo_redo.add_undo_method(Callable(self, "_register_inputs_in_node").bind(node_data)) #link sliders for changes tracking
-				undo_redo.add_undo_method(Callable(self, "_register_node_movement")) # link nodes for changes tracking
-
-	# Clear selection
-	selected_nodes = {}
-
-	undo_redo.commit_action()
-
-func set_node_selected(node: Node, selected: bool) -> void:
-	selected_nodes[node] = selected
-#
-func remove_connections_to_node(node):
-	for con in get_node("GraphEdit").get_connection_list():
-		if con["to_node"] == node.name or con["from_node"] == node.name:
-			get_node("GraphEdit").disconnect_node(con["from_node"], con["from_port"], con["to_node"], con["to_port"])
-			changesmade = true
-			
-#copy and paste nodes with vertical offset on paste
-func copy_selected_nodes():
-	copied_nodes_data.clear()
-	copied_connections.clear()
-
-	var graph_edit = get_node("GraphEdit")
-
-	# Store selected nodes and their slider values
-	for node in graph_edit.get_children():
-		# Check if the node is selected and not an 'inputfile' or 'outputfile'
-		if node is GraphNode and selected_nodes.get(node, false):
-			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
-				continue  # Skip these nodes
-
-			var node_data = {
-				"name": node.name,
-				"type": node.get_class(),
-				"offset": node.position_offset,
-				"slider_values": {}
-			}
-
-			for child in node.get_children():
-				if child is HSlider or child is VSlider:
-					node_data["slider_values"][child.name] = child.value
-
-			copied_nodes_data.append(node_data)
-
-	# Store connections between selected nodes
-	for conn in graph_edit.get_connection_list():
-		var from_ref = graph_edit.get_node_or_null(NodePath(conn["from_node"]))
-		var to_ref = graph_edit.get_node_or_null(NodePath(conn["to_node"]))
-
-		var is_from_selected = from_ref != null and selected_nodes.get(from_ref, false)
-		var is_to_selected = to_ref != null and selected_nodes.get(to_ref, false)
-
-		# Skip if any of the connected nodes are 'inputfile' or 'outputfile'
-		if (from_ref != null and (from_ref.get_meta("command") == "inputfile" or from_ref.get_meta("command") == "outputfile")) or (to_ref != null and (to_ref.get_meta("command") == "inputfile" or to_ref.get_meta("command") == "outputfile")):
-			continue
-
-		if is_from_selected and is_to_selected:
-			# Store connection as dictionary
-			var conn_data = {
-				"from_node": conn["from_node"],
-				"from_port": conn["from_port"],
-				"to_node": conn["to_node"],
-				"to_port": conn["to_port"]
-			}
-			copied_connections.append(conn_data)
-
-func paste_copied_nodes():
-	if copied_nodes_data.is_empty():
-		return
-
-	var graph_edit = get_node("GraphEdit")
-	var name_map = {}
-	var pasted_nodes = []
-
-	# Step 1: Find topmost and bottommost Y of copied nodes
-	var min_y = INF
-	var max_y = -INF
-	for node_data in copied_nodes_data:
-		var y = node_data["offset"].y
-		min_y = min(min_y, y)
-		max_y = max(max_y, y)
-
-	# Step 2: Decide where to paste the group
-	var base_y_offset = max_y + 350  # Pasting below the lowest node
-
-	# Step 3: Paste nodes, preserving vertical layout
-	for node_data in copied_nodes_data:
-		var original_node = graph_edit.get_node_or_null(NodePath(node_data["name"]))
-		if not original_node:
-			continue
-
-		var new_node = original_node.duplicate()
-		new_node.name = node_data["name"] + "_copy_" + str(randi() % 10000)
-
-		var relative_y = node_data["offset"].y - min_y
-		new_node.position_offset = Vector2(
-			node_data["offset"].x,
-			base_y_offset + relative_y
-		)
-		
-
-		# Restore sliders
-		for child in new_node.get_children():
-			if child.name in node_data["slider_values"]:
-				child.value = node_data["slider_values"][child.name]
-
-		graph_edit.add_child(new_node, true)
-		new_node.connect("open_help", Callable(self, "show_help_for_node"))
-		_register_inputs_in_node(new_node) #link sliders for changes tracking
-		_register_node_movement() # link nodes for changes tracking
-		name_map[node_data["name"]] = new_node.name
-		pasted_nodes.append(new_node)
-
-
-	# Step 4: Reconnect new nodes
-	for conn_data in copied_connections:
-		var new_from = name_map.get(conn_data["from_node"], null)
-		var new_to = name_map.get(conn_data["to_node"], null)
-
-		if new_from and new_to:
-			graph_edit.connect_node(new_from, conn_data["from_port"], new_to, conn_data["to_port"])
-
-	# Step 5: Select pasted nodes
-	for pasted_node in pasted_nodes:
-		graph_edit.set_selected(pasted_node)
-		selected_nodes[pasted_node] = true
-	
-	changesmade = true
-	
-	# Remove node with UndoRedo
-	undo_redo.create_action("Paste Nodes")
-	for pasted_node in pasted_nodes:
-		undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(pasted_node))
-		undo_redo.add_undo_method(Callable(pasted_node, "queue_free"))
-		undo_redo.add_undo_method(Callable(self, "remove_connections_to_node").bind(pasted_node))
-		undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-	
-
-#functions for tracking changes for save state detection
-func _register_inputs_in_node(node: Node):
-	#tracks input to nodes sliders and codeedit to track if patch is saved
-	# Track Sliders
-	for slider in node.find_children("*", "HSlider", true, false):
-		# Create a Callable to the correct method
-		var callable = Callable(self, "_on_any_slider_changed")
-		# Check if it's already connected, and connect if not
-		if not slider.is_connected("value_changed", callable):
-			slider.connect("value_changed", callable)
-	
-	for slider in node.find_children("*", "VBoxContainer", true, false):
-		# Also connect to meta_changed if the slider has that signal
-		if slider.has_signal("meta_changed"):
-			var meta_callable = Callable(self, "_on_any_slider_meta_changed")
-			if not slider.is_connected("meta_changed", meta_callable):
-				slider.connect("meta_changed", meta_callable)
-		
-	# Track CodeEdits
-	for editor in node.find_children("*", "CodeEdit", true, false):
-		var callable = Callable(self, "_on_any_input_changed")
-		if not editor.is_connected("text_changed", callable):
-			editor.connect("text_changed", callable)
-			
-func _on_any_slider_meta_changed():
-	changesmade = true
-	print("Meta changed in slider")
-	
-func _register_node_movement():
-	for graphnode in graph_edit.get_children():
-		if graphnode is GraphNode:
-			var callable = Callable(self, "_on_graphnode_moved")
-			if not graphnode.is_connected("position_offset_changed", callable):
-				graphnode.connect("position_offset_changed", callable)
-
-func _on_graphnode_moved():
-	changesmade = true
-	
-func _on_any_slider_changed(value: float) -> void:
-	changesmade = true
-	
-func _on_any_input_changed():
-	changesmade = true
-
-func _track_changes():
-	changesmade = true
-	
-
-
-func _run_process() -> void:
-	if Global.infile == "no_file":
-		$NoInputPopup.popup_centered()
-	else:
-		if foldertoggle.button_pressed == true and lastoutputfolder != "none":
-			_on_file_dialog_dir_selected(lastoutputfolder)
-		else:
-			$FileDialog.show()
-			
-
-func _on_file_dialog_dir_selected(dir: String) -> void:
-	lastoutputfolder = dir
-	console_output.clear()
-	var interface_settings = ConfigHandler.load_interface_settings()
-	if interface_settings.disable_progress_bar == false:
-		$ProgressWindow.show()
-	else:
-		if $Console.is_visible():
-			$Console.hide()
-			await get_tree().process_frame  # Wait a frame to allow hide to complete
-			$Console.popup_centered()
-		else:
-			$Console.popup_centered()
-	await get_tree().process_frame
-	log_console("Generating processing queue", true)
-	await get_tree().process_frame
-
-	#get the current time in hh-mm-ss format as default : causes file name issues
-	var time_dict = Time.get_time_dict_from_system()
-	# Pad with zeros to ensure two digits for hour, minute, second
-	var hour = str(time_dict.hour).pad_zeros(2)
-	var minute = str(time_dict.minute).pad_zeros(2)
-	var second = str(time_dict.second).pad_zeros(2)
-	var time_str = hour + "-" + minute + "-" + second
-	Global.outfile = dir + "/" + outfilename.text.get_basename() + "_" + Time.get_date_string_from_system() + "_" + time_str
-	log_console("Output directory and file name(s):" + Global.outfile, true)
-	await get_tree().process_frame
-	
-	run_thread_with_branches()
-	
-func run_thread_with_branches():
-	process_cancelled = false
-	process_successful = true
-	# Detect platform: Determine if the OS is Windows
-	var is_windows := OS.get_name() == "Windows"
-	
-	# Choose appropriate commands based on OS
-	var delete_cmd = "del" if is_windows else "rm"
-	var rename_cmd = "ren" if is_windows else "mv"
-	var path_sep := "/"  # Always use forward slash for paths
-
-	# Get all node connections in the GraphEdit
-	var connections = graph_edit.get_connection_list()
-
-	# Prepare data structures for graph traversal
-	var graph = {}          # forward adjacency list
-	var reverse_graph = {}  # reverse adjacency list (for input lookup)
-	var indegree = {}       # used for topological sort
-	var all_nodes = {}      # map of node name -> GraphNode reference
-
-	log_console("Mapping thread.", true)
-	await get_tree().process_frame  # Let UI update
-
-	#Step 0: check thread is valid
-	var is_valid = path_exists_through_all_nodes()
-	if is_valid == false:
-		log_console("[color=#9c2828][b]Error: Valid Thread not found[/b][/color]", true)
-		log_console("Threads must contain at least one processing node and a valid path from the Input File to the Output File.", true)
-		await get_tree().process_frame  # Let UI update
-		return
-	else:
-		log_console("[color=#638382][b]Valid Thread found[/b][/color]", true)
-		await get_tree().process_frame  # Let UI update
-		
-	# Step 1: Gather nodes from the GraphEdit
-	for child in graph_edit.get_children():
-		if child is GraphNode:
-			var name = str(child.name)
-			all_nodes[name] = child
-			if not child.has_meta("utility"):
-				graph[name] = []
-				reverse_graph[name] = []
-				indegree[name] = 0  # Start with zero incoming edges
-	#do calculations for progress bar
-	var progress_step
-	if Global.trim_infile == true:
-		progress_step = 100 / (graph.size() + 4)
-	else:
-		progress_step = 100 / (graph.size() + 3)
-	
-
-	# Step 2: Build graph relationships from connections
-	if process_cancelled:
-		$ProgressWindow/ProgressLabel.text = "Thread Stopped"
-		log_console("[b]Thread Stopped[/b]", true)
-		return
-	else:
-		$ProgressWindow/ProgressLabel.text = "Building Thread"
-	for conn in connections:
-		var from = str(conn["from_node"])
-		var to = str(conn["to_node"])
-		if graph.has(from) and graph.has(to):
-			graph[from].append(to)
-			reverse_graph[to].append(from)
-			indegree[to] += 1  # Count incoming edges
-
-	# Step 3: Topological sort to get execution order
-	var sorted = []  # Sorted list of node names
-	var queue = []   # Queue of nodes with 0 indegree
-
-	for node in graph.keys():
-		if indegree[node] == 0:
-			queue.append(node)
-
-	while not queue.is_empty():
-		var current = queue.pop_front()
-		sorted.append(current)
-		for neighbor in graph[current]:
-			indegree[neighbor] -= 1
-			if indegree[neighbor] == 0:
-				queue.append(neighbor)
-
-	# If not all nodes were processed, there's a cycle
-	if sorted.size() != graph.size():
-		log_console("[color=#9c2828][b]Error: Thread not valid[/b][/color]", true)
-		log_console("Threads cannot contain loops.", true)
-		return
-	$ProgressWindow/ProgressBar.value = progress_step
-	# Step 4: Start processing audio
-	var batch_lines = []        # Holds all batch file commands
-	var intermediate_files = [] # Files to delete later
-	var breakfiles = [] #breakfiles to delete later
-
-	# Dictionary to keep track of each node's output file
-	var output_files = {}
-	var process_count = 0
-
-	# Start with the original input file
-	var starting_infile = Global.infile
-	
-	
-	#If trim is enabled trim input audio
-	if Global.trim_infile == true:
-		if process_cancelled:
-			$ProgressWindow/ProgressLabel.text = "Thread Stopped"
-			log_console("[b]Thread Stopped[/b]", true)
-			return
-		else:
-			$ProgressWindow/ProgressLabel.text = "Trimming input audio"
-		await run_command(cdpprogs_location + "/sfedit", ["cut", "1", starting_infile, "%s_trimmed.wav" % Global.outfile, str(Global.infile_start), str(Global.infile_stop)])
-		starting_infile = Global.outfile + "_trimmed.wav"
-		# Mark trimmed file for cleanup if needed
-		if delete_intermediate_outputs:
-			intermediate_files.append(Global.outfile + "_trimmed.wav")
-		$ProgressWindow/ProgressBar.value += progress_step
-	var current_infile = starting_infile
-
-	# Iterate over the processing nodes in topological order
-	for node_name in sorted:
-		var node = all_nodes[node_name]
-		if process_cancelled:
-			$ProgressWindow/ProgressLabel.text = "Thread Stopped"
-			log_console("[b]Thread Stopped[/b]", true)
-			break
-		else:
-			$ProgressWindow/ProgressLabel.text = "Running process: " + node.get_title()
-		# Find upstream nodes connected to the current node
-		var inputs = reverse_graph[node_name]
-		var input_files = []
-		for input_node in inputs:
-			input_files.append(output_files[input_node])
-
-		# Merge inputs if this node has more than one input
-		if input_files.size() > 1:
-			# Prepare final merge output file name
-			var runmerge = await merge_many_files(process_count, input_files)
-			var merge_output = runmerge[0]
-			var converted_files = runmerge[1]
-
-			# Track the output and intermediate files
-			current_infile = merge_output
-			
-			if delete_intermediate_outputs:
-				intermediate_files.append(merge_output)
-				for f in converted_files:
-					intermediate_files.append(f)
-
-		# If only one input, use that
-		elif input_files.size() == 1:
-			current_infile = input_files[0]
-
-		## If no input, use the original input file
-		else:
-			current_infile = starting_infile
-
-		# Build the command for the current node's audio processing
-		var slider_data = _get_slider_values_ordered(node)
-		
-		if node.get_slot_type_right(0) == 1: #detect if process outputs pvoc data
-			if typeof(current_infile) == TYPE_ARRAY:
-				#check if infile is an array meaning that the last pvoc process was run in dual mono mode
-				# Process left and right seperately
-				var pvoc_stereo_files = []
-				
-				for infile in current_infile:
-					var makeprocess = await make_process(node, process_count, infile, slider_data)
-					# run the command
-					await run_command(makeprocess[0], makeprocess[3])
-					await get_tree().process_frame
-					var output_file = makeprocess[1]
-					pvoc_stereo_files.append(output_file)
-					
-					# Mark file for cleanup if needed
-					if delete_intermediate_outputs:
-						for file in makeprocess[2]:
-							breakfiles.append(file)
-						intermediate_files.append(output_file)
-
-					process_count += 1
-					
-				output_files[node_name] = pvoc_stereo_files
-			else:
-				var input_stereo = await is_stereo(current_infile)
-				if input_stereo == true: 
-					#audio file is stereo and needs to be split for pvoc processing
-					var pvoc_stereo_files = []
-					##Split stereo to c1/c2
-					await run_command(cdpprogs_location + "/housekeep",["chans", "2", current_infile])
-			
-					# Process left and right seperately
-					for channel in ["c1", "c2"]:
-						var dual_mono_file = current_infile.get_basename() + "_%s.wav" % channel
-						
-						var makeprocess = await make_process(node, process_count, dual_mono_file, slider_data)
-						# run the command
-						await run_command(makeprocess[0], makeprocess[3])
-						await get_tree().process_frame
-						var output_file = makeprocess[1]
-						pvoc_stereo_files.append(output_file)
-						
-						# Mark file for cleanup if needed
-						if delete_intermediate_outputs:
-							for file in makeprocess[2]:
-								breakfiles.append(file)
-							intermediate_files.append(output_file)
-						
-						#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
-						#with this stereo process CDP will throw errors in the console even though its fine
-						if is_windows:
-							dual_mono_file = dual_mono_file.replace("/", "\\")
-						await run_command(delete_cmd, [dual_mono_file])
-						process_count += 1
-						
-						# Store output file path for this node
-					output_files[node_name] = pvoc_stereo_files
-				else: 
-					#input file is mono run through process
-					var makeprocess = await make_process(node, process_count, current_infile, slider_data)
-					# run the command
-					await run_command(makeprocess[0], makeprocess[3])
-					await get_tree().process_frame
-					var output_file = makeprocess[1]
-
-					# Store output file path for this node
-					output_files[node_name] = output_file
-
-					# Mark file for cleanup if needed
-					if delete_intermediate_outputs:
-						for file in makeprocess[2]:
-							breakfiles.append(file)
-						intermediate_files.append(output_file)
-
-		# Increase the process step count
-			process_count += 1
-			
-		else: 
-			#Process outputs audio
-			#check if this is the last pvoc process in a stereo processing chain
-			if node.get_meta("command") == "pvoc_synth" and typeof(current_infile) == TYPE_ARRAY:
-			
-				#check if infile is an array meaning that the last pvoc process was run in dual mono mode
-				# Process left and right seperately
-				var pvoc_stereo_files = []
-				
-				for infile in current_infile:
-					var makeprocess = await make_process(node, process_count, infile, slider_data)
-					# run the command
-					await run_command(makeprocess[0], makeprocess[3])
-					await get_tree().process_frame
-					var output_file = makeprocess[1]
-					pvoc_stereo_files.append(output_file)
-					
-					# Mark file for cleanup if needed
-					if delete_intermediate_outputs:
-						for file in makeprocess[2]:
-							breakfiles.append(file)
-						intermediate_files.append(output_file)
-
-					process_count += 1
-					
-					
-				#interleave left and right
-				var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
-				await run_command(cdpprogs_location + "/submix", ["interleave", pvoc_stereo_files[0], pvoc_stereo_files[1], output_file])
-				# Store output file path for this node
-				output_files[node_name] = output_file
-				
-				# Mark file for cleanup if needed
-				if delete_intermediate_outputs:
-					intermediate_files.append(output_file)
-
-			else:
-				#Detect if input file is mono or stereo
-				var input_stereo = await is_stereo(current_infile)
-				if input_stereo == true:
-					if node.get_meta("stereo_input") == true: #audio file is stereo and process is stereo, run file through process
-						var makeprocess = await make_process(node, process_count, current_infile, slider_data)
-						# run the command
-						await run_command(makeprocess[0], makeprocess[3])
-						await get_tree().process_frame
-						var output_file = makeprocess[1]
-						
-						# Store output file path for this node
-						output_files[node_name] = output_file
-
-						# Mark file for cleanup if needed
-						if delete_intermediate_outputs:
-							for file in makeprocess[2]:
-								breakfiles.append(file)
-							intermediate_files.append(output_file)
-
-					else: #audio file is stereo and process is mono, split stereo, process and recombine
-						##Split stereo to c1/c2
-						await run_command(cdpprogs_location + "/housekeep",["chans", "2", current_infile])
-				
-						# Process left and right seperately
-						var dual_mono_output = []
-						for channel in ["c1", "c2"]:
-							var dual_mono_file = current_infile.get_basename() + "_%s.wav" % channel
-							
-							var makeprocess = await make_process(node, process_count, dual_mono_file, slider_data)
-							# run the command
-							await run_command(makeprocess[0], makeprocess[3])
-							await get_tree().process_frame
-							var output_file = makeprocess[1]
-							dual_mono_output.append(output_file)
-							
-							# Mark file for cleanup if needed
-							if delete_intermediate_outputs:
-								for file in makeprocess[2]:
-									breakfiles.append(file)
-								intermediate_files.append(output_file)
-							
-							#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
-							#with this stereo process CDP will throw errors in the console even though its fine
-							if is_windows:
-								dual_mono_file = dual_mono_file.replace("/", "\\")
-							await run_command(delete_cmd, [dual_mono_file])
-							process_count += 1
-						
-						
-						var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
-						await run_command(cdpprogs_location + "/submix", ["interleave", dual_mono_output[0], dual_mono_output[1], output_file])
-						
-						# Store output file path for this node
-						output_files[node_name] = output_file
-
-						# Mark file for cleanup if needed
-						if delete_intermediate_outputs:
-							intermediate_files.append(output_file)
-
-				else: #audio file is mono, run through the process
-					var makeprocess = await make_process(node, process_count, current_infile, slider_data)
-					# run the command
-					await run_command(makeprocess[0], makeprocess[3])
-					await get_tree().process_frame
-					var output_file = makeprocess[1]
-					
-
-					# Store output file path for this node
-					output_files[node_name] = output_file
-
-					# Mark file for cleanup if needed
-					if delete_intermediate_outputs:
-						for file in makeprocess[2]:
-							breakfiles.append(file)
-						intermediate_files.append(output_file)
-
-			# Increase the process step count
-			process_count += 1
-		$ProgressWindow/ProgressBar.value += progress_step
-	# FINAL OUTPUT STAGE
-
-	# Collect all nodes that are connected to the outputfile node
-	if process_cancelled:
-		$ProgressWindow/ProgressLabel.text = "Thread Stopped"
-		log_console("[b]Thread Stopped[/b]", true)
-		return
-	else:
-		$ProgressWindow/ProgressLabel.text = "Finalising output"
-	var output_inputs := []
-	for conn in connections:
-		var to_node = str(conn["to_node"])
-		if all_nodes.has(to_node) and all_nodes[to_node].get_meta("command") == "outputfile":
-			output_inputs.append(str(conn["from_node"]))
-
-	# List to hold the final output files to be merged (if needed)
-	var final_outputs := []
-	for node_name in output_inputs:
-		if output_files.has(node_name):
-			final_outputs.append(output_files[node_name])
-
-	# If multiple outputs go to the outputfile node, merge them
-	if final_outputs.size() > 1:
-		var runmerge = await merge_many_files(process_count, final_outputs)
-		final_output_dir = runmerge[0]
-		var converted_files = runmerge[1]
-		
-		if delete_intermediate_outputs:
-			for f in converted_files:
-				intermediate_files.append(f)
-
-
-	# Only one output, no merge needed
-	elif final_outputs.size() == 1:
-		var single_output = final_outputs[0]
-		final_output_dir = single_output
-		intermediate_files.erase(single_output)
-	$ProgressWindow/ProgressBar.value += progress_step
-	# CLEANUP: Delete intermediate files after processing and rename final output
-	if process_cancelled:
-		$ProgressWindow/ProgressLabel.text = "Thread Stopped"
-		log_console("[b]Thread Stopped[/b]", true)
-		return
-	else:
-		log_console("Cleaning up intermediate files.", true)
-		$ProgressWindow/ProgressLabel.text = "Cleaning up"
-	for file_path in intermediate_files:
-		# Adjust file path format for Windows if needed
-		var fixed_path = file_path
-		if is_windows:
-			fixed_path = fixed_path.replace("/", "\\")
-		await run_command(delete_cmd, [fixed_path])
-		await get_tree().process_frame
-	#delete break files 
-	for file_path in breakfiles:
-		# Adjust file path format for Windows if needed
-		var fixed_path = file_path
-		if is_windows:
-			fixed_path = fixed_path.replace("/", "\\")
-		await run_command(delete_cmd, [fixed_path])
-		await get_tree().process_frame
-		
-	var final_filename = "%s.wav" % Global.outfile
-	var final_output_dir_fixed_path = final_output_dir
-	if is_windows:
-		final_output_dir_fixed_path = final_output_dir_fixed_path.replace("/", "\\")
-		await run_command(rename_cmd, [final_output_dir_fixed_path, final_filename.get_file()])
-	else:
-		await run_command(rename_cmd, [final_output_dir_fixed_path, "%s.wav" % Global.outfile])
-	final_output_dir = Global.outfile + ".wav"
-	
-	output_audio_player.play_outfile(final_output_dir)
-	outfile = final_output_dir
-	$ProgressWindow/ProgressBar.value = 100.0
-	var interface_settings = ConfigHandler.load_interface_settings() #checks if close console is enabled and closes console on a success
-	$ProgressWindow.hide()
-	if interface_settings.auto_close_console and process_successful == true:
-		$Console.hide()
-
-
-func is_stereo(file: String) -> bool:
-	var output = await run_command(cdpprogs_location + "/sfprops", ["-c", file])
-	output = int(output.strip_edges()) #convert output from cmd to clean int
-	if output == 1:
-		return false
-	elif output == 2:
-		return true
-	elif output == 1026: #ignore pvoc .ana files
-		return false
-	else:
-		log_console("[color=#9c2828]Error: Only mono and stereo files are supported[/color]", true)
-		return false
-
-func merge_many_files(process_count: int, input_files: Array) -> Array:
-	var merge_output = "%s_merge_%d.wav" % [Global.outfile.get_basename(), process_count]
-	var converted_files := []  # Track any mono->stereo converted files
-	var inputs_to_merge := []  # Files to be used in the final merge
-
-	var mono_files := []
-	var stereo_files := []
-
-	# STEP 1: Check each file's channel count
-	for f in input_files:
-		var stereo = await is_stereo(f)
-		if stereo == false:
-			mono_files.append(f)
-		elif stereo == true:
-			stereo_files.append(f)
-
-
-	# STEP 2: Convert mono to stereo if there is a mix
-	if mono_files.size() > 0 and stereo_files.size() > 0:
-		for mono_file in mono_files:
-			var stereo_file = "%s_stereo.wav" % mono_file.get_basename()
-			await run_command(cdpprogs_location + "/submix", ["interleave", mono_file, mono_file, stereo_file])
-			if process_successful == false:
-				log_console("Failed to interleave mono file: %s" % mono_file, true)
-			else:
-				converted_files.append(stereo_file)
-				inputs_to_merge.append(stereo_file)
-		# Add existing stereo files
-		inputs_to_merge += stereo_files
-	else:
-		# All mono or all stereo — use input_files directly
-		inputs_to_merge = input_files.duplicate()
-
-	# STEP 3: Merge all input files (converted or original)
-	var quoted_inputs := []
-	for f in inputs_to_merge:
-		quoted_inputs.append(f)
-	quoted_inputs.insert(0, "mergemany")
-	quoted_inputs.append(merge_output)
-	await run_command(cdpprogs_location + "/submix", quoted_inputs)
-
-	if process_successful == false:
-		log_console("Failed to to merge files to" + merge_output, true)
-	
-	return [merge_output, converted_files]
-
-func _get_slider_values_ordered(node: Node) -> Array:
-	var results := []
-	for child in node.get_children():
-		if child is Range:
-			var flag = child.get_meta("flag") if child.has_meta("flag") else ""
-			var time
-			var brk_data = []
-			var min_slider = child.min_value
-			var max_slider = child.max_value
-			if child.has_meta("time"):
-				time = child.get_meta("time")
-			else:
-				time = false
-			if child.has_meta("brk_data"):
-				brk_data = child.get_meta("brk_data")
-			results.append([flag, child.value, time, brk_data, min_slider, max_slider])
-		elif child.get_child_count() > 0:
-			var nested := _get_slider_values_ordered(child)
-			results.append_array(nested)
-	return results
-
-
-
-func make_process(node: Node, process_count: int, current_infile: String, slider_data: Array) -> Array:
-	# Determine output extension: .wav or .ana based on the node's slot type
-	var extension = ".wav" if node.get_slot_type_right(0) == 0 else ".ana"
-
-	# Construct output filename for this step
-	var output_file = "%s_%d%s" % [Global.outfile.get_basename(), process_count, extension]
-
-	# Get the command name from metadata or default to node name
-	var command_name = str(node.get_meta("command"))
-	#command_name = command_name.replace("_", " ")
-	command_name = command_name.split("_", true, 1)
-	print(command_name)
-	var command = "%s/%s" %[cdpprogs_location, command_name[0]]
-	print(command)
-	var args = command_name[1].split("_", true, 1)
-	print(args)
-	args.append(current_infile)
-	args.append(output_file)
-	print(args)
-	# Start building the command line windows
-	var line = "%s/%s \"%s\" \"%s\" " % [cdpprogs_location, command_name, current_infile, output_file]
-	#mac
-
-	
-	var cleanup = []
-
-	# Append parameter values from the sliders, include flags if present
-	var slider_count = 0
-	for entry in slider_data:
-		var flag = entry[0]
-		var value = entry[1]
-		var time = entry[2] #checks if slider is a time percentage slider
-		var brk_data = entry[3]
-		var min_slider = entry[4]
-		var max_slider = entry[5]
-		if brk_data.size() > 0: #if breakpoint data is present on slider
-			#Sort all points by time
-			var sorted_brk_data = []
-			sorted_brk_data = brk_data.duplicate()
-			sorted_brk_data.sort_custom(sort_points)
-			
-			var calculated_brk = []
-			
-			#get length of input file in seconds
-			var infile_length = await run_command(cdpprogs_location + "/sfprops", ["-d", current_infile])
-			infile_length = float(infile_length.strip_edges())
-			
-			#scale values from automation window to the right length for file and correct slider values
-			#need to check how time is handled in all files that accept it, zigzag is x = outfile position, y = infile position
-			#if time == true:
-				#for point in sorted_brk_data:
-					#var new_x = infile_length * (point.x / 700) #time
-					#var new_y = infile_length * (remap(point.y, 255, 0, min_slider, max_slider) / 100) #slider value scaled as a percentage of infile time
-					#calculated_brk.append(Vector2(new_x, new_y))
-			#else:
-			for i in range(sorted_brk_data.size()):
-				var point = sorted_brk_data[i]
-				var new_x = infile_length * (point.x / 700) #time
-				if i == sorted_brk_data.size() - 1: #check if this is last automation point
-					new_x = infile_length + 0.1  # force last point's x to infile_length + 100ms to make sure the file is defo over
-				var new_y = remap(point.y, 255, 0, min_slider, max_slider) #slider value
-				calculated_brk.append(Vector2(new_x, new_y))
-				
-			#make text file
-			var brk_file_path = output_file.get_basename() + "_" + str(slider_count) + ".txt"
-			write_breakfile(calculated_brk, brk_file_path)
-			
-			#append text file in place of value
-			line += ("\"%s\" " % brk_file_path)
-			args.append(brk_file_path)
-			
-			cleanup.append(brk_file_path)
-		else:
-			if time == true:
-				var infile_length = await run_command(cdpprogs_location + "/sfprops", ["-d", current_infile])
-				infile_length = float(infile_length.strip_edges())
-				value = infile_length * (value / 100) #calculate percentage time of the input file
-			line += ("%s%.2f " % [flag, value]) if flag.begins_with("-") else ("%.2f " % value)
-			args.append(("%s%.2f " % [flag, value]) if flag.begins_with("-") else ("%.2f " % value))
-			
-		slider_count += 1
-	return [command, output_file, cleanup, args]
-	#return [line.strip_edges(), output_file, cleanup]
-
-func sort_points(a, b):
-	return a.x < b.x
-	
-func write_breakfile(points: Array, path: String):
-	var file = FileAccess.open(path, FileAccess.WRITE)
-	if file:
-		for point in points:
-			var line = str(point.x) + " " + str(point.y) + "\n"
-			file.store_string(line)
-		file.close()
-	else:
-		print("Failed to open file for writing.")
-
-func run_command(command: String, args: Array) -> String:
-	var is_windows = OS.get_name() == "Windows"
-
-	console_output.append_text(command + " " + " ".join(args) + "\n")
-	console_output.scroll_to_line(console_output.get_line_count() - 1)
-	await get_tree().process_frame
-	
-	if is_windows:
-		#exit_code = OS.execute("cmd.exe", ["/C", command], output, true, false)
-		args.insert(0, command)
-		args.insert(0, "/C")
-		process_info = OS.execute_with_pipe("cmd.exe", args, false)
-	else:
-		process_info = OS.execute_with_pipe(command, args, false)
-	# Check if the process was successfully started
-	if !process_info.has("pid"):
-		print("Failed to start process.")
-		return ""
-	
-	process_running = true
-	
-	# Start monitoring the process output and status
-	return await monitor_process(process_info["pid"], process_info["stdio"], process_info["stderr"])
-
-func monitor_process(pid: int, stdout: FileAccess, stderr: FileAccess) -> String:
-	var output := ""
-	
-	while OS.is_process_running(pid):
-		await get_tree().process_frame
-		
-		while stdout.get_position() < stdout.get_length():
-			var line = stdout.get_line()
-			output += line
-			console_output.append_text(line + "\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-		while stderr.get_position() < stderr.get_length():
-			var line = stderr.get_line()
-			output += line
-			console_output.append_text(line + "\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-	
-	var exit_code = OS.get_process_exit_code(pid)
-	if exit_code == 0:
-		if output.contains("ERROR:"): #checks if CDP reported an error but passed exit code 0 anyway
-			console_output.append_text("[color=#9c2828][b]Processes failed[/b][/color]\n\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-			process_successful = false
-			if process_cancelled == false:
-				$ProgressWindow.hide()
-				if !$Console.visible:
-					$Console.popup_centered()
-		else:
-			console_output.append_text("[color=#638382]Processes ran successfully[/color]\n\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-	else:
-		console_output.append_text("[color=#9c2828][b]Processes failed with exit code: %d[/b][/color]\n" % exit_code + "\n")
-		console_output.scroll_to_line(console_output.get_line_count() - 1)
-		process_successful = false
-		if process_cancelled == false:
-			$ProgressWindow.hide()
-			if !$Console.visible:
-				$Console.popup_centered()
-		if output.contains("as an internal or external command"): #check for cdprogs location error on windows
-			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is C:\\CDPR8\\_cdp\\_cdprogs[/b][/color]\n\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-		if output.contains("command not found"): #check for cdprogs location error on unix systems
-			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is ~/cdpr8/_cdp/_cdprogs[/b][/color]\n\n")
-			console_output.scroll_to_line(console_output.get_line_count() - 1)
-			
-	process_running = false
-	return output
-
-func _on_kill_process_button_down() -> void:
-	if process_running and process_info.has("pid"):
-		$ProgressWindow.hide()
-		# Terminate the process by PID
-		OS.kill(process_info["pid"])
-		process_running = false
-		print("Process cancelled.")
-		process_cancelled = true
-
-	
-func path_exists_through_all_nodes() -> bool:
-	var all_nodes = {}
-	var graph = {}
-
-	var input_node_name = ""
-	var output_node_name = ""
-
-	# Gather all relevant nodes
-	for child in graph_edit.get_children():
-		if child is GraphNode:
-			var name = str(child.name)
-			all_nodes[name] = child
-
-			var command = child.get_meta("command")
-			if command == "inputfile":
-				input_node_name = name
-			elif command == "outputfile":
-				output_node_name = name
-
-			# Skip utility nodes, include others
-			if command in ["inputfile", "outputfile"] or not child.has_meta("utility"):
-				graph[name] = []
-
-	# Ensure both input and output were found
-	if input_node_name == "" or output_node_name == "":
-		print("Input or output node not found!")
-		return false
-
-	# Add edges to graph from the connection list
-	var connection_list = graph_edit.get_connection_list()
-	for conn in connection_list:
-		var from = str(conn["from_node"])
-		var to = str(conn["to_node"])
-		if graph.has(from):
-			graph[from].append(to)
-
-	# BFS traversal to check path and depth
-	var visited = {}
-	var queue = [ { "node": input_node_name, "depth": 0 } ]
-	var has_intermediate = false
-
-	while queue.size() > 0:
-		var current = queue.pop_front()
-		var current_node = current["node"]
-		var depth = current["depth"]
-
-		if current_node in visited:
-			continue
-		visited[current_node] = true
-
-		if current_node == output_node_name and depth >= 2:
-			has_intermediate = true
-
-		if graph.has(current_node):
-			for neighbor in graph[current_node]:
-				queue.append({ "node": neighbor, "depth": depth + 1 })
-
-	return has_intermediate
-
-
-func _toggle_delete(toggled_on: bool):
-	delete_intermediate_outputs = toggled_on
-	print(toggled_on)
-
-func _on_console_close_requested() -> void:
-	$Console.hide()
-
-func log_console(text: String, update: bool) -> void:
-	console_output.append_text(text + "\n \n")
-	console_output.scroll_to_line(console_output.get_line_count() - 1)
-	if update == true:
-		await get_tree().process_frame  # Optional: ensure UI updates
-
-
-func _on_console_open_folder_button_down() -> void:
-	$Console.hide()
-	OS.shell_open(Global.outfile.get_base_dir())
-
-
-func _on_ok_button_2_button_down() -> void:
-	$NoInputPopup.hide()
-
-
-func _on_ok_button_3_button_down() -> void:
-	$MultipleConnectionsPopup.hide()
-
-
-
-func _on_settings_button_index_pressed(index: int) -> void:
-	var interface_settings = ConfigHandler.load_interface_settings()
-	
-	match index:
-		0:
-			$Settings.popup_centered()
-		1:
-			$AudioSettings.popup_centered()
-		2:
-			if $Console.is_visible():
-				$Console.hide()
-				await get_tree().process_frame  # Wait a frame to allow hide to complete
-				$Console.popup_centered()
-			else:
-				$Console.popup_centered()
-
-func _on_file_button_index_pressed(index: int) -> void:
-	match index:
-		0:
-			if changesmade == true:
-				savestate = "newfile"
-				$SaveChangesPopup.popup_centered()
-			else:
-				new_patch()
-				currentfile = "none" #reset current file to none for save tracking
-		1:
-			if currentfile == "none":
-				savestate = "saveas"
-				$SaveDialog.popup_centered()
-			else:
-				save_graph_edit(currentfile)
-		2:
-			savestate = "saveas"
-			$SaveDialog.popup_centered()
-		3:
-			if changesmade == true:
-				savestate = "load"
-				$SaveChangesPopup.popup_centered()
-			else:
-				$LoadDialog.popup_centered()
-
-func save_graph_edit(path: String):
-	var file = FileAccess.open(path, FileAccess.WRITE)
-	if file == null:
-		print("Failed to open file for saving")
-		return
-
-	var node_data_list = []
-	var connection_data_list = []
-	var node_id_map = {}  # Map node name to numeric ID
-
-	var node_id = 1
-	# Assign each node a unique numeric ID and gather node data
-	for node in graph_edit.get_children():
-		if node is GraphNode:
-			node_id_map[node.name] = node_id
-
-			var offset = node.position_offset
-			var node_data = {
-				"id": node_id,
-				"name": node.name,
-				"command": node.get_meta("command"),
-				"offset": { "x": offset.x, "y": offset.y },
-				"slider_values": {},
-				"notes": {}
-			}
-
-			# Save slider values and metadata
-			for child in node.find_children("*", "Slider", true, false):
-				var relative_path = node.get_path_to(child)
-				var path_str = str(relative_path)
-
-				node_data["slider_values"][path_str] = {
-					"value": child.value,
-					"editable": child.editable,
-					"meta": {}
-				}
-				for key in child.get_meta_list():
-					node_data["slider_values"][path_str]["meta"][str(key)] = child.get_meta(key)
-				
-			# Save notes from CodeEdit children
-			for child in node.find_children("*", "CodeEdit", true, false):
-				node_data["notes"][child.name] = child.text
-
-			node_data_list.append(node_data)
-			node_id += 1
-
-	# Save connections using node IDs instead of names
-	for conn in graph_edit.get_connection_list():
-		# Map from_node and to_node names to IDs
-		var from_id = node_id_map.get(conn["from_node"], null)
-		var to_id = node_id_map.get(conn["to_node"], null)
-
-		if from_id != null and to_id != null:
-			connection_data_list.append({
-				"from_node_id": from_id,
-				"from_port": conn["from_port"],
-				"to_node_id": to_id,
-				"to_port": conn["to_port"]
-			})
-		else:
-			print("Warning: Connection references unknown node(s). Skipping connection.")
-
-	var graph_data = {
-		"nodes": node_data_list,
-		"connections": connection_data_list
-	}
-
-	var json = JSON.new()
-	var json_string = json.stringify(graph_data, "\t")
-	file.store_string(json_string)
-	file.close()
-	print("Graph saved.")
-	changesmade = false
-	get_window().title = "SoundThread - " + path.get_file().trim_suffix(".thd")
-
-
-func load_graph_edit(path: String):
-	var file = FileAccess.open(path, FileAccess.READ)
-	if file == null:
-		print("Failed to open file for loading")
-		return
-
-	var json_text = file.get_as_text()
-	file.close()
-
-	var json = JSON.new()
-	if json.parse(json_text) != OK:
-		print("Error parsing JSON")
-		return
-
-	var graph_data = json.get_data()
-	graph_edit.clear_connections()
-
-	# Remove all existing GraphNodes from graph_edit
-	for node in graph_edit.get_children():
-		if node is GraphNode:
-			node.queue_free()
-
-	await get_tree().process_frame  # Ensure nodes are freed before adding new ones
-
-	var id_to_node = {}  # Map node IDs to new node instances
-
-	# Recreate nodes and store them by ID
-	for node_data in graph_data["nodes"]:
-		var command_name = node_data.get("command", "")
-		var template = Nodes.get_node_or_null(command_name)
-		if not template:
-			print("Template not found for command:", command_name)
-			continue
-
-		var new_node: GraphNode = template.duplicate()
-		new_node.name = node_data["name"]
-		new_node.position_offset = Vector2(node_data["offset"]["x"], node_data["offset"]["y"])
-		new_node.set_meta("command", command_name)
-		graph_edit.add_child(new_node)
-		new_node.connect("open_help", Callable(self, "show_help_for_node"))
-		_register_node_movement()  # Track node movement changes
-
-		id_to_node[node_data["id"]] = new_node
-
-		# Restore sliders
-		for slider_path_str in node_data["slider_values"]:
-			var slider = new_node.get_node_or_null(slider_path_str)
-			if slider and (slider is HSlider or slider is VSlider):
-				var slider_info = node_data["slider_values"][slider_path_str]
-				if typeof(slider_info) == TYPE_DICTIONARY:
-					slider.value = slider_info.get("value", slider.value)
-					if slider_info.has("editable"):
-						slider.editable = slider_info["editable"]
-					if slider_info.has("meta"):
-						for key in slider_info["meta"]:
-							var value = slider_info["meta"][key]
-							if key == "brk_data" and typeof(value) == TYPE_ARRAY:
-								var new_array: Array = []
-								for item in value:
-									if typeof(item) == TYPE_STRING:
-										var numbers: PackedStringArray = item.strip_edges().trim_prefix("(").trim_suffix(")").split(",")
-										if numbers.size() == 2:
-											var x = float(numbers[0])
-											var y = float(numbers[1])
-											new_array.append(Vector2(x, y))
-								value = new_array
-							slider.set_meta(key, value)
-				else:
-					slider.value = slider_info
-
-		# Restore notes
-		for codeedit_name in node_data["notes"]:
-			var codeedit = new_node.find_child(codeedit_name, true, false)
-			if codeedit and (codeedit is CodeEdit):
-				codeedit.text = node_data["notes"][codeedit_name]
-
-		_register_inputs_in_node(new_node)  # Track slider changes
-
-	# Recreate connections by looking up nodes by ID
-	for conn in graph_data["connections"]:
-		var from_node = id_to_node.get(conn["from_node_id"], null)
-		var to_node = id_to_node.get(conn["to_node_id"], null)
-
-		if from_node != null and to_node != null:
-			graph_edit.connect_node(
-				from_node.name, conn["from_port"],
-				to_node.name, conn["to_port"]
-			)
-		else:
-			print("Warning: Connection references unknown node ID(s). Skipping connection.")
-
-	link_output()
-	print("Graph loaded.")
-	get_window().title = "SoundThread - " + path.get_file().trim_suffix(".thd")
-
-func _on_save_dialog_file_selected(path: String) -> void:
-	save_graph_edit(path) #save file
-	#check what the user was trying to do before save and do that action
-	if savestate == "newfile":
-		new_patch()
-		currentfile = "none" #reset current file to none for save tracking
-	elif savestate == "load":
-		$LoadDialog.popup_centered()
-	elif savestate == "helpfile":
-		currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-		load_graph_edit(helpfile)
-	elif savestate == "quit":
-		await get_tree().create_timer(0.25).timeout #little pause so that it feels like it actually saved even though it did
-		get_tree().quit()
-		
-	savestate = "none" #reset save state, not really needed but feels good
-
-
-func _on_load_dialog_file_selected(path: String) -> void:
-	currentfile = path #tracking path here only means "save" only saves patches the user has loaded rather than overwriting help files
-	load_graph_edit(path)
-
-func _on_help_button_index_pressed(index: int) -> void:
-	match index:
-		0:
-			pass
-		1:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/getting_started.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/getting_started.thd")
-		2:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/navigating.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/navigating.thd")
-		3:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/building_a_thread.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/building_a_thread.thd")
-		4:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/frequency_domain.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/frequency_domain.thd")
-		5:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/automation.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/automation.thd")
-		6:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/trimming.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/trimming.thd")
-		7:
-			pass
-		8:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/wetdry.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/wetdry.thd")
-		9:
-			if changesmade == true:
-				savestate = "helpfile"
-				helpfile = "res://examples/resonant_filters.thd"
-				$SaveChangesPopup.popup_centered()
-			else:
-				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-				load_graph_edit("res://examples/resonant_filters.thd")
-		10:
-			pass
-		11:
-			OS.shell_open("https://www.composersdesktop.com/docs/html/ccdpndex.htm")
-		12:
-			OS.shell_open("https://github.com/j-p-higgins/SoundThread/issues")
-
-func _recycle_outfile():
-	if outfile != "no file":
-		input_audio_player.recycle_outfile(outfile)
-
-
-
-func _on_save_changes_button_down() -> void:
-	$SaveChangesPopup.hide()
-	if currentfile == "none":
-		$SaveDialog.show()
-	else:
-		save_graph_edit(currentfile)
-		if savestate == "newfile":
-			new_patch()
-			currentfile = "none" #reset current file to none for save tracking
-		elif savestate == "load":
-			$LoadDialog.popup_centered()
-		elif savestate == "helpfile":
-			currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-			load_graph_edit(helpfile)
-		elif savestate == "quit":
-			await get_tree().create_timer(0.25).timeout #little pause so that it feels like it actually saved even though it did
-			get_tree().quit()
-			
-		savestate = "none"
-
-
-func _on_dont_save_changes_button_down() -> void:
-	$SaveChangesPopup.hide()
-	if savestate == "newfile":
-		new_patch()
-		currentfile = "none" #reset current file to none for save tracking
-	elif savestate == "load":
-		$LoadDialog.popup_centered()
-	elif savestate == "helpfile":
-		currentfile = "none" #reset current file to none for save tracking so user cant save over help file
-		load_graph_edit(helpfile)
-	elif savestate == "quit":
-		get_tree().quit()
-	
-	savestate = "none"
-	
-func _notification(what):
-	if what == NOTIFICATION_WM_CLOSE_REQUEST:
-		_on_kill_process_button_down()
-		$Console.hide()
-		if changesmade == true:
-			savestate = "quit"
-			$SaveChangesPopup.popup_centered()
-			#$HelpWindow.hide()
-		else:
-			get_tree().quit() # default behavior
-			
-func _open_output_folder():
-	if lastoutputfolder != "none":
-		OS.shell_open(lastoutputfolder)
-		
-
-func _on_rich_text_label_meta_clicked(meta: Variant) -> void:
-	print(str(meta))
-	OS.shell_open(str(meta))
-
-
-func _on_graph_edit_popup_request(at_position: Vector2) -> void:
-
-	effect_position = graph_edit.get_local_mouse_position()
-
-	#get the mouse position in screen coordinates
-	var mouse_screen_pos = DisplayServer.mouse_get_position()  
-	#get the window position in screen coordinates
-	var window_screen_pos = get_window().position
-	#get the window size relative to its scaling for retina displays
-	var window_size = get_window().size * DisplayServer.screen_get_scale()
-
-	#calculate the xy position of the mouse clamped to the size of the window and menu so it doesn't go off the screen
-	var clamped_x = clamp(mouse_screen_pos.x, window_screen_pos.x, window_screen_pos.x + window_size.x - $SearchMenu.size.x)
-	var clamped_y = clamp(mouse_screen_pos.y, window_screen_pos.y, window_screen_pos.y + window_size.y - (420 * DisplayServer.screen_get_scale()))
-	
-	#position and show the menu
-	$SearchMenu.position = Vector2(clamped_x, clamped_y)
-	$SearchMenu.popup()
-
-func _on_audio_settings_close_requested() -> void:
-	$AudioSettings.hide()
-
-
-func _on_open_audio_settings_button_down() -> void:
-	$AudioDevicePopup.hide()
-	$AudioSettings.popup_centered()
-
-
-func _on_audio_device_popup_close_requested() -> void:
-	$AudioDevicePopup.hide()
-
-func _on_mainmenu_close_requested() -> void:
-	#closes menu if click is anywhere other than the menu as it is a window with popup set to true
-	$mainmenu.hide()
-
-func open_explore():
-	effect_position = graph_edit.get_local_mouse_position()
-	
-	#get the mouse position in screen coordinates
-	var mouse_screen_pos = DisplayServer.mouse_get_position()  
-	#get the window position in screen coordinates
-	var window_screen_pos = get_window().position
-	#get the window size relative to its scaling for retina displays
-	var window_size = get_window().size * DisplayServer.screen_get_scale()
-	#get the size of the popup menu
-	var popup_size = $mainmenu.size
-
-	#calculate the xy position of the mouse clamped to the size of the window and menu so it doesn't go off the screen
-	var clamped_x = clamp(mouse_screen_pos.x, window_screen_pos.x, window_screen_pos.x + window_size.x - popup_size.x)
-	var clamped_y = clamp(mouse_screen_pos.y, window_screen_pos.y, window_screen_pos.y + window_size.y - popup_size.y)
-	
-	#position and show the menu
-	$mainmenu.position = Vector2(clamped_x, clamped_y)
-	$mainmenu.popup()
-	
-func change_console_settings(toggled: bool):
-	$Console.always_on_top = toggled

+ 10 - 9
scenes/main/control.tscn

@@ -1,10 +1,11 @@
-[gd_scene load_steps=9 format=3 uid="uid://bcs87y7ptx3ke"]
+[gd_scene load_steps=10 format=3 uid="uid://bcs87y7ptx3ke"]
 
 
-[ext_resource type="Script" uid="uid://bdlfvuljckmu1" path="res://scenes/main/control.gd" id="1_2f0aq"]
-[ext_resource type="Script" uid="uid://l2yejnjysupr" path="res://scenes/main/graph_edit.gd" id="2_3ioqo"]
+[ext_resource type="Script" uid="uid://bdlfvuljckmu1" path="res://scenes/main/scripts/control.gd" id="1_2f0aq"]
+[ext_resource type="Script" uid="uid://l2yejnjysupr" path="res://scenes/main/scripts/graph_edit.gd" id="2_3ioqo"]
 [ext_resource type="PackedScene" uid="uid://b0wdj8v6o0wq0" path="res://scenes/menu/menu.tscn" id="3_dtf4o"]
 [ext_resource type="PackedScene" uid="uid://b0wdj8v6o0wq0" path="res://scenes/menu/menu.tscn" id="3_dtf4o"]
 [ext_resource type="Texture2D" uid="uid://cdwux1smquvpi" path="res://theme/images/logo.png" id="4_3ioqo"]
 [ext_resource type="Texture2D" uid="uid://cdwux1smquvpi" path="res://theme/images/logo.png" id="4_3ioqo"]
 [ext_resource type="PackedScene" uid="uid://dta7rfalv4uvd" path="res://scenes/main/audio_settings.tscn" id="5_dtf4o"]
 [ext_resource type="PackedScene" uid="uid://dta7rfalv4uvd" path="res://scenes/main/audio_settings.tscn" id="5_dtf4o"]
+[ext_resource type="Script" uid="uid://wja0lo4nobh1" path="res://scenes/main/scripts/about_menu.gd" id="5_yf4wl"]
 [ext_resource type="Script" uid="uid://dlcbmyu3s2phc" path="res://scenes/menu/search_menu.gd" id="6_fyarh"]
 [ext_resource type="Script" uid="uid://dlcbmyu3s2phc" path="res://scenes/menu/search_menu.gd" id="6_fyarh"]
 [ext_resource type="Script" uid="uid://b6r7k326k3vif" path="res://scenes/Nodes/check_for_updates.gd" id="7_1kc3g"]
 [ext_resource type="Script" uid="uid://b6r7k326k3vif" path="res://scenes/Nodes/check_for_updates.gd" id="7_1kc3g"]
 [ext_resource type="PackedScene" uid="uid://c1a6elrpk4eks" path="res://scenes/main/settings.tscn" id="8_16l5g"]
 [ext_resource type="PackedScene" uid="uid://c1a6elrpk4eks" path="res://scenes/main/settings.tscn" id="8_16l5g"]
@@ -296,6 +297,7 @@ item_12/id = 11
 item_count = 1
 item_count = 1
 item_0/id = 0
 item_0/id = 0
 item_0/disabled = true
 item_0/disabled = true
+script = ExtResource("5_yf4wl")
 
 
 [node name="SaveDialog" type="FileDialog" parent="."]
 [node name="SaveDialog" type="FileDialog" parent="."]
 use_native_dialog = true
 use_native_dialog = true
@@ -435,12 +437,11 @@ offset_right = 592.0
 offset_bottom = 100.0
 offset_bottom = 100.0
 text = "Stop Running Thread"
 text = "Stop Running Thread"
 
 
-[connection signal="connection_request" from="GraphEdit" to="." method="_on_graph_edit_connection_request"]
-[connection signal="delete_nodes_request" from="GraphEdit" to="." method="_on_graph_edit_delete_nodes_request"]
-[connection signal="disconnection_request" from="GraphEdit" to="." method="_on_graph_edit_disconnection_request"]
-[connection signal="focus_entered" from="GraphEdit" to="." method="_on_graph_edit_focus_entered"]
-[connection signal="node_deselected" from="GraphEdit" to="." method="_on_graph_edit_node_deselected"]
-[connection signal="node_selected" from="GraphEdit" to="." method="_on_graph_edit_node_selected"]
+[connection signal="connection_request" from="GraphEdit" to="GraphEdit" method="_on_connection_request"]
+[connection signal="delete_nodes_request" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_delete_nodes_request"]
+[connection signal="disconnection_request" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_disconnection_request"]
+[connection signal="node_deselected" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_node_deselected"]
+[connection signal="node_selected" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_node_selected"]
 [connection signal="popup_request" from="GraphEdit" to="." method="_on_graph_edit_popup_request"]
 [connection signal="popup_request" from="GraphEdit" to="." method="_on_graph_edit_popup_request"]
 [connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
 [connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
 [connection signal="close_requested" from="mainmenu" to="." method="_on_mainmenu_close_requested"]
 [connection signal="close_requested" from="mainmenu" to="." method="_on_mainmenu_close_requested"]

+ 0 - 7
scenes/main/graph_edit.gd

@@ -1,7 +0,0 @@
-extends GraphEdit
-
-# Called when the node enters the scene tree for the first time.
-func _ready() -> void:
-	snapping_enabled = false
-	show_grid = false
-	zoom = 0.9

+ 1 - 1
scenes/main/help_window.tscn

@@ -1,6 +1,6 @@
 [gd_scene load_steps=2 format=3 uid="uid://cp5uwxjskqgr7"]
 [gd_scene load_steps=2 format=3 uid="uid://cp5uwxjskqgr7"]
 
 
-[ext_resource type="Script" uid="uid://bkte84glywsny" path="res://scenes/main/help_window.gd" id="1_ro77y"]
+[ext_resource type="Script" uid="uid://bkte84glywsny" path="res://scenes/main/scripts/help_window.gd" id="1_ro77y"]
 
 
 [node name="HelpWindow" type="Window"]
 [node name="HelpWindow" type="Window"]
 auto_translate_mode = 1
 auto_translate_mode = 1

+ 11 - 0
scenes/main/scripts/about_menu.gd

@@ -0,0 +1,11 @@
+extends PopupMenu
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+	#Check export config for version number and set about menu to current version
+	#Assumes version of mac + linux builds is the same as windows
+	#Requires manual update for alpha and beta builds but once the -beta is removed will be fully automatic so long as version is updated on export
+	var export_config = ConfigFile.new()
+	export_config.load("res://export_presets.cfg")
+	set_item_text(0, "SoundThread v" + export_config.get_value("preset.0.options", "application/product_version", "version unknown") + "-beta") 

+ 1 - 0
scenes/main/scripts/about_menu.gd.uid

@@ -0,0 +1 @@
+uid://wja0lo4nobh1

+ 0 - 0
scenes/main/audiosettings.gd → scenes/main/scripts/audiosettings.gd


+ 0 - 0
scenes/main/audiosettings.gd.uid → scenes/main/scripts/audiosettings.gd.uid


+ 579 - 0
scenes/main/scripts/control.gd

@@ -0,0 +1,579 @@
+extends Control
+
+var mainmenu_visible : bool = false #used to test if mainmenu is open
+var effect_position = Vector2(40,40) #tracks mouse position for node placement offset
+@onready var graph_edit = $GraphEdit
+var cdpprogs_location #stores the cdp programs location from user prefs for easy access
+var delete_intermediate_outputs # tracks state of delete intermediate outputs toggle
+@onready var console_output: RichTextLabel = $Console/ConsoleOutput
+var final_output_dir
+var undo_redo := UndoRedo.new() 
+var output_audio_player #tracks the node that is the current output player for linking
+var input_audio_player #tracks node that is the current input player for linking
+var outfile = "no file" #tracks dir of output file from cdp process
+var currentfile = "none" #tracks dir of currently loaded file for saving
+var changesmade = false #tracks if user has made changes to the currently loaded save file
+var savestate # tracks what the user is trying to do when savechangespopup is called
+var helpfile #tracks which help file the user was trying to load when savechangespopup is called
+var outfilename #links to the user name for outputfile field
+var foldertoggle #links to the reuse folder button
+var lastoutputfolder = "none" #tracks last output folder, this can in future be used to replace global.outfile but i cba right now
+var uiscale = 1.0 #tracks scaling for retina screens
+
+#scripts
+var open_help
+var run_thread
+var save_load
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+	Nodes.hide()
+	$mainmenu.hide()
+	$NoLocationPopup.hide()
+	$Console.hide()
+	$NoInputPopup.hide()
+	$MultipleConnectionsPopup.hide()
+	$AudioSettings.hide()
+	$AudioDevicePopup.hide()
+	$SearchMenu.hide()
+	$Settings.hide()
+	$ProgressWindow.hide()
+	
+	$SaveDialog.access = FileDialog.ACCESS_FILESYSTEM
+	$SaveDialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
+	$SaveDialog.filters = ["*.thd"]
+	
+	$LoadDialog.access = FileDialog.ACCESS_FILESYSTEM
+	$LoadDialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
+	$LoadDialog.filters = ["*.thd"]
+	
+	get_tree().set_auto_accept_quit(false) #disable closing the app with the x and instead handle it internally
+	
+	load_scripts()
+	make_signal_connections()
+	check_user_preferences()
+	hidpi_adjustment()
+	new_patch()
+	check_cdp_location_set()
+	
+func load_scripts():
+	#load and initialise scripts
+	open_help = preload("res://scenes/main/scripts/open_help.gd").new()
+	open_help.init(self)
+	add_child(open_help)
+	
+	run_thread = preload("res://scenes/main/scripts/run_thread.gd").new()
+	run_thread.init(self, $ProgressWindow, $ProgressWindow/ProgressLabel, $ProgressWindow/ProgressBar, $GraphEdit, $Console, $Console/ConsoleOutput)
+	add_child(run_thread)
+	
+	graph_edit.init(self, $GraphEdit, Callable(open_help, "show_help_for_node"), $MultipleConnectionsPopup)
+	
+	save_load = preload("res://scenes/main/scripts/save_load.gd").new()
+	save_load.init(self, $GraphEdit, Callable(open_help, "show_help_for_node"), Callable(graph_edit, "_register_node_movement"), Callable(graph_edit, "_register_inputs_in_node"), Callable(self, "link_output"))
+	add_child(save_load)
+
+func make_signal_connections():
+	get_node("SearchMenu").make_node.connect(graph_edit._make_node)
+	get_node("mainmenu").make_node.connect(graph_edit._make_node)
+	get_node("mainmenu").open_help.connect(open_help.show_help_for_node)
+	get_node("Settings").open_cdp_location.connect(show_cdp_location)
+	get_node("Settings").console_on_top.connect(change_console_settings)
+	
+func hidpi_adjustment():
+	#checks if display is hidpi and scales ui accordingly hidpi - 144
+	if DisplayServer.screen_get_dpi(0) >= 144:
+		uiscale = 2.0
+		get_window().content_scale_factor = uiscale
+		#goes through popup_windows group and scales all popups and resizes them
+		for window in get_tree().get_nodes_in_group("popup_windows"):
+			window.size = window.size * uiscale
+			window.content_scale_factor = uiscale
+
+	#checks if user has opened a file from the system file menu and loads it
+	var args = OS.get_cmdline_args()
+	for arg in args:
+		var path = arg.strip_edges()
+		if FileAccess.file_exists(path) and path.get_extension().to_lower() == "thd":
+			save_load.load_graph_edit(path)
+			break
+
+func new_patch():
+	#clear old patch
+	graph_edit.clear_connections()
+
+	for node in graph_edit.get_children():
+		if node is GraphNode:
+			node.queue_free()
+	
+	await get_tree().process_frame  # Wait for nodes to actually be removed
+	
+	graph_edit.scroll_offset = Vector2(0, 0)
+	
+		#Generate input and output nodes
+	var effect: GraphNode = Nodes.get_node(NodePath("inputfile")).duplicate()
+	effect.name = "inputfile"
+	get_node("GraphEdit").add_child(effect, true)
+	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
+	effect.position_offset = Vector2(20,80)
+	
+	effect = Nodes.get_node(NodePath("outputfile")).duplicate()
+	effect.name = "outputfile"
+	get_node("GraphEdit").add_child(effect, true)
+	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
+	effect.position_offset = Vector2((DisplayServer.screen_get_size().x - 480) / uiscale, 80)
+	graph_edit._register_node_movement() #link nodes for tracking position changes for changes tracking
+	
+	changesmade = false #so it stops trying to save unchanged empty files
+	Global.infile = "no_file" #resets input to stop processes running with old files
+	get_window().title = "SoundThread"
+	link_output()
+	
+	
+func link_output():
+	#links various buttons and function in the input nodes - this is called after they are created so that it still works on new and loading files
+	for control in get_tree().get_nodes_in_group("outputnode"): #check all items in outputnode group
+		if control.get_meta("outputfunction") == "deleteintermediate": #link delete intermediate files toggle to script
+			control.toggled.connect(_toggle_delete)
+			control.button_pressed = true
+		elif control.get_meta("outputfunction") == "runprocess": #link runprocess button
+			control.button_down.connect(_run_process)
+		elif control.get_meta("outputfunction") == "recycle": #link recycle button
+			control.button_down.connect(_recycle_outfile)
+		elif control.get_meta("outputfunction") == "audioplayer": #link output audio player
+			output_audio_player = control
+		elif control.get_meta("outputfunction") == "filename":
+			control.text = "outfile"
+			outfilename = control
+		elif control.get_meta("outputfunction") == "reusefolder":
+			foldertoggle = control
+			foldertoggle.button_pressed = true
+		elif control.get_meta("outputfunction") == "openfolder":
+			control.button_down.connect(_open_output_folder)
+
+	for control in get_tree().get_nodes_in_group("inputnode"):
+		if control.get_meta("inputfunction") == "audioplayer": #link input for recycle function
+			print("input player found")
+			input_audio_player = control
+
+func check_user_preferences():
+	var interface_settings = ConfigHandler.load_interface_settings()
+	var audio_settings = ConfigHandler.load_audio_settings()
+	var audio_devices = AudioServer.get_output_device_list()
+	$Console.always_on_top = interface_settings.console_on_top
+	if audio_devices.has(audio_settings.device):
+		AudioServer.set_output_device(audio_settings.device)
+	else:
+		$AudioDevicePopup.popup_centered()
+	
+	match interface_settings.theme:
+		0:
+			RenderingServer.set_default_clear_color(Color("#2f4f4e"))
+		1:
+			RenderingServer.set_default_clear_color(Color("#000807"))
+		2:
+			RenderingServer.set_default_clear_color(Color("#98d4d2"))
+		3:
+			RenderingServer.set_default_clear_color(Color(interface_settings.theme_custom_colour))
+func show_cdp_location():
+	$CdpLocationDialog.show()
+	
+func check_cdp_location_set():
+	#checks if the location has been set and prompts user to set it
+	var cdpprogs_settings = ConfigHandler.load_cdpprogs_settings()
+	if cdpprogs_settings.location == "no_location":
+		$NoLocationPopup.popup_centered()
+	else:
+		#if location is set, stores it in a variable
+		cdpprogs_location = str(cdpprogs_settings.location)
+		print(cdpprogs_location)
+
+func _on_ok_button_button_down() -> void:
+	#after user has read dialog on where to find cdp progs this loads the file browser
+	$NoLocationPopup.hide()
+	if OS.get_name() == "Windows":
+		$CdpLocationDialog.current_dir = "C:/"
+	else:
+		$CdpLocationDialog.current_dir = OS.get_environment("HOME")
+	$CdpLocationDialog.show()
+
+func _on_cdp_location_dialog_dir_selected(dir: String) -> void:
+	#saves default location for cdp programs in config file
+	ConfigHandler.save_cdpprogs_settings(dir)
+	cdpprogs_location = dir
+
+func _on_cdp_location_dialog_canceled() -> void:
+	#cycles around the set location prompt if user cancels the file dialog
+	check_cdp_location_set()
+	
+
+func _input(event):
+	if event.is_action_pressed("copy_node"):
+		graph_edit.copy_selected_nodes()
+		get_viewport().set_input_as_handled()
+
+	elif event.is_action_pressed("paste_node"):
+		simulate_mouse_click() #hacky fix to stop tooltips getting stuck
+		await get_tree().process_frame
+		graph_edit.paste_copied_nodes()
+		get_viewport().set_input_as_handled()
+	elif event.is_action_pressed("undo"):
+		undo_redo.undo()
+	elif event.is_action_pressed("redo"):
+		undo_redo.redo()
+	elif event.is_action_pressed("save"):
+		if currentfile == "none":
+			savestate = "saveas"
+			$SaveDialog.popup_centered()
+		else:
+			save_load.save_graph_edit(currentfile)
+	elif event.is_action_pressed("open_explore"):
+		open_explore()
+	
+
+
+func simulate_mouse_click():
+	#simulates clicking the middle mouse button in order to hide any visible tooltips
+	var click_pos = get_viewport().get_mouse_position()
+
+	var down_event := InputEventMouseButton.new()
+	down_event.button_index = MOUSE_BUTTON_MIDDLE
+	down_event.pressed = true
+	down_event.position = click_pos
+	Input.parse_input_event(down_event)
+
+	var up_event := InputEventMouseButton.new()
+	up_event.button_index = MOUSE_BUTTON_MIDDLE
+	up_event.pressed = false
+	up_event.position = click_pos
+	Input.parse_input_event(up_event)
+
+
+func _run_process() -> void:
+	if Global.infile == "no_file":
+		$NoInputPopup.popup_centered()
+	else:
+		if foldertoggle.button_pressed == true and lastoutputfolder != "none":
+			_on_file_dialog_dir_selected(lastoutputfolder)
+		else:
+			$FileDialog.show()
+			
+
+func _on_file_dialog_dir_selected(dir: String) -> void:
+	lastoutputfolder = dir
+	console_output.clear()
+	var interface_settings = ConfigHandler.load_interface_settings()
+	if interface_settings.disable_progress_bar == false:
+		$ProgressWindow.show()
+	else:
+		if $Console.is_visible():
+			$Console.hide()
+			await get_tree().process_frame  # Wait a frame to allow hide to complete
+			$Console.popup_centered()
+		else:
+			$Console.popup_centered()
+	await get_tree().process_frame
+	run_thread.log_console("Generating processing queue", true)
+	await get_tree().process_frame
+
+	#get the current time in hh-mm-ss format as default : causes file name issues
+	var time_dict = Time.get_time_dict_from_system()
+	# Pad with zeros to ensure two digits for hour, minute, second
+	var hour = str(time_dict.hour).pad_zeros(2)
+	var minute = str(time_dict.minute).pad_zeros(2)
+	var second = str(time_dict.second).pad_zeros(2)
+	var time_str = hour + "-" + minute + "-" + second
+	Global.outfile = dir + "/" + outfilename.text.get_basename() + "_" + Time.get_date_string_from_system() + "_" + time_str
+	run_thread.log_console("Output directory and file name(s):" + Global.outfile, true)
+	await get_tree().process_frame
+	
+	run_thread.run_thread_with_branches()
+
+func _toggle_delete(toggled_on: bool):
+	delete_intermediate_outputs = toggled_on
+	print(toggled_on)
+
+func _on_console_close_requested() -> void:
+	$Console.hide()
+
+
+
+func _on_console_open_folder_button_down() -> void:
+	$Console.hide()
+	OS.shell_open(Global.outfile.get_base_dir())
+
+
+func _on_ok_button_2_button_down() -> void:
+	$NoInputPopup.hide()
+
+
+func _on_ok_button_3_button_down() -> void:
+	$MultipleConnectionsPopup.hide()
+
+
+
+func _on_settings_button_index_pressed(index: int) -> void:
+	var interface_settings = ConfigHandler.load_interface_settings()
+	
+	match index:
+		0:
+			$Settings.popup_centered()
+		1:
+			$AudioSettings.popup_centered()
+		2:
+			if $Console.is_visible():
+				$Console.hide()
+				await get_tree().process_frame  # Wait a frame to allow hide to complete
+				$Console.popup_centered()
+			else:
+				$Console.popup_centered()
+
+func _on_file_button_index_pressed(index: int) -> void:
+	match index:
+		0:
+			if changesmade == true:
+				savestate = "newfile"
+				$SaveChangesPopup.popup_centered()
+			else:
+				new_patch()
+				currentfile = "none" #reset current file to none for save tracking
+		1:
+			if currentfile == "none":
+				savestate = "saveas"
+				$SaveDialog.popup_centered()
+			else:
+				save_load.save_graph_edit(currentfile)
+		2:
+			savestate = "saveas"
+			$SaveDialog.popup_centered()
+		3:
+			if changesmade == true:
+				savestate = "load"
+				$SaveChangesPopup.popup_centered()
+			else:
+				$LoadDialog.popup_centered()
+
+
+
+func _on_save_dialog_file_selected(path: String) -> void:
+	save_load.save_graph_edit(path) #save file
+	#check what the user was trying to do before save and do that action
+	if savestate == "newfile":
+		new_patch()
+		currentfile = "none" #reset current file to none for save tracking
+	elif savestate == "load":
+		$LoadDialog.popup_centered()
+	elif savestate == "helpfile":
+		currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+		save_load.load_graph_edit(helpfile)
+	elif savestate == "quit":
+		await get_tree().create_timer(0.25).timeout #little pause so that it feels like it actually saved even though it did
+		get_tree().quit()
+		
+	savestate = "none" #reset save state, not really needed but feels good
+
+
+func _on_load_dialog_file_selected(path: String) -> void:
+	currentfile = path #tracking path here only means "save" only saves patches the user has loaded rather than overwriting help files
+	save_load.load_graph_edit(path)
+
+func _on_help_button_index_pressed(index: int) -> void:
+	match index:
+		0:
+			pass
+		1:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/getting_started.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/getting_started.thd")
+		2:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/navigating.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/navigating.thd")
+		3:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/building_a_thread.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/building_a_thread.thd")
+		4:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/frequency_domain.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/frequency_domain.thd")
+		5:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/automation.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/automation.thd")
+		6:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/trimming.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/trimming.thd")
+		7:
+			pass
+		8:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/wetdry.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/wetdry.thd")
+		9:
+			if changesmade == true:
+				savestate = "helpfile"
+				helpfile = "res://examples/resonant_filters.thd"
+				$SaveChangesPopup.popup_centered()
+			else:
+				currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+				save_load.load_graph_edit("res://examples/resonant_filters.thd")
+		10:
+			pass
+		11:
+			OS.shell_open("https://www.composersdesktop.com/docs/html/ccdpndex.htm")
+		12:
+			OS.shell_open("https://github.com/j-p-higgins/SoundThread/issues")
+
+func _recycle_outfile():
+	if outfile != "no file":
+		input_audio_player.recycle_outfile(outfile)
+
+
+
+func _on_save_changes_button_down() -> void:
+	$SaveChangesPopup.hide()
+	if currentfile == "none":
+		$SaveDialog.show()
+	else:
+		save_load.save_graph_edit(currentfile)
+		if savestate == "newfile":
+			new_patch()
+			currentfile = "none" #reset current file to none for save tracking
+		elif savestate == "load":
+			$LoadDialog.popup_centered()
+		elif savestate == "helpfile":
+			currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+			save_load.load_graph_edit(helpfile)
+		elif savestate == "quit":
+			await get_tree().create_timer(0.25).timeout #little pause so that it feels like it actually saved even though it did
+			get_tree().quit()
+			
+		savestate = "none"
+
+
+func _on_dont_save_changes_button_down() -> void:
+	$SaveChangesPopup.hide()
+	if savestate == "newfile":
+		new_patch()
+		currentfile = "none" #reset current file to none for save tracking
+	elif savestate == "load":
+		$LoadDialog.popup_centered()
+	elif savestate == "helpfile":
+		currentfile = "none" #reset current file to none for save tracking so user cant save over help file
+		save_load.load_graph_edit(helpfile)
+	elif savestate == "quit":
+		get_tree().quit()
+	
+	savestate = "none"
+	
+func _notification(what):
+	if what == NOTIFICATION_WM_CLOSE_REQUEST:
+		run_thread._on_kill_process_button_down()
+		$Console.hide()
+		if changesmade == true:
+			savestate = "quit"
+			$SaveChangesPopup.popup_centered()
+			#$HelpWindow.hide()
+		else:
+			get_tree().quit() # default behavior
+			
+func _open_output_folder():
+	if lastoutputfolder != "none":
+		OS.shell_open(lastoutputfolder)
+		
+
+func _on_rich_text_label_meta_clicked(meta: Variant) -> void:
+	print(str(meta))
+	OS.shell_open(str(meta))
+
+
+func _on_graph_edit_popup_request(at_position: Vector2) -> void:
+
+	effect_position = graph_edit.get_local_mouse_position()
+
+	#get the mouse position in screen coordinates
+	var mouse_screen_pos = DisplayServer.mouse_get_position()  
+	#get the window position in screen coordinates
+	var window_screen_pos = get_window().position
+	#get the window size relative to its scaling for retina displays
+	var window_size = get_window().size * DisplayServer.screen_get_scale()
+
+	#calculate the xy position of the mouse clamped to the size of the window and menu so it doesn't go off the screen
+	var clamped_x = clamp(mouse_screen_pos.x, window_screen_pos.x, window_screen_pos.x + window_size.x - $SearchMenu.size.x)
+	var clamped_y = clamp(mouse_screen_pos.y, window_screen_pos.y, window_screen_pos.y + window_size.y - (420 * DisplayServer.screen_get_scale()))
+	
+	#position and show the menu
+	$SearchMenu.position = Vector2(clamped_x, clamped_y)
+	$SearchMenu.popup()
+
+func _on_audio_settings_close_requested() -> void:
+	$AudioSettings.hide()
+
+
+func _on_open_audio_settings_button_down() -> void:
+	$AudioDevicePopup.hide()
+	$AudioSettings.popup_centered()
+
+
+func _on_audio_device_popup_close_requested() -> void:
+	$AudioDevicePopup.hide()
+
+func _on_mainmenu_close_requested() -> void:
+	#closes menu if click is anywhere other than the menu as it is a window with popup set to true
+	$mainmenu.hide()
+
+func open_explore():
+	effect_position = graph_edit.get_local_mouse_position()
+	
+	#get the mouse position in screen coordinates
+	var mouse_screen_pos = DisplayServer.mouse_get_position()  
+	#get the window position in screen coordinates
+	var window_screen_pos = get_window().position
+	#get the window size relative to its scaling for retina displays
+	var window_size = get_window().size * DisplayServer.screen_get_scale()
+	#get the size of the popup menu
+	var popup_size = $mainmenu.size
+
+	#calculate the xy position of the mouse clamped to the size of the window and menu so it doesn't go off the screen
+	var clamped_x = clamp(mouse_screen_pos.x, window_screen_pos.x, window_screen_pos.x + window_size.x - popup_size.x)
+	var clamped_y = clamp(mouse_screen_pos.y, window_screen_pos.y, window_screen_pos.y + window_size.y - popup_size.y)
+	
+	#position and show the menu
+	$mainmenu.position = Vector2(clamped_x, clamped_y)
+	$mainmenu.popup()
+	
+func change_console_settings(toggled: bool):
+	$Console.always_on_top = toggled
+
+
+func _on_kill_process_button_down() -> void:
+	run_thread._on_kill_process_button_down()

+ 0 - 0
scenes/main/control.gd.uid → scenes/main/scripts/control.gd.uid


+ 298 - 0
scenes/main/scripts/graph_edit.gd

@@ -0,0 +1,298 @@
+extends GraphEdit
+
+var control_script
+var graph_edit
+var open_help
+var multiple_connections
+var selected_nodes = {} #used to track which nodes in the GraphEdit are selected
+var copied_nodes_data = [] #stores node data on ctrl+c
+var copied_connections = [] #stores all connections on ctrl+c
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+	snapping_enabled = false
+	show_grid = false
+	zoom = 0.9
+
+func init(main_node: Node, graphedit: GraphEdit, openhelp: Callable, multipleconnections: Window) -> void:
+	control_script = main_node
+	graph_edit = graphedit
+	open_help = openhelp
+	multiple_connections = multipleconnections
+
+func _make_node(command: String):
+	#Find node with matching name to button and create a version of it in the graph edit
+	#and position it close to the origin right click to open the menu
+	var effect: GraphNode = Nodes.get_node(NodePath(command)).duplicate()
+	effect.name = command
+	add_child(effect, true)
+	effect.connect("open_help", open_help)
+	effect.set_position_offset((control_script.effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
+	_register_inputs_in_node(effect) #link sliders for changes tracking
+	_register_node_movement() #link nodes for tracking position changes for changes tracking
+
+	control_script.changesmade = true
+
+	# Remove node with UndoRedo
+	control_script.undo_redo.create_action("Add Node")
+	control_script.undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
+	control_script.undo_redo.add_undo_method(Callable(effect, "queue_free"))
+	control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+	control_script.undo_redo.commit_action()
+	
+
+func _on_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
+	var to_graph_node = get_node(NodePath(to_node))
+
+	# Get the type of the input port using GraphNode's built-in method
+	var port_type = to_graph_node.get_input_port_type(to_port)
+
+	# If port type is 1 and already has a connection, reject the request
+	if port_type == 1:
+		var connections = get_connection_list()
+		var existing_connections = 0
+
+		for conn in connections:
+			if conn.to_node == to_node and conn.to_port == to_port:
+				existing_connections += 1
+				if existing_connections >= 1:
+					var interface_settings = ConfigHandler.load_interface_settings()
+					if interface_settings.disable_pvoc_warning == false:
+						multiple_connections.popup_centered()
+					return
+
+	# If no conflict, allow the connection
+	connect_node(from_node, from_port, to_node, to_port)
+	control_script.changesmade = true
+
+func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
+	disconnect_node(from_node, from_port, to_node, to_port)
+	control_script.changesmade = true
+
+func _on_graph_edit_node_selected(node: Node) -> void:
+	selected_nodes[node] = true
+
+func _on_graph_edit_node_deselected(node: Node) -> void:
+	selected_nodes[node] = false
+
+func _unhandled_key_input(event: InputEvent) -> void:
+	if event is InputEventKey and event.pressed and not event.echo:
+		if event.keycode == KEY_BACKSPACE:
+			_on_graph_edit_delete_nodes_request(PackedStringArray(selected_nodes.keys().filter(func(k): return selected_nodes[k])))
+			pass
+
+func _on_graph_edit_delete_nodes_request(nodes: Array[StringName]) -> void:
+	control_script.undo_redo.create_action("Delete Nodes (Undo only)")
+
+	for node in selected_nodes.keys():
+		if selected_nodes[node]:
+			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
+				print("can't delete input or output")
+			else:
+				# Store duplicate and state for undo
+				var node_data = node.duplicate()
+				var position = node.position_offset
+
+				# Store all connections for undo
+				var conns = []
+				for con in get_connection_list():
+					if con["to_node"] == node.name or con["from_node"] == node.name:
+						conns.append(con)
+
+				# Delete
+				remove_connections_to_node(node)
+				node.queue_free()
+				control_script.changesmade = true
+
+				# Register undo restore
+				control_script.undo_redo.add_undo_method(Callable(self, "add_child").bind(node_data, true))
+				control_script.undo_redo.add_undo_method(Callable(node_data, "set_position_offset").bind(position))
+				for con in conns:
+					control_script.undo_redo.add_undo_method(Callable(self, "connect_node").bind(
+						con["from_node"], con["from_port"],
+						con["to_node"], con["to_port"]
+					))
+				control_script.undo_redo.add_undo_method(Callable(self, "set_node_selected").bind(node_data, true))
+				control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+				control_script.undo_redo.add_undo_method(Callable(self, "_register_inputs_in_node").bind(node_data)) #link sliders for changes tracking
+				control_script.undo_redo.add_undo_method(Callable(self, "_register_node_movement")) # link nodes for changes tracking
+
+	# Clear selection
+	selected_nodes = {}
+
+	control_script.undo_redo.commit_action()
+
+func set_node_selected(node: Node, selected: bool) -> void:
+	selected_nodes[node] = selected
+#
+func remove_connections_to_node(node):
+	for con in get_connection_list():
+		if con["to_node"] == node.name or con["from_node"] == node.name:
+			disconnect_node(con["from_node"], con["from_port"], con["to_node"], con["to_port"])
+			control_script.changesmade = true
+			
+#copy and paste nodes with vertical offset on paste
+func copy_selected_nodes():
+	copied_nodes_data.clear()
+	copied_connections.clear()
+
+	# Store selected nodes and their slider values
+	for node in get_children():
+		# Check if the node is selected and not an 'inputfile' or 'outputfile'
+		if node is GraphNode and selected_nodes.get(node, false):
+			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
+				continue  # Skip these nodes
+
+			var node_data = {
+				"name": node.name,
+				"type": node.get_class(),
+				"offset": node.position_offset,
+				"slider_values": {}
+			}
+
+			for child in node.get_children():
+				if child is HSlider or child is VSlider:
+					node_data["slider_values"][child.name] = child.value
+
+			copied_nodes_data.append(node_data)
+
+	# Store connections between selected nodes
+	for conn in get_connection_list():
+		var from_ref = get_node_or_null(NodePath(conn["from_node"]))
+		var to_ref = get_node_or_null(NodePath(conn["to_node"]))
+
+		var is_from_selected = from_ref != null and selected_nodes.get(from_ref, false)
+		var is_to_selected = to_ref != null and selected_nodes.get(to_ref, false)
+
+		# Skip if any of the connected nodes are 'inputfile' or 'outputfile'
+		if (from_ref != null and (from_ref.get_meta("command") == "inputfile" or from_ref.get_meta("command") == "outputfile")) or (to_ref != null and (to_ref.get_meta("command") == "inputfile" or to_ref.get_meta("command") == "outputfile")):
+			continue
+
+		if is_from_selected and is_to_selected:
+			# Store connection as dictionary
+			var conn_data = {
+				"from_node": conn["from_node"],
+				"from_port": conn["from_port"],
+				"to_node": conn["to_node"],
+				"to_port": conn["to_port"]
+			}
+			copied_connections.append(conn_data)
+
+func paste_copied_nodes():
+	if copied_nodes_data.is_empty():
+		return
+
+	var name_map = {}
+	var pasted_nodes = []
+
+	# Step 1: Find topmost and bottommost Y of copied nodes
+	var min_y = INF
+	var max_y = -INF
+	for node_data in copied_nodes_data:
+		var y = node_data["offset"].y
+		min_y = min(min_y, y)
+		max_y = max(max_y, y)
+
+	# Step 2: Decide where to paste the group
+	var base_y_offset = max_y + 350  # Pasting below the lowest node
+
+	# Step 3: Paste nodes, preserving vertical layout
+	for node_data in copied_nodes_data:
+		var original_node = get_node_or_null(NodePath(node_data["name"]))
+		if not original_node:
+			continue
+
+		var new_node = original_node.duplicate()
+		new_node.name = node_data["name"] + "_copy_" + str(randi() % 10000)
+
+		var relative_y = node_data["offset"].y - min_y
+		new_node.position_offset = Vector2(
+			node_data["offset"].x,
+			base_y_offset + relative_y
+		)
+		
+
+		# Restore sliders
+		for child in new_node.get_children():
+			if child.name in node_data["slider_values"]:
+				child.value = node_data["slider_values"][child.name]
+
+		add_child(new_node, true)
+		new_node.connect("open_help", open_help)
+		_register_inputs_in_node(new_node) #link sliders for changes tracking
+		_register_node_movement() # link nodes for changes tracking
+		name_map[node_data["name"]] = new_node.name
+		pasted_nodes.append(new_node)
+
+
+	# Step 4: Reconnect new nodes
+	for conn_data in copied_connections:
+		var new_from = name_map.get(conn_data["from_node"], null)
+		var new_to = name_map.get(conn_data["to_node"], null)
+
+		if new_from and new_to:
+			connect_node(new_from, conn_data["from_port"], new_to, conn_data["to_port"])
+
+	# Step 5: Select pasted nodes
+	for pasted_node in pasted_nodes:
+		set_selected(pasted_node)
+		selected_nodes[pasted_node] = true
+	
+	control_script.changesmade = true
+	
+	# Remove node with UndoRedo
+	control_script.undo_redo.create_action("Paste Nodes")
+	for pasted_node in pasted_nodes:
+		control_script.undo_redo.add_undo_method(Callable(self, "remove_child").bind(pasted_node))
+		control_script.undo_redo.add_undo_method(Callable(pasted_node, "queue_free"))
+		control_script.undo_redo.add_undo_method(Callable(self, "remove_connections_to_node").bind(pasted_node))
+		control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+	control_script.undo_redo.commit_action()
+	
+
+#functions for tracking changes for save state detection
+func _register_inputs_in_node(node: Node):
+	#tracks input to nodes sliders and codeedit to track if patch is saved
+	# Track Sliders
+	for slider in node.find_children("*", "HSlider", true, false):
+		# Create a Callable to the correct method
+		var callable = Callable(self, "_on_any_slider_changed")
+		# Check if it's already connected, and connect if not
+		if not slider.is_connected("value_changed", callable):
+			slider.connect("value_changed", callable)
+	
+	for slider in node.find_children("*", "VBoxContainer", true, false):
+		# Also connect to meta_changed if the slider has that signal
+		if slider.has_signal("meta_changed"):
+			var meta_callable = Callable(self, "_on_any_slider_meta_changed")
+			if not slider.is_connected("meta_changed", meta_callable):
+				slider.connect("meta_changed", meta_callable)
+		
+	# Track CodeEdits
+	for editor in node.find_children("*", "CodeEdit", true, false):
+		var callable = Callable(self, "_on_any_input_changed")
+		if not editor.is_connected("text_changed", callable):
+			editor.connect("text_changed", callable)
+			
+func _on_any_slider_meta_changed():
+	control_script.changesmade = true
+	print("Meta changed in slider")
+	
+func _register_node_movement():
+	for graphnode in get_children():
+		if graphnode is GraphNode:
+			var callable = Callable(self, "_on_graphnode_moved")
+			if not graphnode.is_connected("position_offset_changed", callable):
+				graphnode.connect("position_offset_changed", callable)
+
+func _on_graphnode_moved():
+	control_script.changesmade = true
+	
+func _on_any_slider_changed(value: float) -> void:
+	control_script.changesmade = true
+	
+func _on_any_input_changed():
+	control_script.changesmade = true
+
+func _track_changes():
+	control_script.changesmade = true

+ 0 - 0
scenes/main/graph_edit.gd.uid → scenes/main/scripts/graph_edit.gd.uid


+ 0 - 0
scenes/main/help_window.gd → scenes/main/scripts/help_window.gd


+ 0 - 0
scenes/main/help_window.gd.uid → scenes/main/scripts/help_window.gd.uid


+ 77 - 0
scenes/main/scripts/open_help.gd

@@ -0,0 +1,77 @@
+extends Node
+var control_script
+var help_data := {} #stores help data for each node to display in help popup
+var HelpWindowScene = preload("res://scenes/main/help_window.tscn")
+
+func _ready() -> void:
+	var file = FileAccess.open("res://scenes/main/process_help.json", FileAccess.READ)
+	if file:
+		help_data = JSON.parse_string(file.get_as_text())
+
+func init(main_node: Node) -> void:
+	control_script = main_node
+
+func show_help_for_node(node_name: String, node_title: String):
+	#check if there is already a help window open for this node and pop it up instead of making a new one
+	for child in get_tree().current_scene.get_children():
+		if child is Window and child.title == "Help - " + node_title:
+			# Found existing window, bring it to front
+			if child.is_visible():
+				child.hide()
+				child.popup()
+			else:
+				child.popup()
+			return
+	
+	if help_data.has(node_name):
+		#looks up the help data from the json and stores it in info
+		var info = help_data[node_name]
+		#makes an instance of the help_window scene
+		var help_window = HelpWindowScene.instantiate()
+		help_window.title = "Help - " + node_title
+		help_window.get_node("HelpTitle").text = node_title
+		
+		var output = ""
+		output += info.get("short_description", "") + "\n\n"
+		
+		var parameters = info.get("parameters", {})
+		#checks if there are parameters and if there are places them in a table
+		if parameters.size() > 0:
+			output += "[table=3]\n"
+			output += "[cell][b]Parameter Name[/b][/cell][cell][b]Description[/b][/cell][cell][b]Automatable[/b][/cell]\n"
+			for key in parameters.keys(): #scans through all parameters
+				var param = parameters[key]
+				var name = param.get("paramname", "")
+				var desc = param.get("paramdescription", "")
+				var automatable = param.get("automatable", false)
+				var autom_text = "[center]✓[/center]" if automatable else "[center]𐄂[/center]" #replaces true and false with ticks and crosses
+				output += "[cell]%s[/cell][cell]%s[/cell][cell]%s[/cell]\n" % [name, desc, autom_text] #places each param detail into cells of the table
+			output += "[/table]\n\n" #ends the table
+		
+		output += "[b]Functionality[/b]\n"
+		var description_text = info.get("description", "")
+		output += description_text.strip_edges()
+		#check if this is a cdp process or a utility and display the cdp process if it is one
+		var category = info.get("category", "")
+		if category != "utility":
+			output += "\n\n[b]CDP Process[/b]\nThis node runs the CDP Process: " + node_name.replace("_", " ")
+		
+		help_window.get_node("HelpText").bbcode_text = output
+		help_window.get_node("HelpText").scroll_to_line(0) #scrolls to the first line of the help file just incase
+		
+		# Add to the current scene tree to show it
+		get_tree().current_scene.add_child(help_window)
+		if help_window.content_scale_factor < control_script.uiscale:
+			help_window.size = help_window.size * control_script.uiscale
+			help_window.content_scale_factor = control_script.uiscale
+		
+		help_window.popup() 
+		
+	else:
+		# If no help available, even though there always should be, show a window saying no help found
+		var help_window = HelpWindowScene.instance()
+		help_window.title = "Help - " + node_title
+		help_window.get_node("HelpTitle").text = node_title
+		help_window.get_node("HelpText").bbcode_text = "No help found."
+		get_tree().current_scene.add_child(help_window)
+		help_window.popup()

+ 1 - 0
scenes/main/scripts/open_help.gd.uid

@@ -0,0 +1 @@
+uid://ba1snhxb0lg3t

+ 786 - 0
scenes/main/scripts/run_thread.gd

@@ -0,0 +1,786 @@
+extends Node
+var control_script
+
+var progress_label
+var progress_bar
+var graph_edit
+var console_output
+var progress_window
+var console_window
+var process_successful #tracks if the last run process was successful
+var process_info = {} #tracks the data of the currently running process
+var process_running := false #tracks if a process is currently running
+var process_cancelled = false #checks if the currently running process has been cancelled
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+	pass
+
+
+func init(main_node: Node, progresswindow: Window, progresslabel: Label, progressbar: ProgressBar, graphedit: GraphEdit, consolewindow: Window, consoleoutput: RichTextLabel) -> void:
+	control_script = main_node
+	progress_window = progresswindow
+	progress_label = progresslabel
+	progress_bar = progressbar
+	graph_edit = graphedit
+	console_window = consolewindow
+	console_output = consoleoutput
+
+
+	
+func run_thread_with_branches():
+	process_cancelled = false
+	process_successful = true
+	# Detect platform: Determine if the OS is Windows
+	var is_windows := OS.get_name() == "Windows"
+	
+	# Choose appropriate commands based on OS
+	var delete_cmd = "del" if is_windows else "rm"
+	var rename_cmd = "ren" if is_windows else "mv"
+	var path_sep := "/"  # Always use forward slash for paths
+
+	# Get all node connections in the GraphEdit
+	var connections = graph_edit.get_connection_list()
+
+	# Prepare data structures for graph traversal
+	var graph = {}          # forward adjacency list
+	var reverse_graph = {}  # reverse adjacency list (for input lookup)
+	var indegree = {}       # used for topological sort
+	var all_nodes = {}      # map of node name -> GraphNode reference
+
+	log_console("Mapping thread.", true)
+	await get_tree().process_frame  # Let UI update
+
+	#Step 0: check thread is valid
+	var is_valid = path_exists_through_all_nodes()
+	if is_valid == false:
+		log_console("[color=#9c2828][b]Error: Valid Thread not found[/b][/color]", true)
+		log_console("Threads must contain at least one processing node and a valid path from the Input File to the Output File.", true)
+		await get_tree().process_frame  # Let UI update
+		return
+	else:
+		log_console("[color=#638382][b]Valid Thread found[/b][/color]", true)
+		await get_tree().process_frame  # Let UI update
+		
+	# Step 1: Gather nodes from the GraphEdit
+	for child in graph_edit.get_children():
+		if child is GraphNode:
+			var name = str(child.name)
+			all_nodes[name] = child
+			if not child.has_meta("utility"):
+				graph[name] = []
+				reverse_graph[name] = []
+				indegree[name] = 0  # Start with zero incoming edges
+	#do calculations for progress bar
+	var progress_step
+	if Global.trim_infile == true:
+		progress_step = 100 / (graph.size() + 4)
+	else:
+		progress_step = 100 / (graph.size() + 3)
+	
+
+	# Step 2: Build graph relationships from connections
+	if process_cancelled:
+		progress_label.text = "Thread Stopped"
+		log_console("[b]Thread Stopped[/b]", true)
+		return
+	else:
+		progress_label.text = "Building Thread"
+	for conn in connections:
+		var from = str(conn["from_node"])
+		var to = str(conn["to_node"])
+		if graph.has(from) and graph.has(to):
+			graph[from].append(to)
+			reverse_graph[to].append(from)
+			indegree[to] += 1  # Count incoming edges
+
+	# Step 3: Topological sort to get execution order
+	var sorted = []  # Sorted list of node names
+	var queue = []   # Queue of nodes with 0 indegree
+
+	for node in graph.keys():
+		if indegree[node] == 0:
+			queue.append(node)
+
+	while not queue.is_empty():
+		var current = queue.pop_front()
+		sorted.append(current)
+		for neighbor in graph[current]:
+			indegree[neighbor] -= 1
+			if indegree[neighbor] == 0:
+				queue.append(neighbor)
+
+	# If not all nodes were processed, there's a cycle
+	if sorted.size() != graph.size():
+		log_console("[color=#9c2828][b]Error: Thread not valid[/b][/color]", true)
+		log_console("Threads cannot contain loops.", true)
+		return
+	progress_bar.value = progress_step
+	# Step 4: Start processing audio
+	var batch_lines = []        # Holds all batch file commands
+	var intermediate_files = [] # Files to delete later
+	var breakfiles = [] #breakfiles to delete later
+
+	# Dictionary to keep track of each node's output file
+	var output_files = {}
+	var process_count = 0
+
+	# Start with the original input file
+	var starting_infile = Global.infile
+	
+	
+	#If trim is enabled trim input audio
+	if Global.trim_infile == true:
+		if process_cancelled:
+			progress_label.text = "Thread Stopped"
+			log_console("[b]Thread Stopped[/b]", true)
+			return
+		else:
+			progress_label.text = "Trimming input audio"
+		await run_command(control_script.cdpprogs_location + "/sfedit", ["cut", "1", starting_infile, "%s_trimmed.wav" % Global.outfile, str(Global.infile_start), str(Global.infile_stop)])
+		starting_infile = Global.outfile + "_trimmed.wav"
+		# Mark trimmed file for cleanup if needed
+		if control_script.delete_intermediate_outputs:
+			intermediate_files.append(Global.outfile + "_trimmed.wav")
+		progress_bar.value += progress_step
+	var current_infile = starting_infile
+
+	# Iterate over the processing nodes in topological order
+	for node_name in sorted:
+		var node = all_nodes[node_name]
+		if process_cancelled:
+			progress_label.text = "Thread Stopped"
+			log_console("[b]Thread Stopped[/b]", true)
+			break
+		else:
+			progress_label.text = "Running process: " + node.get_title()
+		# Find upstream nodes connected to the current node
+		var inputs = reverse_graph[node_name]
+		var input_files = []
+		for input_node in inputs:
+			input_files.append(output_files[input_node])
+
+		# Merge inputs if this node has more than one input
+		if input_files.size() > 1:
+			# Prepare final merge output file name
+			var runmerge = await merge_many_files(process_count, input_files)
+			var merge_output = runmerge[0]
+			var converted_files = runmerge[1]
+
+			# Track the output and intermediate files
+			current_infile = merge_output
+			
+			if control_script.delete_intermediate_outputs:
+				intermediate_files.append(merge_output)
+				for f in converted_files:
+					intermediate_files.append(f)
+
+		# If only one input, use that
+		elif input_files.size() == 1:
+			current_infile = input_files[0]
+
+		## If no input, use the original input file
+		else:
+			current_infile = starting_infile
+
+		# Build the command for the current node's audio processing
+		var slider_data = _get_slider_values_ordered(node)
+		
+		if node.get_slot_type_right(0) == 1: #detect if process outputs pvoc data
+			if typeof(current_infile) == TYPE_ARRAY:
+				#check if infile is an array meaning that the last pvoc process was run in dual mono mode
+				# Process left and right seperately
+				var pvoc_stereo_files = []
+				
+				for infile in current_infile:
+					var makeprocess = await make_process(node, process_count, infile, slider_data)
+					# run the command
+					await run_command(makeprocess[0], makeprocess[3])
+					await get_tree().process_frame
+					var output_file = makeprocess[1]
+					pvoc_stereo_files.append(output_file)
+					
+					# Mark file for cleanup if needed
+					if control_script.delete_intermediate_outputs:
+						for file in makeprocess[2]:
+							breakfiles.append(file)
+						intermediate_files.append(output_file)
+
+					process_count += 1
+					
+				output_files[node_name] = pvoc_stereo_files
+			else:
+				var input_stereo = await is_stereo(current_infile)
+				if input_stereo == true: 
+					#audio file is stereo and needs to be split for pvoc processing
+					var pvoc_stereo_files = []
+					##Split stereo to c1/c2
+					await run_command(control_script.cdpprogs_location + "/housekeep",["chans", "2", current_infile])
+			
+					# Process left and right seperately
+					for channel in ["c1", "c2"]:
+						var dual_mono_file = current_infile.get_basename() + "_%s.wav" % channel
+						
+						var makeprocess = await make_process(node, process_count, dual_mono_file, slider_data)
+						# run the command
+						await run_command(makeprocess[0], makeprocess[3])
+						await get_tree().process_frame
+						var output_file = makeprocess[1]
+						pvoc_stereo_files.append(output_file)
+						
+						# Mark file for cleanup if needed
+						if control_script.delete_intermediate_outputs:
+							for file in makeprocess[2]:
+								breakfiles.append(file)
+							intermediate_files.append(output_file)
+						
+						#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
+						#with this stereo process CDP will throw errors in the console even though its fine
+						if is_windows:
+							dual_mono_file = dual_mono_file.replace("/", "\\")
+						await run_command(delete_cmd, [dual_mono_file])
+						process_count += 1
+						
+						# Store output file path for this node
+					output_files[node_name] = pvoc_stereo_files
+				else: 
+					#input file is mono run through process
+					var makeprocess = await make_process(node, process_count, current_infile, slider_data)
+					# run the command
+					await run_command(makeprocess[0], makeprocess[3])
+					await get_tree().process_frame
+					var output_file = makeprocess[1]
+
+					# Store output file path for this node
+					output_files[node_name] = output_file
+
+					# Mark file for cleanup if needed
+					if control_script.delete_intermediate_outputs:
+						for file in makeprocess[2]:
+							breakfiles.append(file)
+						intermediate_files.append(output_file)
+
+		# Increase the process step count
+			process_count += 1
+			
+		else: 
+			#Process outputs audio
+			#check if this is the last pvoc process in a stereo processing chain
+			if node.get_meta("command") == "pvoc_synth" and typeof(current_infile) == TYPE_ARRAY:
+			
+				#check if infile is an array meaning that the last pvoc process was run in dual mono mode
+				# Process left and right seperately
+				var pvoc_stereo_files = []
+				
+				for infile in current_infile:
+					var makeprocess = await make_process(node, process_count, infile, slider_data)
+					# run the command
+					await run_command(makeprocess[0], makeprocess[3])
+					await get_tree().process_frame
+					var output_file = makeprocess[1]
+					pvoc_stereo_files.append(output_file)
+					
+					# Mark file for cleanup if needed
+					if control_script.delete_intermediate_outputs:
+						for file in makeprocess[2]:
+							breakfiles.append(file)
+						intermediate_files.append(output_file)
+
+					process_count += 1
+					
+					
+				#interleave left and right
+				var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
+				await run_command(control_script.cdpprogs_location + "/submix", ["interleave", pvoc_stereo_files[0], pvoc_stereo_files[1], output_file])
+				# Store output file path for this node
+				output_files[node_name] = output_file
+				
+				# Mark file for cleanup if needed
+				if control_script.delete_intermediate_outputs:
+					intermediate_files.append(output_file)
+
+			else:
+				#Detect if input file is mono or stereo
+				var input_stereo = await is_stereo(current_infile)
+				if input_stereo == true:
+					if node.get_meta("stereo_input") == true: #audio file is stereo and process is stereo, run file through process
+						var makeprocess = await make_process(node, process_count, current_infile, slider_data)
+						# run the command
+						await run_command(makeprocess[0], makeprocess[3])
+						await get_tree().process_frame
+						var output_file = makeprocess[1]
+						
+						# Store output file path for this node
+						output_files[node_name] = output_file
+
+						# Mark file for cleanup if needed
+						if control_script.delete_intermediate_outputs:
+							for file in makeprocess[2]:
+								breakfiles.append(file)
+							intermediate_files.append(output_file)
+
+					else: #audio file is stereo and process is mono, split stereo, process and recombine
+						##Split stereo to c1/c2
+						await run_command(control_script.cdpprogs_location + "/housekeep",["chans", "2", current_infile])
+				
+						# Process left and right seperately
+						var dual_mono_output = []
+						for channel in ["c1", "c2"]:
+							var dual_mono_file = current_infile.get_basename() + "_%s.wav" % channel
+							
+							var makeprocess = await make_process(node, process_count, dual_mono_file, slider_data)
+							# run the command
+							await run_command(makeprocess[0], makeprocess[3])
+							await get_tree().process_frame
+							var output_file = makeprocess[1]
+							dual_mono_output.append(output_file)
+							
+							# Mark file for cleanup if needed
+							if control_script.delete_intermediate_outputs:
+								for file in makeprocess[2]:
+									breakfiles.append(file)
+								intermediate_files.append(output_file)
+							
+							#Delete c1 and c2 because they can be in the wrong folder and if the same infile is used more than once
+							#with this stereo process CDP will throw errors in the console even though its fine
+							if is_windows:
+								dual_mono_file = dual_mono_file.replace("/", "\\")
+							await run_command(delete_cmd, [dual_mono_file])
+							process_count += 1
+						
+						
+						var output_file = Global.outfile.get_basename() + str(process_count) + "_interleaved.wav"
+						await run_command(control_script.cdpprogs_location + "/submix", ["interleave", dual_mono_output[0], dual_mono_output[1], output_file])
+						
+						# Store output file path for this node
+						output_files[node_name] = output_file
+
+						# Mark file for cleanup if needed
+						if control_script.delete_intermediate_outputs:
+							intermediate_files.append(output_file)
+
+				else: #audio file is mono, run through the process
+					var makeprocess = await make_process(node, process_count, current_infile, slider_data)
+					# run the command
+					await run_command(makeprocess[0], makeprocess[3])
+					await get_tree().process_frame
+					var output_file = makeprocess[1]
+					
+
+					# Store output file path for this node
+					output_files[node_name] = output_file
+
+					# Mark file for cleanup if needed
+					if control_script.delete_intermediate_outputs:
+						for file in makeprocess[2]:
+							breakfiles.append(file)
+						intermediate_files.append(output_file)
+
+			# Increase the process step count
+			process_count += 1
+		progress_bar.value += progress_step
+	# FINAL OUTPUT STAGE
+
+	# Collect all nodes that are connected to the outputfile node
+	if process_cancelled:
+		progress_label.text = "Thread Stopped"
+		log_console("[b]Thread Stopped[/b]", true)
+		return
+	else:
+		progress_label.text = "Finalising output"
+	var output_inputs := []
+	for conn in connections:
+		var to_node = str(conn["to_node"])
+		if all_nodes.has(to_node) and all_nodes[to_node].get_meta("command") == "outputfile":
+			output_inputs.append(str(conn["from_node"]))
+
+	# List to hold the final output files to be merged (if needed)
+	var final_outputs := []
+	for node_name in output_inputs:
+		if output_files.has(node_name):
+			final_outputs.append(output_files[node_name])
+
+	# If multiple outputs go to the outputfile node, merge them
+	if final_outputs.size() > 1:
+		var runmerge = await merge_many_files(process_count, final_outputs)
+		control_script.final_output_dir = runmerge[0]
+		var converted_files = runmerge[1]
+		
+		if control_script.delete_intermediate_outputs:
+			for f in converted_files:
+				intermediate_files.append(f)
+
+
+	# Only one output, no merge needed
+	elif final_outputs.size() == 1:
+		var single_output = final_outputs[0]
+		control_script.final_output_dir = single_output
+		intermediate_files.erase(single_output)
+	progress_bar.value += progress_step
+	# CLEANUP: Delete intermediate files after processing and rename final output
+	if process_cancelled:
+		progress_label.text = "Thread Stopped"
+		log_console("[b]Thread Stopped[/b]", true)
+		return
+	else:
+		log_console("Cleaning up intermediate files.", true)
+		progress_label.text = "Cleaning up"
+	for file_path in intermediate_files:
+		# Adjust file path format for Windows if needed
+		var fixed_path = file_path
+		if is_windows:
+			fixed_path = fixed_path.replace("/", "\\")
+		await run_command(delete_cmd, [fixed_path])
+		await get_tree().process_frame
+	#delete break files 
+	for file_path in breakfiles:
+		# Adjust file path format for Windows if needed
+		var fixed_path = file_path
+		if is_windows:
+			fixed_path = fixed_path.replace("/", "\\")
+		await run_command(delete_cmd, [fixed_path])
+		await get_tree().process_frame
+		
+	var final_filename = "%s.wav" % Global.outfile
+	var final_output_dir_fixed_path = control_script.final_output_dir
+	if is_windows:
+		final_output_dir_fixed_path = final_output_dir_fixed_path.replace("/", "\\")
+		await run_command(rename_cmd, [final_output_dir_fixed_path, final_filename.get_file()])
+	else:
+		await run_command(rename_cmd, [final_output_dir_fixed_path, "%s.wav" % Global.outfile])
+	control_script.final_output_dir = Global.outfile + ".wav"
+	
+	control_script.output_audio_player.play_outfile(control_script.final_output_dir)
+	control_script.outfile = control_script.final_output_dir
+	progress_bar.value = 100.0
+	var interface_settings = ConfigHandler.load_interface_settings() #checks if close console is enabled and closes console on a success
+	progress_window.hide()
+	if interface_settings.auto_close_console and process_successful == true:
+		console_window.hide()
+
+
+func is_stereo(file: String) -> bool:
+	var output = await run_command(control_script.cdpprogs_location + "/sfprops", ["-c", file])
+	output = int(output.strip_edges()) #convert output from cmd to clean int
+	if output == 1:
+		return false
+	elif output == 2:
+		return true
+	elif output == 1026: #ignore pvoc .ana files
+		return false
+	else:
+		log_console("[color=#9c2828]Error: Only mono and stereo files are supported[/color]", true)
+		return false
+
+func merge_many_files(process_count: int, input_files: Array) -> Array:
+	var merge_output = "%s_merge_%d.wav" % [Global.outfile.get_basename(), process_count]
+	var converted_files := []  # Track any mono->stereo converted files
+	var inputs_to_merge := []  # Files to be used in the final merge
+
+	var mono_files := []
+	var stereo_files := []
+
+	# STEP 1: Check each file's channel count
+	for f in input_files:
+		var stereo = await is_stereo(f)
+		if stereo == false:
+			mono_files.append(f)
+		elif stereo == true:
+			stereo_files.append(f)
+
+
+	# STEP 2: Convert mono to stereo if there is a mix
+	if mono_files.size() > 0 and stereo_files.size() > 0:
+		for mono_file in mono_files:
+			var stereo_file = "%s_stereo.wav" % mono_file.get_basename()
+			await run_command(control_script.cdpprogs_location + "/submix", ["interleave", mono_file, mono_file, stereo_file])
+			if process_successful == false:
+				log_console("Failed to interleave mono file: %s" % mono_file, true)
+			else:
+				converted_files.append(stereo_file)
+				inputs_to_merge.append(stereo_file)
+		# Add existing stereo files
+		inputs_to_merge += stereo_files
+	else:
+		# All mono or all stereo — use input_files directly
+		inputs_to_merge = input_files.duplicate()
+
+	# STEP 3: Merge all input files (converted or original)
+	var quoted_inputs := []
+	for f in inputs_to_merge:
+		quoted_inputs.append(f)
+	quoted_inputs.insert(0, "mergemany")
+	quoted_inputs.append(merge_output)
+	await run_command(control_script.cdpprogs_location + "/submix", quoted_inputs)
+
+	if process_successful == false:
+		log_console("Failed to to merge files to" + merge_output, true)
+	
+	return [merge_output, converted_files]
+
+func _get_slider_values_ordered(node: Node) -> Array:
+	var results := []
+	for child in node.get_children():
+		if child is Range:
+			var flag = child.get_meta("flag") if child.has_meta("flag") else ""
+			var time
+			var brk_data = []
+			var min_slider = child.min_value
+			var max_slider = child.max_value
+			if child.has_meta("time"):
+				time = child.get_meta("time")
+			else:
+				time = false
+			if child.has_meta("brk_data"):
+				brk_data = child.get_meta("brk_data")
+			results.append([flag, child.value, time, brk_data, min_slider, max_slider])
+		elif child.get_child_count() > 0:
+			var nested := _get_slider_values_ordered(child)
+			results.append_array(nested)
+	return results
+
+
+
+func make_process(node: Node, process_count: int, current_infile: String, slider_data: Array) -> Array:
+	# Determine output extension: .wav or .ana based on the node's slot type
+	var extension = ".wav" if node.get_slot_type_right(0) == 0 else ".ana"
+
+	# Construct output filename for this step
+	var output_file = "%s_%d%s" % [Global.outfile.get_basename(), process_count, extension]
+
+	# Get the command name from metadata or default to node name
+	var command_name = str(node.get_meta("command"))
+	#command_name = command_name.replace("_", " ")
+	command_name = command_name.split("_", true, 1)
+	print(command_name)
+	var command = "%s/%s" %[control_script.cdpprogs_location, command_name[0]]
+	print(command)
+	var args = command_name[1].split("_", true, 1)
+	print(args)
+	args.append(current_infile)
+	args.append(output_file)
+	print(args)
+	# Start building the command line windows
+	var line = "%s/%s \"%s\" \"%s\" " % [control_script.cdpprogs_location, command_name, current_infile, output_file]
+	#mac
+
+	
+	var cleanup = []
+
+	# Append parameter values from the sliders, include flags if present
+	var slider_count = 0
+	for entry in slider_data:
+		var flag = entry[0]
+		var value = entry[1]
+		var time = entry[2] #checks if slider is a time percentage slider
+		var brk_data = entry[3]
+		var min_slider = entry[4]
+		var max_slider = entry[5]
+		if brk_data.size() > 0: #if breakpoint data is present on slider
+			#Sort all points by time
+			var sorted_brk_data = []
+			sorted_brk_data = brk_data.duplicate()
+			sorted_brk_data.sort_custom(sort_points)
+			
+			var calculated_brk = []
+			
+			#get length of input file in seconds
+			var infile_length = await run_command(control_script.cdpprogs_location + "/sfprops", ["-d", current_infile])
+			infile_length = float(infile_length.strip_edges())
+			
+			#scale values from automation window to the right length for file and correct slider values
+			#need to check how time is handled in all files that accept it, zigzag is x = outfile position, y = infile position
+			#if time == true:
+				#for point in sorted_brk_data:
+					#var new_x = infile_length * (point.x / 700) #time
+					#var new_y = infile_length * (remap(point.y, 255, 0, min_slider, max_slider) / 100) #slider value scaled as a percentage of infile time
+					#calculated_brk.append(Vector2(new_x, new_y))
+			#else:
+			for i in range(sorted_brk_data.size()):
+				var point = sorted_brk_data[i]
+				var new_x = infile_length * (point.x / 700) #time
+				if i == sorted_brk_data.size() - 1: #check if this is last automation point
+					new_x = infile_length + 0.1  # force last point's x to infile_length + 100ms to make sure the file is defo over
+				var new_y = remap(point.y, 255, 0, min_slider, max_slider) #slider value
+				calculated_brk.append(Vector2(new_x, new_y))
+				
+			#make text file
+			var brk_file_path = output_file.get_basename() + "_" + str(slider_count) + ".txt"
+			write_breakfile(calculated_brk, brk_file_path)
+			
+			#append text file in place of value
+			line += ("\"%s\" " % brk_file_path)
+			args.append(brk_file_path)
+			
+			cleanup.append(brk_file_path)
+		else:
+			if time == true:
+				var infile_length = await run_command(control_script.cdpprogs_location + "/sfprops", ["-d", current_infile])
+				infile_length = float(infile_length.strip_edges())
+				value = infile_length * (value / 100) #calculate percentage time of the input file
+			line += ("%s%.2f " % [flag, value]) if flag.begins_with("-") else ("%.2f " % value)
+			args.append(("%s%.2f " % [flag, value]) if flag.begins_with("-") else ("%.2f " % value))
+			
+		slider_count += 1
+	return [command, output_file, cleanup, args]
+	#return [line.strip_edges(), output_file, cleanup]
+
+func sort_points(a, b):
+	return a.x < b.x
+	
+func write_breakfile(points: Array, path: String):
+	var file = FileAccess.open(path, FileAccess.WRITE)
+	if file:
+		for point in points:
+			var line = str(point.x) + " " + str(point.y) + "\n"
+			file.store_string(line)
+		file.close()
+	else:
+		print("Failed to open file for writing.")
+
+func run_command(command: String, args: Array) -> String:
+	var is_windows = OS.get_name() == "Windows"
+
+	console_output.append_text(command + " " + " ".join(args) + "\n")
+	console_output.scroll_to_line(console_output.get_line_count() - 1)
+	await get_tree().process_frame
+	
+	if is_windows:
+		#exit_code = OS.execute("cmd.exe", ["/C", command], output, true, false)
+		args.insert(0, command)
+		args.insert(0, "/C")
+		process_info = OS.execute_with_pipe("cmd.exe", args, false)
+	else:
+		process_info = OS.execute_with_pipe(command, args, false)
+	# Check if the process was successfully started
+	if !process_info.has("pid"):
+		print("Failed to start process.")
+		return ""
+	
+	process_running = true
+	
+	# Start monitoring the process output and status
+	return await monitor_process(process_info["pid"], process_info["stdio"], process_info["stderr"])
+
+func monitor_process(pid: int, stdout: FileAccess, stderr: FileAccess) -> String:
+	var output := ""
+	
+	while OS.is_process_running(pid):
+		await get_tree().process_frame
+		
+		while stdout.get_position() < stdout.get_length():
+			var line = stdout.get_line()
+			output += line
+			console_output.append_text(line + "\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+		while stderr.get_position() < stderr.get_length():
+			var line = stderr.get_line()
+			output += line
+			console_output.append_text(line + "\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+	
+	var exit_code = OS.get_process_exit_code(pid)
+	if exit_code == 0:
+		if output.contains("ERROR:"): #checks if CDP reported an error but passed exit code 0 anyway
+			console_output.append_text("[color=#9c2828][b]Processes failed[/b][/color]\n\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+			process_successful = false
+			if process_cancelled == false:
+				progress_window.hide()
+				if !console_window.visible:
+					console_window.popup_centered()
+		else:
+			console_output.append_text("[color=#638382]Processes ran successfully[/color]\n\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+	else:
+		console_output.append_text("[color=#9c2828][b]Processes failed with exit code: %d[/b][/color]\n" % exit_code + "\n")
+		console_output.scroll_to_line(console_output.get_line_count() - 1)
+		process_successful = false
+		if process_cancelled == false:
+			progress_window.hide()
+			if !console_window.visible:
+				console_window.popup_centered()
+		if output.contains("as an internal or external command"): #check for cdprogs location error on windows
+			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is C:\\CDPR8\\_cdp\\_cdprogs[/b][/color]\n\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+		if output.contains("command not found"): #check for cdprogs location error on unix systems
+			console_output.append_text("[color=#9c2828][b]Please make sure your cdprogs folder is set to the correct location in the Settings menu. The default location is ~/cdpr8/_cdp/_cdprogs[/b][/color]\n\n")
+			console_output.scroll_to_line(console_output.get_line_count() - 1)
+			
+	process_running = false
+	return output
+
+func _on_kill_process_button_down() -> void:
+	if process_running and process_info.has("pid"):
+		progress_window.hide()
+		# Terminate the process by PID
+		OS.kill(process_info["pid"])
+		process_running = false
+		print("Process cancelled.")
+		process_cancelled = true
+
+	
+func path_exists_through_all_nodes() -> bool:
+	var all_nodes = {}
+	var graph = {}
+
+	var input_node_name = ""
+	var output_node_name = ""
+
+	# Gather all relevant nodes
+	for child in graph_edit.get_children():
+		if child is GraphNode:
+			var name = str(child.name)
+			all_nodes[name] = child
+
+			var command = child.get_meta("command")
+			if command == "inputfile":
+				input_node_name = name
+			elif command == "outputfile":
+				output_node_name = name
+
+			# Skip utility nodes, include others
+			if command in ["inputfile", "outputfile"] or not child.has_meta("utility"):
+				graph[name] = []
+
+	# Ensure both input and output were found
+	if input_node_name == "" or output_node_name == "":
+		print("Input or output node not found!")
+		return false
+
+	# Add edges to graph from the connection list
+	var connection_list = graph_edit.get_connection_list()
+	for conn in connection_list:
+		var from = str(conn["from_node"])
+		var to = str(conn["to_node"])
+		if graph.has(from):
+			graph[from].append(to)
+
+	# BFS traversal to check path and depth
+	var visited = {}
+	var queue = [ { "node": input_node_name, "depth": 0 } ]
+	var has_intermediate = false
+
+	while queue.size() > 0:
+		var current = queue.pop_front()
+		var current_node = current["node"]
+		var depth = current["depth"]
+
+		if current_node in visited:
+			continue
+		visited[current_node] = true
+
+		if current_node == output_node_name and depth >= 2:
+			has_intermediate = true
+
+		if graph.has(current_node):
+			for neighbor in graph[current_node]:
+				queue.append({ "node": neighbor, "depth": depth + 1 })
+
+	return has_intermediate
+	
+func log_console(text: String, update: bool) -> void:
+	console_output.append_text(text + "\n \n")
+	console_output.scroll_to_line(console_output.get_line_count() - 1)
+	if update == true:
+		await get_tree().process_frame  # Optional: ensure UI updates

+ 1 - 0
scenes/main/scripts/run_thread.gd.uid

@@ -0,0 +1 @@
+uid://f671i3kwftfy

+ 194 - 0
scenes/main/scripts/save_load.gd

@@ -0,0 +1,194 @@
+extends Node
+
+var control_script
+var graph_edit
+var open_help
+var register_movement
+var register_input
+var link_output
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+	pass # Replace with function body.
+
+
+func init(main_node: Node, graphedit: GraphEdit, openhelp: Callable, registermovement: Callable, registerinput: Callable, linkoutput: Callable) -> void:
+	control_script = main_node
+	graph_edit = graphedit
+	open_help = openhelp
+	register_movement = registermovement
+	register_input = registerinput
+	link_output = linkoutput
+	
+	
+func save_graph_edit(path: String):
+	var file = FileAccess.open(path, FileAccess.WRITE)
+	if file == null:
+		print("Failed to open file for saving")
+		return
+
+	var node_data_list = []
+	var connection_data_list = []
+	var node_id_map = {}  # Map node name to numeric ID
+
+	var node_id = 1
+	# Assign each node a unique numeric ID and gather node data
+	for node in graph_edit.get_children():
+		if node is GraphNode:
+			node_id_map[node.name] = node_id
+
+			var offset = node.position_offset
+			var node_data = {
+				"id": node_id,
+				"name": node.name,
+				"command": node.get_meta("command"),
+				"offset": { "x": offset.x, "y": offset.y },
+				"slider_values": {},
+				"notes": {}
+			}
+
+			# Save slider values and metadata
+			for child in node.find_children("*", "Slider", true, false):
+				var relative_path = node.get_path_to(child)
+				var path_str = str(relative_path)
+
+				node_data["slider_values"][path_str] = {
+					"value": child.value,
+					"editable": child.editable,
+					"meta": {}
+				}
+				for key in child.get_meta_list():
+					node_data["slider_values"][path_str]["meta"][str(key)] = child.get_meta(key)
+				
+			# Save notes from CodeEdit children
+			for child in node.find_children("*", "CodeEdit", true, false):
+				node_data["notes"][child.name] = child.text
+
+			node_data_list.append(node_data)
+			node_id += 1
+
+	# Save connections using node IDs instead of names
+	for conn in graph_edit.get_connection_list():
+		# Map from_node and to_node names to IDs
+		var from_id = node_id_map.get(conn["from_node"], null)
+		var to_id = node_id_map.get(conn["to_node"], null)
+
+		if from_id != null and to_id != null:
+			connection_data_list.append({
+				"from_node_id": from_id,
+				"from_port": conn["from_port"],
+				"to_node_id": to_id,
+				"to_port": conn["to_port"]
+			})
+		else:
+			print("Warning: Connection references unknown node(s). Skipping connection.")
+
+	var graph_data = {
+		"nodes": node_data_list,
+		"connections": connection_data_list
+	}
+
+	var json = JSON.new()
+	var json_string = json.stringify(graph_data, "\t")
+	file.store_string(json_string)
+	file.close()
+	print("Graph saved.")
+	control_script.changesmade = false
+	get_window().title = "SoundThread - " + path.get_file().trim_suffix(".thd")
+
+
+func load_graph_edit(path: String):
+	var file = FileAccess.open(path, FileAccess.READ)
+	if file == null:
+		print("Failed to open file for loading")
+		return
+
+	var json_text = file.get_as_text()
+	file.close()
+
+	var json = JSON.new()
+	if json.parse(json_text) != OK:
+		print("Error parsing JSON")
+		return
+
+	var graph_data = json.get_data()
+	graph_edit.clear_connections()
+
+	# Remove all existing GraphNodes from graph_edit
+	for node in graph_edit.get_children():
+		if node is GraphNode:
+			node.queue_free()
+
+	await get_tree().process_frame  # Ensure nodes are freed before adding new ones
+
+	var id_to_node = {}  # Map node IDs to new node instances
+
+	# Recreate nodes and store them by ID
+	for node_data in graph_data["nodes"]:
+		var command_name = node_data.get("command", "")
+		var template = Nodes.get_node_or_null(command_name)
+		if not template:
+			print("Template not found for command:", command_name)
+			continue
+
+		var new_node: GraphNode = template.duplicate()
+		new_node.name = node_data["name"]
+		new_node.position_offset = Vector2(node_data["offset"]["x"], node_data["offset"]["y"])
+		new_node.set_meta("command", command_name)
+		graph_edit.add_child(new_node)
+		new_node.connect("open_help", open_help)
+		register_movement.call()  # Track node movement changes
+
+		id_to_node[node_data["id"]] = new_node
+
+		# Restore sliders
+		for slider_path_str in node_data["slider_values"]:
+			var slider = new_node.get_node_or_null(slider_path_str)
+			if slider and (slider is HSlider or slider is VSlider):
+				var slider_info = node_data["slider_values"][slider_path_str]
+				if typeof(slider_info) == TYPE_DICTIONARY:
+					slider.value = slider_info.get("value", slider.value)
+					if slider_info.has("editable"):
+						slider.editable = slider_info["editable"]
+					if slider_info.has("meta"):
+						for key in slider_info["meta"]:
+							var value = slider_info["meta"][key]
+							if key == "brk_data" and typeof(value) == TYPE_ARRAY:
+								var new_array: Array = []
+								for item in value:
+									if typeof(item) == TYPE_STRING:
+										var numbers: PackedStringArray = item.strip_edges().trim_prefix("(").trim_suffix(")").split(",")
+										if numbers.size() == 2:
+											var x = float(numbers[0])
+											var y = float(numbers[1])
+											new_array.append(Vector2(x, y))
+								value = new_array
+							slider.set_meta(key, value)
+				else:
+					slider.value = slider_info
+
+		# Restore notes
+		for codeedit_name in node_data["notes"]:
+			var codeedit = new_node.find_child(codeedit_name, true, false)
+			if codeedit and (codeedit is CodeEdit):
+				codeedit.text = node_data["notes"][codeedit_name]
+
+		register_input.call(new_node)  # Track slider changes
+
+	# Recreate connections by looking up nodes by ID
+	for conn in graph_data["connections"]:
+		var from_node = id_to_node.get(conn["from_node_id"], null)
+		var to_node = id_to_node.get(conn["to_node_id"], null)
+
+		if from_node != null and to_node != null:
+			graph_edit.connect_node(
+				from_node.name, conn["from_port"],
+				to_node.name, conn["to_port"]
+			)
+		else:
+			print("Warning: Connection references unknown node ID(s). Skipping connection.")
+
+	link_output.call()
+	print("Graph loaded.")
+	get_window().title = "SoundThread - " + path.get_file().trim_suffix(".thd")
+	

+ 1 - 0
scenes/main/scripts/save_load.gd.uid

@@ -0,0 +1 @@
+uid://bn8ir1gnp0bpn

+ 0 - 0
scenes/main/settings.gd → scenes/main/scripts/settings.gd


+ 0 - 0
scenes/main/settings.gd.uid → scenes/main/scripts/settings.gd.uid


+ 1 - 1
scenes/main/settings.tscn

@@ -1,6 +1,6 @@
 [gd_scene load_steps=2 format=3 uid="uid://c1a6elrpk4eks"]
 [gd_scene load_steps=2 format=3 uid="uid://c1a6elrpk4eks"]
 
 
-[ext_resource type="Script" uid="uid://co12pspac25gq" path="res://scenes/main/settings.gd" id="1_uey6c"]
+[ext_resource type="Script" uid="uid://co12pspac25gq" path="res://scenes/main/scripts/settings.gd" id="1_uey6c"]
 
 
 [node name="Settings" type="Window"]
 [node name="Settings" type="Window"]
 auto_translate_mode = 1
 auto_translate_mode = 1

+ 1 - 0
scenes/menu/search_menu.gd

@@ -74,4 +74,5 @@ func _on_search_bar_text_changed(new_text: String) -> void:
 	display_items(new_text)
 	display_items(new_text)
 	
 	
 func _on_item_selected(key: String):
 func _on_item_selected(key: String):
+	self.hide()
 	make_node.emit(key) # send out signal to main patch
 	make_node.emit(key) # send out signal to main patch