Skip to content

Commit

Permalink
Merge branch 'main' of github.com:PhilippThoelke/goofi-pipe
Browse files Browse the repository at this point in the history
  • Loading branch information
sangfrois committed Jul 13, 2024
2 parents 59ad03f + 98ccb45 commit be2b009
Show file tree
Hide file tree
Showing 10 changed files with 333 additions and 6 deletions.
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ openai==1.3.6
scikit-learn
opencv-python==4.8.0.74 # prevent mediapipe TypeError: 'numpy._DTypeMeta' object is not subscriptable
pyzmq
webcolors
webcolors==1.13
pylsl
mediapipe
librosa==0.9.2
Expand All @@ -19,4 +19,5 @@ networkx
deeptime
py-feat
geopy
edgeofpy @ git+https://github.com/jnobyrne/edgeofpy.git
edgeofpy @ git+https://github.com/jnobyrne/edgeofpy.git
mido
2 changes: 1 addition & 1 deletion src/goofi/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ def _messaging_loop(self):
elif msg.type == MessageType.CLEAR_DATA:
# clear the data in the input slot (usually triggered by a REMOVE_OUTPUT_PIPE message)
slot = self.input_slots[msg.content["slot_name"]]
slot.data = None
slot.clear()
elif msg.type == MessageType.PARAMETER_UPDATE:
# update a parameter
group = msg.content["group"]
Expand Down
3 changes: 3 additions & 0 deletions src/goofi/node_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ def __post_init__(self):
# convert dtype from string to DataType
self.dtype = DataType[self.dtype]

def clear(self):
"""Clears the data stored in the input slot."""
self.data = None

@dataclass
class OutputSlot:
Expand Down
69 changes: 69 additions & 0 deletions src/goofi/nodes/inputs/oscin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import time

import numpy as np
from oscpy.server import OSCThreadServer

from goofi.data import Data, DataType
from goofi.node import Node
from goofi.params import IntParam, StringParam


class OSCIn(Node):
def config_params():
return {
"osc": {
"address": StringParam("0.0.0.0"),
"port": IntParam(9000, 0, 65535),
},
"common": {"autotrigger": True},
}

def config_output_slots():
return {"message": DataType.TABLE}

def setup(self):
self.server = OSCThreadServer(advanced_matching=True)
self.server.listen(address=self.params.osc.address.value, port=self.params.osc.port.value, default=True)

# bind to possible addresses of depth 10 (is there a better way to do this?)
for i in range(1, 11):
self.server.bind(b"/*" * i, self.callback, get_address=True)

self.messages = {}

def callback(self, address, *args):
if len(args) > 1:
raise ValueError(
"For now the OSCIn node only support a single argument per message. "
"Please open an issue if you need more (https://github.com/PhilippThoelke/goofi-pipe/issues)."
)

val = args[0]
if isinstance(val, bytes):
val = Data(DataType.STRING, val.decode(), {})
else:
val = Data(DataType.ARRAY, np.array([val]), {})

self.messages[address.decode()] = val

def process(self):
if len(self.messages) == 0:
return None

data = self.messages
meta = {}
self.messages = {}

return {"message": (data, meta)}

def osc_address_changed(self, address):
self.server.stop_all()
self.server.terminate_server()
self.server.join_server()
self.setup()

def osc_port_changed(self, port):
self.server.stop_all()
self.server.terminate_server()
self.server.join_server()
self.setup()
2 changes: 1 addition & 1 deletion src/goofi/nodes/inputs/textgeneration.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def save_conversation_to_json(self):
print(f"Conversation saved to {filename}")

def process(self, prompt: Data):
if prompt.data is None:
if prompt is None or prompt.data is None:
return None

self.import_libs(self.params["text_generation"]["model"].value)
Expand Down
33 changes: 33 additions & 0 deletions src/goofi/nodes/misc/stringawait.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from goofi.data import Data, DataType
from goofi.node import Node
from goofi.params import BoolParam


class StringAwait(Node):
def config_input_slots():
return {"message": DataType.STRING, "trigger": DataType.ARRAY}

def config_output_slots():
return {"out": DataType.STRING}

def config_params():
return {
"string_await": {
"require_change": BoolParam(True, doc="Only output when the message changes, and we have an unconsumed trigger")
}
}

def setup(self):
self.last_message = None

def process(self, message: Data, trigger: Data):
if trigger is None or message is None:
return

if self.params.string_await.require_change.value and self.last_message == message.data:
return

self.input_slots["trigger"].clear()

self.last_message = message.data
return {"out": (message.data, message.meta)}
5 changes: 5 additions & 0 deletions src/goofi/nodes/outputs/audioout.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class AudioOut(Node):
def config_input_slots():
return {"data": DataType.ARRAY}

def config_output_slots():
return {"finished": DataType.ARRAY}

def config_params():
return {
"audio": {
Expand Down Expand Up @@ -65,6 +68,8 @@ def process(self, data: Data):
# Send the audio data to the output device after ensuring it's C-contiguous
self.stream.write(np.ascontiguousarray(samples))

return {"finished": (np.array([1]), {})}

def audio_sampling_rate_changed(self, value):
self.setup()

Expand Down
93 changes: 93 additions & 0 deletions src/goofi/nodes/outputs/midiccout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import threading

from goofi.data import Data, DataType
from goofi.node import Node
from goofi.params import IntParam, StringParam


class MidiCCout(Node):
def config_input_slots():
return {
"cc1": DataType.ARRAY,
"cc2": DataType.ARRAY,
"cc3": DataType.ARRAY,
"cc4": DataType.ARRAY,
"cc5": DataType.ARRAY,
}

def config_output_slots():
return {"midi_status": DataType.STRING}

def config_params():
try:
import mido

available_ports = mido.get_output_names()
except Exception as e:
print(f"Error getting MIDI ports: {e}")
available_ports = []

return {
"MIDI": {
"port_name": StringParam("", options=available_ports, doc="MIDI output port name"),
"channel": IntParam(1, 1, 15, doc="MIDI channel"),
"cc1": IntParam(1, 0, 127, doc="MIDI CC number for input 1"),
"cc2": IntParam(2, 0, 127, doc="MIDI CC number for input 2"),
"cc3": IntParam(3, 0, 127, doc="MIDI CC number for input 3"),
"cc4": IntParam(4, 0, 127, doc="MIDI CC number for input 4"),
"cc5": IntParam(5, 0, 127, doc="MIDI CC number for input 5"),
}
}

def send_cc(self, outport, cc_number, value, channel):
"""Send a MIDI CC message."""
# convert cc_number to int
cc_number = int(cc_number)
value = int(value)
outport.send(self.mido.Message("control_change", control=cc_number, value=value, channel=channel))

def setup(self):
import mido

self.mido = mido

def process(self, cc1: Data, cc2: Data, cc3: Data, cc4: Data, cc5: Data):
port_name = self.params.MIDI["port_name"].value
channel = self.params.MIDI["channel"].value
channel = channel - 1 # MIDI channels are 0-indexed
cc_numbers = [
self.params.MIDI["cc1"].value,
self.params.MIDI["cc2"].value,
self.params.MIDI["cc3"].value,
self.params.MIDI["cc4"].value,
self.params.MIDI["cc5"].value,
]
cc_data = [cc1, cc2, cc3, cc4, cc5]

outport = self.mido.open_output(port_name) if port_name != "goofi" else self.goofi_port

alert_on = False
try:
threads = []
for i, data in enumerate(cc_data):
if data is not None and len(data.data) > 0:
for value in data.data:
if value < 0 or value > 127:
print(f"Value outside of MIDI 0-127 range: {value}")
alert_on = True
error_message = f"Value outside of MIDI 0-127 range: {value}"
pass
t = threading.Thread(target=self.send_cc, args=(outport, cc_numbers[i], value, channel))
t.start()
threads.append(t)

for t in threads:
t.join()
finally:
if port_name != "goofi":
outport.close() # Ensure that the MIDI port is closed when done

if alert_on:
return {"midi_status": (f"CC messages sent with errors\n{error_message}", cc1.meta)}
else:
return {"midi_status": ("CC messages sent successfully", cc1.meta)}
121 changes: 121 additions & 0 deletions src/goofi/nodes/outputs/midiout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import threading
import time

import numpy as np

from goofi.data import Data, DataType
from goofi.node import Node
from goofi.params import BoolParam, FloatParam, IntParam, StringParam


def hz_to_midi(hz):
"""Convert a frequency in Hz to a MIDI note number."""
return int(69 + 12 * np.log2(hz / 440.0))


class MidiOut(Node):
def config_input_slots():
return {"note": DataType.ARRAY, "velocity": DataType.ARRAY, "duration": DataType.ARRAY}

def config_output_slots():
return {"midi_status": DataType.STRING}

def config_params():
try:
import mido

available_ports = mido.get_output_names()
except Exception as e:
print(f"Error getting MIDI ports: {e}")
available_ports = []

return {
"MIDI": {
"port_name": StringParam("", options=available_ports, doc="MIDI output port name"),
"channel": IntParam(0, 0, 15, doc="MIDI channel"),
"play_mode": StringParam("simultaneous", options=["simultaneous", "sequential"], doc="Play mode"),
"default_velocity": IntParam(100, 0, 127, doc="Default MIDI velocity"),
"default_duration": FloatParam(0.1, 0.001, 10, doc="Default note duration in seconds"),
"hz_input": BoolParam(False, doc="Interpret input as Hz"),
}
}

def play_note(self, outport, n, v, d, channel):
"""Thread function to play a note."""
outport.send(self.mido.Message("note_on", note=n, velocity=v, channel=channel))
time.sleep(d)
outport.send(self.mido.Message("note_off", note=n, velocity=0, channel=channel))

def setup(self):
import mido

self.mido = mido

def process(self, note: Data, velocity: Data, duration: Data):
if note is None or len(note.data) == 0:
return None

# Convert Hz to MIDI if required
midi_notes = [hz_to_midi(n) if self.params.MIDI["hz_input"].value else n for n in note.data]

# Set up velocities
if velocity is None:
velocities = [self.params.MIDI["default_velocity"].value] * len(midi_notes)
else:
# Fill in default velocities where necessary
velocities = [int(v) if v is not None else self.params.MIDI["default_velocity"].value for v in velocity.data]

# Set up durations
if duration is None:
durations = [self.params.MIDI["default_duration"].value] * len(midi_notes)
else:
# Fill in default durations where necessary
durations = [float(d) if d is not None else self.params.MIDI["default_duration"].value for d in duration.data]

port_name = self.params.MIDI["port_name"].value
channel = self.params.MIDI["channel"].value
play_mode = self.params.MIDI["play_mode"].value

outport = self.mido.open_output(port_name) # Open the MIDI port once
alert_on = False
try:
if play_mode == "simultaneous":
threads = []
for n, v, d in zip(midi_notes, velocities, durations):
if n > 127 or n < 0:
print("Note outside of MIDI 0-127 range")
alert_on = True
error_message = "Note outside of MIDI 0-127 range"
pass
if v > 127 or v < 0:
print("Velocity outside of MIDI 0-127 range")
alert_on = True
error_message = "Velocity outside of MIDI 0-127 range"
pass
t = threading.Thread(target=self.play_note, args=(outport, n, v, d, channel))
t.start()
threads.append(t)

# Wait for all threads to complete
for t in threads:
t.join()
elif play_mode == "sequential":
for n, v, d in zip(midi_notes, velocities, durations):
if n > 127 or n < 0:
print("Note outside of MIDI 0-127 range")
alert_on = True
error_message = "Note outside of MIDI 0-127 range"
pass
if v > 127 or v < 0:
print("Velocity outside of MIDI 0-127 range")
alert_on = True
error_message = "Velocity outside of MIDI 0-127 range"
pass
self.play_note(outport, n, v, d, channel)
finally:
outport.close() # Ensure that the MIDI port is closed when done

if alert_on:
return {"midi_status": (f"Notes sent successfully\n{error_message}", note.meta)}
else:
return {"midi_status": ("Notes sent successfully", note.meta)}
Loading

0 comments on commit be2b009

Please sign in to comment.