diff --git a/flapi/script/consts.py b/flapi/script/consts.py deleted file mode 100644 index faeba0e..0000000 --- a/flapi/script/consts.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -# Flapi > Consts - -Constants used by Flapi -""" - -VERSION = (0, 4, 1) -""" -The version of Flapi in the format (major, minor, revision) -""" - -TIMEOUT_DURATION = 0.1 -""" -The amount of time to wait for a response before giving an error -""" - - -SYSEX_HEADER = bytes([ - # 0xF0, # Begin sysex (added by Mido) - 0x7D, # Non-commercial use byte number (see - # https://midi.org/specifications/midi1-specifications/midi-1-0-core-specifications) - 0x46, # 'F' - 0x6C, # 'l' - 0x61, # 'a' - 0x70, # 'p' - 0x69, # 'i' -]) -""" -Header for Sysex messages sent by Flapi -""" - -MSG_FROM_CLIENT = 0x00 -""" -Message originates from the Flapi client (library) -""" - -MSG_FROM_SERVER = 0x01 -""" -Message originates from Flapi server (FL Studio) -""" - -MSG_TYPE_HEARTBEAT = 0x00 -""" -Heartbeat message - this is used to check whether FL Studio is running the -script. - -No extra data associated with this message type. -""" - -MSG_TYPE_VERSION_QUERY = 0x01 -""" -Query the server version - this is used to ensure that the server is running -a matching version of Flapi, so that there aren't any bugs with communication. - -## Request data - -No extra data - -## Response data - -3 bytes, each with a version number - -* major -* minor -* release -""" - -MSG_TYPE_EXEC = 0x02 -""" -Exec message - this is used to run an `exec` command in FL Studio, with no -return type (just a success, or an exception raised). - -## Request data - -encoded string: data to execute - -## Response data - -status: MSG_STATUS_OK or MSG_STATUS_ERR, then - -if status is MSG_STATUS_ERR, the `repr()` of the exception is encoded. -Otherwise, there is no other data. -""" - -MSG_TYPE_EVAL = 0x03 -""" -Eval message - this is used to run an `eval` command in FL Studio, where the -value that it produces is returned. - -## Request data - -encoded string: data to execute - -## Response data - -status: MSG_STATUS_OK or MSG_STATUS_ERR, then - -if status is MSG_STATUS_ERR, the `repr()` of the exception is encoded. -Otherwise, the `repr()` of the return value is encoded. -""" - -MSG_TYPE_STDOUT = 0x04 -""" -Message contains text to write into stdout -""" - -MSG_TYPE_EXIT = 0x05 -""" -Message from server instructing client to exit. Used so that we can have a -working `exit` function when using the server-side REPL. - -Associated data is an exit code, as an ASCII-encoded string (since otherwise -certain exit codes could break the MIDI spec) -""" - -MSG_STATUS_OK = 0x00 -""" -Message was processed correctly. -""" - -MSG_STATUS_ERR = 0x01 -""" -Processing of message raised an exception. -""" - -MSG_STATUS_FAIL = 0x02 -""" -The message could not be processed - -The error message is attached in the remaining bytes. -""" - -DEVICE_ENQUIRY_MESSAGE = bytes([ - # 0xF0 - begin sysex (omitted by Mido) - 0x7E, # Universal sysex message - 0x00, # Device ID (assume zero?) - 0x06, # General information - 0x01, # Identity request - # 0xF7 - end sysex (omitted by Mido) -]) -""" -A universal device enquiry message, sent by FL Studio to attempt to identify -the type of the connected device. -""" - -DEVICE_ENQUIRY_RESPONSE = bytes([ - # 0xF0 - begin sysex (omitted by Mido) - 0x7E, # Universal sysex message - 0x00, # Device ID (assume zero) - 0x06, # General information - 0x02, # Identity reply - 0x7D, # Non-commercial use byte number - 0x46, # 'F' - 0x6c, # 'l' - 0x61, # 'a' - 0x70, # 'p' - 0x69, # 'i' - VERSION[0], # Major version - VERSION[1], # Minor version - VERSION[2], # Revision version - # 0xF7 - end sysex (omitted by Mido) -]) - - -DEFAULT_PORT_NAME = "Flapi" -""" -MIDI port to use/create for sending requests to FL Studio -""" - - -FL_MODULES = [ - "playlist", - "channels", - "mixer", - "patterns", - "arrangement", - "ui", - "transport", - "plugins", - "general", -] -""" -Modules we need to decorate within FL Studio -""" diff --git a/flapi/script/device_flapi_server.py b/flapi/script/device_flapi_server.py deleted file mode 100644 index de9a2ef..0000000 --- a/flapi/script/device_flapi_server.py +++ /dev/null @@ -1,250 +0,0 @@ -# name=Flapi Server -# supportedDevices=Flapi -import device -import consts -from capout import Capout -try: - from fl_classes import FlMidiMsg -except ImportError: - pass - - -def format_fn_params(args, kwargs): - args_str = ", ".join(repr(a) for a in args) - kwargs_str = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items()) - - # Overall parameters string (avoid invalid syntax by removing extra - # commas) - return f"{args_str}, {kwargs_str}"\ - .removeprefix(", ")\ - .removesuffix(", ") - - -def log_decorator(fn): - fn_name = fn.__name__ - - def decorator(*args, **kwargs): - capout.fl_print(f"> {fn_name}({format_fn_params(args, kwargs)})") - res = fn(*args, **kwargs) - capout.fl_print(f"< {fn_name}({format_fn_params(args, kwargs)})") - return res - - return decorator - - -def send_stdout(text: str): - """ - Callback for Capout, sending stdout to the client console - """ - send_ok_with_data(consts.MSG_TYPE_STDOUT, text) - - -capout = Capout(send_stdout) -capout.enable() - - -def OnInit(): - capout.fl_print("\n".join([ - "Flapi server", - f"Server version: {'.'.join(str(n) for n in consts.VERSION)}", - f"Device name: {device.getName()}", - f"Device assigned: {bool(device.isAssigned())}", - f"FL Studio port number: {device.getPortNumber()}", - ])) - - -def bytes_to_str(msg: bytes) -> str: - """ - Helper to give a nicer representation of bytes - """ - return f"{repr([hex(i) for i in msg])} ({repr(msg)})" - - -def send_sysex(msg: bytes): - """ - Helper for sending sysex, with some debugging print statements, since this - seems to cause FL Studio to crash a lot of the time, and I want to find out - why - """ - # capout.fl_print(f"MSG OUT -- {bytes_to_str(msg)}") - device.midiOutSysex(msg) - # capout.fl_print("MSG OUT SUCCESS") - - -def send_ok(msg_type: int): - """ - Respond to a message with an OK status - """ - send_sysex( - bytes([0xF0]) - + consts.SYSEX_HEADER - + bytes([ - consts.MSG_FROM_SERVER, - msg_type, - consts.MSG_STATUS_OK, - 0xF7, - ]) - ) - - -def send_ok_with_data(msg_type: int, data: 'str | bytes'): - """ - Respond to a message with an OK status, additionally attaching the given - data. - """ - if isinstance(data, str): - data = data.encode() - - send_sysex( - bytes([0xF0]) - + consts.SYSEX_HEADER - + bytes([ - consts.MSG_FROM_SERVER, - msg_type, - consts.MSG_STATUS_OK, - ]) - + data - + bytes([0xF7]) - ) - - -def send_err(msg_type: int, error: Exception): - """ - Respond to a message with an ERR status - """ - send_sysex( - bytes([0xF0]) - + consts.SYSEX_HEADER - + bytes([ - consts.MSG_FROM_SERVER, - msg_type, - consts.MSG_STATUS_ERR, - ]) - + repr(error).encode() - + bytes([0xF7]) - ) - - -def send_fail(msg_type: int, message: str): - """ - Respond to a message with a FAIL status - """ - send_sysex( - bytes([0xF0]) - + consts.SYSEX_HEADER - + bytes([ - consts.MSG_FROM_SERVER, - msg_type, - consts.MSG_STATUS_FAIL, - ]) - + message.encode() - + bytes([0xF7]) - ) - - -@log_decorator -def heartbeat(): - """ - Received a heartbeat message - """ - return send_ok(consts.MSG_TYPE_HEARTBEAT) - - -@log_decorator -def version_query(): - """ - Return the version of the Flapi server - """ - return send_ok_with_data( - consts.MSG_TYPE_VERSION_QUERY, - bytes(consts.VERSION), - ) - - -@log_decorator -def fl_exec(code: str): - """ - Execute some code - """ - try: - # Exec in global scope so that the imports are remembered - exec(code, globals()) - except Exception as e: - # Something went wrong, give the error - return send_err(consts.MSG_TYPE_EXEC, e) - - # Operation was a success, give response - capout.flush() - return send_ok(consts.MSG_TYPE_EXEC) - - -@log_decorator -def fl_eval(expression: str): - """ - Evaluate an expression - """ - try: - # Eval in the global scope - result = eval(expression, globals()) - except Exception as e: - # Something went wrong, give the error - return send_err(consts.MSG_TYPE_EVAL, e) - - # Operation was a success, give response - capout.flush() - return send_ok_with_data(consts.MSG_TYPE_EVAL, repr(result)) - - -def receive_stdout(text: str): - """ - Receive text from client, and display it in FL Studio's console - """ - capout.fl_print(text, end='') - - -class __ExitCommand: - """ - Exit function - this sends the Flapi client an exit message - """ - def __repr__(self) -> str: - return "'Type `exit()` to quit'" - - def __call__(self, code: int = 0): - send_ok_with_data(consts.MSG_TYPE_EXIT, str(code)) - - -exit = __ExitCommand() - - -# @log_decorator -def OnSysEx(event: 'FlMidiMsg'): - header = event.sysex[1:7] # Sysex header - data = event.sysex[7:-1] # Any remaining sysex data - - # Make sure the header matches the expected header - assert header == consts.SYSEX_HEADER - - message_origin = data[0] - - # Ignore messages from us, to prevent feedback - if message_origin != consts.MSG_FROM_CLIENT: - return - - message_type = data[1] - - if message_type == consts.MSG_TYPE_HEARTBEAT: - return heartbeat() - - if message_type == consts.MSG_TYPE_VERSION_QUERY: - return version_query() - - if message_type == consts.MSG_TYPE_EXEC: - return fl_exec(data[2:].decode()) - - if message_type == consts.MSG_TYPE_EVAL: - return fl_eval(data[2:].decode()) - - if message_type == consts.MSG_TYPE_STDOUT: - return receive_stdout(data[2:].decode()) - - send_fail(message_type, f"Unknown message type {message_type}") diff --git a/flapi/script/capout.py b/flapi/server/capout.py similarity index 100% rename from flapi/script/capout.py rename to flapi/server/capout.py diff --git a/flapi/server/consts.py b/flapi/server/consts.py new file mode 100644 index 0000000..4e25732 --- /dev/null +++ b/flapi/server/consts.py @@ -0,0 +1,174 @@ +""" +# Flapi > Consts + +Constants used by Flapi +""" +from enum import IntEnum + +VERSION = (0, 4, 1) +""" +The version of Flapi in the format (major, minor, revision) +""" + +TIMEOUT_DURATION = 0.1 +""" +The amount of time to wait for a response before giving an error +""" + + +SYSEX_HEADER = bytes([ + # 0xF0, # Begin sysex (added by Mido) + 0x7D, # Non-commercial use byte number (see + # https://midi.org/specifications/midi1-specifications/midi-1-0-core-specifications) + 0x46, # 'F' + 0x6C, # 'l' + 0x61, # 'a' + 0x70, # 'p' + 0x69, # 'i' +]) +""" +Header for Sysex messages sent by Flapi, excluding the `0xF0` status byte +""" + + +class MessageOrigin(IntEnum): + """ + Origin of a Flapi message + """ + CLIENT = 0x00 + """ + Message originates from the Flapi client (library) + """ + + INTERNAL = 0x02 + """ + Message internal to Flapi server (communication between ports in FL Studio) + """ + + SERVER = 0x01 + """ + Message originates from Flapi server (FL Studio) + """ + + +class MessageType(IntEnum): + """ + Type of a Flapi message + """ + + CLIENT_HELLO = 0x00 + """ + Hello message, used to connect to the client + """ + + CLIENT_GOODBYE = 0x01 + """ + Message from server instructing client to exit. Used so that we can have a + working `exit` function when using the server-side REPL, and to cleanly + disconnect from the server. + """ + + SERVER_GOODBYE = 0x02 + """ + Message from server notifying client that it is shutting down. + """ + + VERSION_QUERY = 0x03 + """ + Query the server version - this is used to ensure that the server is + running a matching version of Flapi, so that there aren't any bugs with + communication. + """ + + EXEC = 0x04 + """ + Exec message - this is used to run an `exec` command in FL Studio, with no + return type (just a success, or an exception raised). + """ + + EVAL = 0x05 + """ + Eval message - this is used to run an `eval` command in FL Studio, where + the value that it produces is returned. + """ + + STDOUT = 0x06 + """ + Message contains text to write into stdout. + """ + + +class MessageStatus(IntEnum): + """ + Status of a Flapi message + """ + OK = 0x00 + """ + Message was processed correctly. + """ + + ERR = 0x01 + """ + Processing of message raised an exception. + """ + + FAIL = 0x02 + """ + The message could not be processed + + The error message is attached in the remaining bytes. + """ + + +DEVICE_ENQUIRY_MESSAGE = bytes([ + # 0xF0 - begin sysex (omitted by Mido) + 0x7E, # Universal sysex message + 0x00, # Device ID (assume zero?) + 0x06, # General information + 0x01, # Identity request + # 0xF7 - end sysex (omitted by Mido) +]) +""" +A universal device enquiry message, sent by FL Studio to attempt to identify +the type of the connected device. +""" + +DEVICE_ENQUIRY_RESPONSE = bytes([ + # 0xF0 - begin sysex (omitted by Mido) + 0x7E, # Universal sysex message + 0x00, # Device ID (assume zero) + 0x06, # General information + 0x02, # Identity reply + 0x7D, # Non-commercial use byte number + 0x46, # 'F' + 0x6c, # 'l' + 0x61, # 'a' + 0x70, # 'p' + 0x69, # 'i' + VERSION[0], # Major version + VERSION[1], # Minor version + VERSION[2], # Revision version + # 0xF7 - end sysex (omitted by Mido) +]) + + +DEFAULT_PORT_NAME = "Flapi" +""" +MIDI port to use/create for sending requests to FL Studio +""" + + +FL_MODULES = [ + "playlist", + "channels", + "mixer", + "patterns", + "arrangement", + "ui", + "transport", + "plugins", + "general", +] +""" +Modules we need to decorate within FL Studio +""" diff --git a/flapi/server/device_flapi_receive.py b/flapi/server/device_flapi_receive.py new file mode 100644 index 0000000..965b456 --- /dev/null +++ b/flapi/server/device_flapi_receive.py @@ -0,0 +1,148 @@ +""" +# Flapi / Server / Flapi Receive + +Responsible for receiving request messages from the Flapi Client. + +It attaches to the "Flapi Request" device and handles messages before sending +a response via the "Flapi Respond" script. +""" +# name=Flapi Receive +# supportedDevices=Flapi Request +import logging +import device +from base64 import b64decode +from . import consts +from .consts import MessageStatus, MessageOrigin, MessageType +from .capout import Capout +from .flapi_response import FlapiResponse + +try: + from fl_classes import FlMidiMsg +except ImportError: + pass + + +log = logging.getLogger(__name__) + + +def send_stdout(text: str): + """ + Callback for Capout, sending stdout to the client console + """ + # Target all devices + FlapiResponse(0).stdout(text) + + +capout = Capout(send_stdout) + + +def OnInit(): + print("\n".join([ + "Flapi server", + f"Server version: {'.'.join(str(n) for n in consts.VERSION)}", + f"Device name: {device.getName()}", + f"Device assigned: {bool(device.isAssigned())}", + f"FL Studio port number: {device.getPortNumber()}", + ])) + capout.enable() + + +def OnDeInit(): + capout.disable() + + +connected_clients: set[int] = set() + + +def client_hello(res: FlapiResponse, data: bytes): + if res.client_id in connected_clients: + # Client ID already taken, take no action + log.debug(f"Client tried to connect to in-use ID {res.client_id}") + return + else: + res.client_hello() + connected_clients.add(res.client_id) + log.info(f"Client with ID {res.client_id} connected") + + +def client_goodbye(res: FlapiResponse, data: bytes): + code = int(b64decode(data).decode()) + connected_clients.remove(res.client_id) + log.info( + f"Client with ID {res.client_id} disconnected with code {code}") + res.client_goodbye(code) + + +def version_query(res: FlapiResponse, data: bytes): + res.version_query(consts.VERSION) + + +def fl_exec(res: FlapiResponse, data: bytes): + statement = b64decode(data) + try: + # Exec in global scope so that the imports are remembered + # TODO: Give each client separate global and local scopes + exec(statement, globals()) + except Exception as e: + # Something went wrong, give the error + return res.exec(MessageStatus.ERR, e) + + # Operation was a success, give response + return res.exec(MessageStatus.OK) + + +def fl_eval(res: FlapiResponse, data: bytes): + expression = b64decode(data) + try: + # Exec in global scope so that the imports are remembered + # TODO: Give each client separate global and local scopes + result = eval(expression, globals()) + except Exception as e: + # Something went wrong, give the error + return res.eval(MessageStatus.ERR, e) + + # Operation was a success, give response + return res.eval(MessageStatus.OK, result) + + +def receive_stdout(res: FlapiResponse, data: bytes): + text = b64decode(data).decode() + capout.fl_print(text) + + +message_handlers = { + MessageType.CLIENT_HELLO: client_hello, + MessageType.CLIENT_GOODBYE: client_goodbye, + MessageType.VERSION_QUERY: version_query, + MessageType.EXEC: fl_exec, + MessageType.EVAL: fl_eval, +} + + +def OnSysEx(event: 'FlMidiMsg'): + header = event.sysex[1:len(consts.SYSEX_HEADER)+1] # Sysex header + # Remaining sysex data + sysex_data = event.sysex[len(consts.SYSEX_HEADER)+1:-1] + + # Ignore events that aren't Flapi messages + if header != consts.SYSEX_HEADER: + return + + message_origin = sysex_data[0] + + res = FlapiResponse(sysex_data[1]) + + message_type = MessageType(sysex_data[2]) + + data = sysex_data[3:] + + # Ignore messages from us, to prevent feedback + if message_origin != MessageOrigin.CLIENT: + return + + handler = message_handlers.get(message_type) + + if handler is None: + return res.fail(message_type, f"Unknown message type {message_type}") + + handler(res, data) diff --git a/flapi/server/device_flapi_respond.py b/flapi/server/device_flapi_respond.py new file mode 100644 index 0000000..d592705 --- /dev/null +++ b/flapi/server/device_flapi_respond.py @@ -0,0 +1,52 @@ +""" +# Flapi / Server / Flapi Respond + +Responsible for sending response messages from the Flapi Server within FL +Studio. + +It attaches to the "Flapi Response" device and sends MIDI messages back to the +Flapi client. +""" +# name=Flapi Respond +# supportedDevices=Flapi Response +# receiveFrom=Flapi Receive +import device +from .consts import MessageOrigin, MessageType, SYSEX_HEADER + +try: + from fl_classes import FlMidiMsg +except ImportError: + pass + + +def OnSysEx(msg: 'FlMidiMsg'): + # Ignore events that don't target the respond script + if not msg.sysex.startswith(SYSEX_HEADER): + return + sysex = msg.sysex.removeprefix(SYSEX_HEADER) + + # Check message origin + if sysex[0] != MessageOrigin.INTERNAL: + return + + # Forward message back to client + device.midiOutSysex( + SYSEX_HEADER + + bytes([MessageOrigin.SERVER]) + + sysex[1:] + # + bytes([0xF7]) + ) + + +def OnDeInit(): + """ + Send server goodbye message + """ + device.midiOutSysex( + SYSEX_HEADER + + bytes(MessageOrigin.SERVER) + # Target all clients by giving 0x00 client ID + + bytes([0x00]) + + bytes([MessageType.SERVER_GOODBYE]) + + bytes([0xF7]) + ) diff --git a/flapi/server/flapi_response.py b/flapi/server/flapi_response.py new file mode 100644 index 0000000..cddd394 --- /dev/null +++ b/flapi/server/flapi_response.py @@ -0,0 +1,181 @@ +""" +# Flapi / Server / Flapi Response + +Class representing a MIDI message response in the format used by Flapi. +""" +import device +import pickle +from typing import Any, Literal, overload +from base64 import b64encode, b64decode +from .consts import SYSEX_HEADER, MessageOrigin, MessageType, MessageStatus + + +def send_sysex(msg: bytes): + """ + Helper for sending sysex, with some debugging print statements, since this + seems to cause FL Studio to crash a lot of the time, and I want to find out + why. + """ + # capout.fl_print(f"MSG OUT -- {bytes_to_str(msg)}") + device.midiOutSysex(msg) + # capout.fl_print("MSG OUT SUCCESS") + + +def decode_python_object(data: bytes) -> Any: + """ + Encode Python object to send to the client + """ + return pickle.loads(b64decode(data)) + + +def encode_python_object(object: Any) -> bytes: + """ + Encode Python object to send to the client + """ + return b64encode(pickle.dumps(object)) + + +class FlapiResponse: + """ + Represents a MIDI message sent by Flapi. This class is used to build + responses to requests. + """ + + def __init__(self, client_id: int) -> None: + """ + Create a FlapiResponse + """ + self.client_id = client_id + + def fail(self, type: MessageType, info: str): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([type]) + + bytes([MessageStatus.FAIL]) + + b64encode(info.encode()) + + bytes([0xF7]) + ) + + def client_hello(self): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.CLIENT_HELLO]) + + bytes([0xF7]) + ) + + def client_goodbye(self, exit_code: int): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.CLIENT_GOODBYE]) + + encode_python_object(exit_code) + + bytes([0xF7]) + ) + + # Server goodbye is handled externally in `device_flapi_respond.py` + + def version_query(self, version_info: tuple[int, int, int]): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.VERSION_QUERY]) + + bytes(version_info) + + bytes([0xF7]) + ) + + @overload + def exec(self, status: Literal[MessageStatus.OK]): + ... + + @overload + def exec( + self, + status: Literal[MessageStatus.ERR], + exc_info: Exception, + ): + ... + ... + + @overload + def exec( + self, + status: Literal[MessageStatus.FAIL], + exc_info: str, + ): + ... + + def exec( + self, + status: MessageStatus, + exc_info: Exception | str | None = None, + ): + if status != MessageStatus.OK: + response_data = encode_python_object(exc_info) + else: + response_data = bytes() + + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.EXEC]) + + bytes([status]) + + response_data + + bytes([0xF7]) + ) + + @overload + def eval( + self, + status: Literal[MessageStatus.OK], + data: Any, + ): + ... + + @overload + def eval( + self, + status: Literal[MessageStatus.ERR], + data: Exception, + ): + ... + ... + + @overload + def eval( + self, + status: Literal[MessageStatus.FAIL], + data: str, + ): + ... + + def eval( + self, + status: MessageStatus, + data: Exception | str | Any, + ): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.EVAL]) + + bytes([status]) + + encode_python_object(data) + + bytes([0xF7]) + ) + + def stdout(self, content: str): + send_sysex( + SYSEX_HEADER + + bytes([MessageOrigin.INTERNAL]) + + bytes([self.client_id]) + + bytes([MessageType.STDOUT]) + + encode_python_object(content) + + bytes([0xF7]) + )