├── .github ├── dependabot-disabled.yml └── workflows │ ├── publish.yml │ └── python-app.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── Protocol.md ├── README.md ├── Unmaintained.md ├── assets ├── banner.pdn ├── banner.png ├── icon.pdn ├── icon.png ├── socials.pdn └── socials.png ├── flapi ├── __comms.py ├── __context.py ├── __decorate.py ├── __enable.py ├── __init__.py ├── __main__.py ├── __util.py ├── _consts.py ├── cli │ ├── __init__.py │ ├── consts.py │ ├── install.py │ ├── repl.py │ ├── uninstall.py │ └── util.py ├── client │ ├── __init__.py │ ├── base_client.py │ ├── client.py │ ├── comms.py │ └── ports.py ├── device_flapi_receive.py ├── device_flapi_respond.py ├── errors.py ├── flapi_msg.py ├── py.typed ├── server │ ├── __init__.py │ ├── capout.py │ └── client_context.py └── types │ ├── __init__.py │ ├── message_handler.py │ ├── mido_types.py │ └── scope.py ├── poetry.lock └── pyproject.toml /.github/dependabot-disabled.yml: -------------------------------------------------------------------------------- 1 | # Rename this file to `dependabot.yml` to re-enable Dependabot 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" # See documentation for possible values 6 | directory: "/" # Location of package manifests 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package on PyPi 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | submodules: true 14 | - name: Set up Python 3.12 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.12 18 | 19 | # Install dependencies using Poetry 20 | - uses: Gr1N/setup-poetry@v9 21 | - uses: actions/cache@v4 22 | with: 23 | path: ~/.cache/pypoetry/virtualenvs 24 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 25 | - run: poetry --version 26 | - run: poetry install --all-extras 27 | - name: Build and publish package 28 | run: poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test and Lint 5 | 6 | on: [push] 7 | 8 | jobs: 9 | Flake8: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | submodules: 'recursive' 15 | - name: Set up Python 3.12 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.12 19 | - uses: Gr1N/setup-poetry@v9 20 | - uses: actions/cache@v4 21 | with: 22 | path: ~/.cache/pypoetry/virtualenvs 23 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 24 | - run: poetry --version 25 | - run: poetry install --all-extras 26 | - name: Lint with flake8 27 | run: | 28 | poetry run flake8 29 | 30 | Mypy: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | submodules: 'recursive' 36 | - name: Set up Python 3.12 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: 3.12 40 | - uses: Gr1N/setup-poetry@v9 41 | - uses: actions/cache@v4 42 | with: 43 | path: ~/.cache/pypoetry/virtualenvs 44 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 45 | - run: poetry --version 46 | - run: poetry install --all-extras 47 | - name: Lint with mypy 48 | run: | 49 | poetry run mypy . 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Capout", 4 | "Flapi", 5 | "mido" 6 | ], 7 | "python.analysis.extraPaths": [ 8 | "${workspaceFolder}/src", 9 | "${workspaceFolder}/src/server", 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2023 Maddy Guthridge, dimentorium 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Protocol.md: -------------------------------------------------------------------------------- 1 | # Flapi communication protocol 2 | 3 | The Flapi protocol is a simple protocol for communication between the Flapi 4 | server and Flapi clients. 5 | 6 | Version 2.0 7 | 8 | ## Message format 9 | 10 | Messages are all constructed in the following format: 11 | 12 | | Section | Request/Response | Meaning | 13 | |-----------------------------------------|------------------|---------| 14 | | [Sysex header](#sysex-header) | Both | Begin MIDI system-exclusive message and identify the message type. | 15 | | [Message origin](#message-origin) | Both | Shows the origin of the message. | 16 | | [Client ID](#client-id) | Both | Unique identifier for client. | 17 | | [Continuation byte](#continuation-byte) | Both | Whether this is message contains a continuation (ie is split across multiple messages). | 18 | | [Message type](#message-type) | Both | Type of message being sent. | 19 | | [Status code](#status-code) | Both | Status info about response. | 20 | | Additional data (optional) | Depends | Data is dependent by message type, but often uses a base-64 encoded string. | 21 | | Sysex end byte (`0xF7`) | Both | End of MIDI system-exclusive message. | 22 | 23 | ### Sysex header 24 | 25 | The following values are sent at the start of all messages in order to identify 26 | that the message is associated with Flapi. Messages without this header are 27 | ignored. 28 | 29 | | Byte | Meaning | 30 | |--------|---------| 31 | | `0xF0` | System exclusive message start | 32 | | `0x7D` | Non-commercial use byte number (see the [MIDI 1.0 core specification](https://midi.org/specifications/midi1-specifications/midi-1-0-core-specifications)) | 33 | | `0x46` | `'F'` | 34 | | `0x6C` | `'l'` | 35 | | `0x61` | `'a'` | 36 | | `0x70` | `'p'` | 37 | | `0x69` | `'i'` | 38 | 39 | ### Message origin 40 | 41 | One of the listed values is used. 42 | 43 | | Value | Meaning | 44 | |--------|---------| 45 | | `0x00` | Message originates from client. | 46 | | `0x01` | Message originates from server. | 47 | | `0x02` | Message is internal to server (client should disregard). | 48 | 49 | ### Client ID 50 | 51 | The ID of the client. This should be randomly generated by the client at the 52 | start of a session, and allows multiple clients to connect simultaneously. 53 | Clients should ignore responses targeting a different client ID. 54 | 55 | This should be a random byte, with a value between `0x01` and `0x7F`. 56 | The ID `0x00` is reserved for universal messages, which target all clients. 57 | 58 | Upon receiving a request with a given client ID, the server will give a 59 | response with the same client ID. 60 | 61 | ### Continuation byte 62 | 63 | In Windows, system-exclusive MIDI messages can't have an arbitrary length. 64 | Applications must instead pre-allocate a buffer, which the Windows API writes 65 | into. If the buffer is too small, the message may be discarded. Most 66 | applications only support a buffer size of `1024` bytes. 67 | 68 | To account for this, Flapi messages may be split across multiple MIDI messages. 69 | 70 | If a message is too long, it is split at `1000` bytes, and the continuation 71 | byte is set to `1`. The remaining message is sent as a separate message, which 72 | only contains the [sysex header](#sysex-header), 73 | [message origin](#message-origin), [client ID](#client-id) and continuation 74 | byte. These split messages must be continuous for a single client, and cannot 75 | be interleaved with messages of other types. 76 | 77 | The final MIDI message of a Flapi message should have its continuation byte set 78 | to `0` to indicate the end of the message. 79 | 80 | ### Message type 81 | 82 | The message type byte describes the type of message. The value can be one of 83 | the following values. Other values are reserved for future Flapi versions. 84 | 85 | | Value | Meaning | 86 | |--------|---------| 87 | | `0x00` | [Client hello](#client-hello) | 88 | | `0x01` | [Client goodbye](#client-goodbye) | 89 | | `0x02` | [Server goodbye](#server-goodbye) | 90 | | `0x03` | [Version query](#version-query) | 91 | | `0x04` | [Register message type](#register-message-type) | 92 | | `0x05` | [Exec](#exec) | 93 | | `0x06` | [Stdout](#stdout) | 94 | 95 | Later sections describe each of these message types. 96 | 97 | ### Status code 98 | 99 | The final byte is a status code, which is used to indicate the status of a 100 | response. In requests, this should be set to zero. 101 | 102 | | Code | Meaning | Typical additional data | 103 | |--------|---------|-------------------------| 104 | | `0x00` | Success | Determined by message type. | 105 | | `0x01` | An exception was raised during the operation. | Base-64 encoded string of the error message. | 106 | | `0x02` | The server failed to process the request | Base-64 encoded string of the error message. Default client raises this message as a `FlapiServerError`. | 107 | 108 | ### Client hello 109 | 110 | This message is sent when a client wants to connect to the Flapi server. For 111 | client hello messages, no additional data is present. 112 | 113 | This message type is the only message type to which the server may not respond. 114 | In particular, the server will not respond if that client ID is already in use 115 | by another active client. If a client does not receive a response, it should 116 | generate a new client ID and try again. If the client repeatedly doesn't 117 | receive a response, it is likely that FL Studio isn't running (or all device 118 | IDs are taken). 119 | 120 | If the connection is accepted, the server will give an empty response targeted 121 | to the newly connected client. This means the server has registered that client 122 | ID session. 123 | 124 | ### Client goodbye 125 | 126 | This message is sent when a client is exiting, and wants to disconnect from the 127 | Flapi server. As additional data, it contains an `int` exit code, represented 128 | as as base-64 string to prevent illegal values for high exit codes. 129 | 130 | ```py 131 | from base64 import b64encode, b64decode 132 | 133 | code = 130 134 | 135 | # Encode 136 | encoded = b64encode(str(code).encode()) 137 | 138 | # Decode 139 | decoded = int(b64decode(data).decode()) 140 | 141 | assert code == decoded 142 | ``` 143 | 144 | The server will respond with an identical reply. The default Flapi client 145 | (provided by this library) raises a `FlapiClientExit` exception when this 146 | happens, which will (by default) cause the program to exit with the given exit 147 | code. 148 | 149 | ### Server goodbye 150 | 151 | This message should never be sent by clients. Instead, it is sent by the server 152 | when FL Studio is exiting. The default Flapi client raises a `FlapiServerExit` 153 | exception when this happens, which (by default) is not handled. 154 | 155 | ### Version query 156 | 157 | This message is sent by clients to query the version of the Flapi server. 158 | 159 | The server will respond with 3 bytes as the message data: 160 | `(major, minor, revision)`. 161 | 162 | ### Register message type 163 | 164 | This message is sent by clients to register a new type of message. This message 165 | type is handled by the server by setting up a new handler for a message. 166 | 167 | This can be used to inject code into the Flapi server, to allow for clients to 168 | gain deeper control over FL Studio, and to save on repeated operations. 169 | 170 | The default client uses this functionality to register a `pickle-eval` message to 171 | evaluate data and encode the results using Pickle. 172 | 173 | Note that the server should not share message handlers between clients. 174 | 175 | #### Interface for message handlers 176 | 177 | Message handler functions should match the given interface: 178 | 179 | ```py 180 | def message_handler( 181 | client_id: int, 182 | status_code: int, 183 | msg_data: Optional[bytes], 184 | scope: dict[str, Any], 185 | ) -> int | tuple[int, bytes]: 186 | ... 187 | ``` 188 | 189 | Where: 190 | 191 | * `client_id` is the ID of the client that sent the request 192 | * `status_code` is the incoming status code. 193 | * `msg_data` is the full message data, if provided. Otherwise, the message data 194 | is `None`. Note that if the data was split across multiple messages, it will 195 | be joined before calling this function. 196 | * `scope` is a dictionary containing a local scope that should be used when 197 | executing arbitrary code. 198 | * Return type is `int` for status code, and optionally `bytes` for response 199 | data. Note that if the response needs to be split across multiple messages, 200 | this will be handled internally by the server. 201 | 202 | #### Register message type request data 203 | 204 | A base-64 string containing the definition of the message handler function. 205 | 206 | #### Register message type response data 207 | 208 | A single byte, the message type number that should be used to communicate with 209 | this message hander. 210 | 211 | ### Exec 212 | 213 | This message is sent by clients, and instructs the server to `exec` code inside 214 | FL Studio. 215 | 216 | #### Exec request data 217 | 218 | A base-64 string containing the code to execute. 219 | 220 | #### Exec response data 221 | 222 | No data is given on success. In order to get return data, clients should 223 | register an `eval` message type handler. 224 | 225 | ### Stdout 226 | 227 | This message is sent from the server to the client to notify it of `stdout` 228 | originating from FL Studio's console. This can be sent at any time, and so 229 | clients should be prepared to handle it at any point when receiving a message. 230 | 231 | Additional data is a base-64 string of the content written to `stdout`. 232 | 233 | When sent from the client, it is printed to FL Studio's console, and no 234 | response is given. 235 | 236 | ## Example 237 | 238 | To help clarify, here is an example demonstrating the basic functionality of 239 | the protocol. 240 | 241 | Messages are shown as a list of bytes in hexadecimal, with spaces used to 242 | show different sections of the message. 243 | 244 | | Origin | Message | Meaning | 245 | |--------|--------------------------------------------------------------|---------| 246 | | Client | `F07D466C617069` `00` `01` `00` `00` | Client requests to connect using client ID `01` | 247 | | Server | `F07D466C617069` `01` `01` `00` `00` | Server responds, accepting the connection | 248 | | Client | `F07D466C617069` `00` `01` `00` `03` | Client requests server version information | 249 | | Server | `F07D466C617069` `01` `01` `00` `03` `010000` | Server responds, saying it is using version `1.0.0` | 250 | | Client | `F07D466C617069` `00` `01` `00` `04` `61573...97964413D3D` | Client requests to execute `import transport` | 251 | | Server | `F07D466C617069` `01` `01` `00` `04` `00` | Server responds, indicating success | 252 | | Client | `F07D466C617069` `00` `01` `00` `04` `64484...A304B436B3D` | Client requests to execute `transport.start()` | 253 | | Server | `F07D466C617069` `01` `01` `00` `04` `00` | Server responds, indicating success | 254 | | Client | `F07D466C617069` `00` `01` `00` `01` `6741524C4143343D` | Client disconnects with a code of `0` | 255 | | Server | `F07D466C617069` `01` `01` `00` `01` `6741524C4143343D` | Server acknowledges disconnect | 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Flapi](https://raw.githubusercontent.com/MaddyGuthridge/Flapi/main/assets/banner.png) 2 | 3 | Flapi (pron. *"flappy"*) is a remote control server for FL Studio, using the 4 | MIDI Controller Scripting API. It allows you to execute Python code in FL 5 | Studio from outside of FL Studio by modifying functions in the 6 | [FL Studio API Stubs package](https://github.com/IL-Group/FL-Studio-API-Stubs) 7 | to forward their calling information to the Flapi server, where they are 8 | executed, with their values returned to the client. 9 | 10 | ```py 11 | $ flapi 12 | >>> import ui 13 | >>> ui.setHintMsg("Hello from Flapi!") 14 | # Hint message "Hello from Flapi!" is displayed in FL Studio's hint panel 15 | ``` 16 | 17 | ## Maintenance 18 | 19 | This project is currently unmaintained, with the latest code on the `main` 20 | branch being half-way through a refactor. To learn more about this decision, 21 | please have a read of [Unmaintained.md](./Unmaintained.md). Thank you for 22 | understanding. 23 | 24 | ## Setup 25 | 26 | 1. Install the Flapi library using Pip, or any package manager of your choice. 27 | `pip install flapi` 28 | 29 | 2. Install the Flapi server to FL Studio by running `flapi install`. If you 30 | have changed your FL Studio user data folder, you will need to enter it. 31 | 32 | 3. On Windows, install a virtual MIDI loopback tool such as 33 | [loopMIDI](https://www.tobias-erichsen.de/software/loopmidi.html) and use it 34 | to create two virtual MIDI ports, named `Flapi Request` and 35 | `Flapi Response`. On MacOS, Flapi is able to create these MIDI ports 36 | automatically, so this step is not required. 37 | 38 | 4. Start or restart FL Studio. The server should be loaded automatically, but 39 | if not, you may need to set it up in FL Studio's MIDI settings. To do this, 40 | set each MIDI port to have a unique port number in the outputs section, 41 | configure the input ports to match the port numbers of their corresponding 42 | output ports, then assign the "Flapi Request" port to the "Flapi Request" 43 | script and the "Flapi Response" port to the "Flapi Response" script. 44 | 45 | ## Usage 46 | 47 | ### As a library 48 | 49 | ```py 50 | import flapi 51 | 52 | # Enable flapi 53 | flapi.enable() 54 | 55 | # Now all calls to functions in FL Studio's MIDI Controller Scripting API will 56 | # be forwarded to FL Studio to be executed. 57 | ``` 58 | 59 | ### As a REPL 60 | 61 | ```py 62 | $ flapi 63 | >>> import transport 64 | >>> transport.start() 65 | # FL Studio starts playback 66 | >>> transport.stop() 67 | # FL Studio stops playback 68 | ``` 69 | 70 | #### Server-side execution 71 | 72 | Flapi also supports a bare-bones server-side REPL, where all input is executed 73 | within FL Studio (as opposed to forwarding function data). 74 | 75 | ```py 76 | $ flapi -s server 77 | >>> import sys 78 | >>> sys.version 79 | '3.12.1 (tags/v3.12.1:2305ca5, Dec 7 2023, 22:03:25) [MSC v.1937 64 bit (AMD64)]' 80 | # It's running inside FL Studio! 81 | >>> print("Hello") 82 | Hello 83 | # Stdout is redirected back to the client too! 84 | ``` 85 | 86 | ## Credits 87 | 88 | This concept was originally created by 89 | [dimentorium](https://github.com/dimentorium) and is available on GitHub at 90 | [dimentorium/Flappy](https://github.com/dimentorium/Flappy). I have adapted 91 | their code to improve its usability, and make it easier to install. 92 | -------------------------------------------------------------------------------- /Unmaintained.md: -------------------------------------------------------------------------------- 1 | # Flapi is currently unmaintained 2 | 3 | Hello, Flapi user! 4 | 5 | Unfortunately, I currently do not have the time or energy to develop or 6 | maintain Flapi. This is due to a multitude of reasons: 7 | 8 | * The Windows MIDI API has a limitation on the length of sysex (system 9 | exclusive) messages, meaning that messages larger than a pre-defined size 10 | silently fail to send, and can even crash FL Studio due to buffer overflow\* 11 | errors. I started working implementing a chunking system to split a Flapi 12 | message over multiple MIDI messages, but never completed this work due to its 13 | complexity. 14 | * In general, FL Studio’s Python API is pretty difficult to work with, with 15 | many frustrating bugs and crashes. This made it difficult to find the 16 | motivation to maintain the project. 17 | * I have [many other projects](https://maddyguthridge.com/portfolio/projects) 18 | which I have been busy developing and maintaining, and so developing Flapi 19 | has taken a bit of a back seat. I developed it entirely in my spare time for 20 | free, and I want to spend my spare time on projects that I have motivation 21 | and energy for. 22 | 23 | \*I believe this is now fixed, so hopefully won't be a security issue in 24 | current FL Studio versions. 25 | 26 | As such, I consider Flapi to be unmaintained. Fortunately, Flapi is (and will 27 | continue to be) free and open-source software, using the 28 | [MIT license](./LICENSE.md). As such, I encourage you to create a fork with 29 | improvements and fixes. If you do this, please let me know so I can help share 30 | your hard work, by: 31 | 32 | * Updating this repo's readme to point to your fork, or 33 | * Merging your improvements back into this repo, or even 34 | * Transferring ownership of [the `flapi` project on Pypi](https://pypi.org/project/flapi/) 35 | so you can publish updates to the software (if I trust you, and feel that the 36 | project is in good hands). 37 | 38 | If you do choose to continue development of Flapi, here are the things that 39 | need to be done. 40 | 41 | * The chunking system for messages is mostly implemented, at least in the 42 | client, but is currently untested. 43 | * The server code currently doesn't implement some message types, especially 44 | for `Register message type` messages. 45 | * You'll probably also need to refactor the majority of the server. 46 | * Perhaps you can create some bindings to Microsoft's new MIDI API to handle 47 | the creation of virtual MIDI ports on Windows so that the painful setup with 48 | LoopMIDI isn't required anymore. 49 | 50 | Thankfully, the protocol for you to implement is fully documented in 51 | [`Protocol.md`](Protocol.md), so the challenging design work should be out of 52 | the way. If any of the protocol is unclear, I'm happy to explain it in greater 53 | detail -- just open an issue. 54 | 55 | Thanks for all of your support of Flapi and my many other software projects. 56 | 57 | Maddy 58 | -------------------------------------------------------------------------------- /assets/banner.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/banner.pdn -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/banner.png -------------------------------------------------------------------------------- /assets/icon.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/icon.pdn -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/icon.png -------------------------------------------------------------------------------- /assets/socials.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/socials.pdn -------------------------------------------------------------------------------- /assets/socials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/assets/socials.png -------------------------------------------------------------------------------- /flapi/__comms.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Comms 3 | 4 | Code for communicating events to FL Studio. 5 | 6 | For data model, see Protocol.md in the project root directory. 7 | """ 8 | import time 9 | import logging 10 | from base64 import b64decode, b64encode 11 | from mido import Message as MidoMsg # type: ignore 12 | from typing import Any, Optional 13 | from .__util import decode_python_object 14 | from .__context import get_context 15 | from flapi import _consts as consts 16 | from flapi._consts import MessageOrigin, MessageStatus, MessageType 17 | from .errors import ( 18 | FlapiTimeoutError, 19 | FlapiInvalidMsgError, 20 | FlapiServerError, 21 | FlapiClientError, 22 | FlapiServerExit, 23 | FlapiClientExit, 24 | ) 25 | 26 | 27 | log = logging.getLogger(__name__) 28 | 29 | 30 | def send_msg(msg: bytes): 31 | """ 32 | Send a message to FL Studio 33 | """ 34 | mido_msg = MidoMsg("sysex", data=msg) 35 | get_context().req_port.send(mido_msg) 36 | 37 | 38 | def handle_stdout(output: str): 39 | print(output, end='') 40 | 41 | 42 | def handle_received_message(msg: bytes) -> Optional[bytes]: 43 | """ 44 | Handling of some received MIDI messages. If the event is a response to an 45 | event we sent, it is returned. Otherwise, it is processed here, and `None` 46 | is returned instead. 47 | """ 48 | # Handle universal device enquiry 49 | if msg == consts.DEVICE_ENQUIRY_MESSAGE: 50 | # Send the response 51 | log.debug('Received universal device enquiry') 52 | send_msg(consts.DEVICE_ENQUIRY_RESPONSE) 53 | return None 54 | 55 | # Handle invalid message types 56 | if not msg.startswith(consts.SYSEX_HEADER): 57 | log.debug('Received unrecognised message') 58 | raise FlapiInvalidMsgError(msg) 59 | 60 | remaining_msg = msg.removeprefix(consts.SYSEX_HEADER) 61 | 62 | # Handle loopback (prevent us from receiving our own messages) 63 | if remaining_msg[0] != MessageOrigin.SERVER: 64 | return None 65 | 66 | # Handle other clients (prevent us from receiving their messages) 67 | # We still accept client ID zero, since it targets all devices 68 | if remaining_msg[1] not in [0, get_context().client_id]: 69 | return None 70 | 71 | # Handle FL Studio stdout 72 | if remaining_msg[2] == MessageType.STDOUT: 73 | text = b64decode(remaining_msg[3:]).decode() 74 | log.debug(f"Received server stdout: {text}") 75 | handle_stdout(text) 76 | return None 77 | 78 | # Handle exit command 79 | if remaining_msg[2] == MessageType.CLIENT_GOODBYE: 80 | code = int(b64decode(remaining_msg[3:]).decode()) 81 | log.info(f"Received exit command with code {code}") 82 | raise FlapiClientExit(code) 83 | 84 | # Handle server disconnect 85 | if remaining_msg[2] == MessageType.SERVER_GOODBYE: 86 | raise FlapiServerExit() 87 | 88 | # Normal processing (remove bytes for header, origin and client ID) 89 | return remaining_msg[2:] 90 | 91 | 92 | def assert_response_is_ok(msg: bytes, expected_msg_type: MessageType): 93 | """ 94 | Ensure the message type is correct, and handle the message status 95 | 96 | * MSG_STATUS_OK: take no action 97 | * MSG_STATUS_ERR: raise the exception 98 | * MSG_STATUS_FAIL: raise an exception describing the failure 99 | """ 100 | msg_type = MessageType(msg[0]) 101 | 102 | if msg_type != expected_msg_type: 103 | expected = expected_msg_type 104 | actual = msg_type 105 | raise FlapiClientError( 106 | f"Expected message type '{expected}', received '{actual}'") 107 | 108 | msg_status = msg[1] 109 | 110 | if msg_status == MessageStatus.OK: 111 | return 112 | elif msg_status == MessageStatus.ERR: 113 | raise decode_python_object(msg[2:]) 114 | elif msg_status == MessageStatus.FAIL: 115 | raise FlapiServerError(b64decode(msg[2:]).decode()) 116 | 117 | 118 | def poll_for_message() -> Optional[bytes]: 119 | """ 120 | Poll for new MIDI messages from FL Studio 121 | """ 122 | ctx = get_context() 123 | if (msg := ctx.res_port.receive(block=False)) is not None: 124 | # If there was a message, do pre-handling of message 125 | # Make sure to remove the start and end bits to simplify processing 126 | msg = handle_received_message(bytes(msg.bytes()[1:-1])) 127 | # If it is None, this message wasn't a response message, try to get 128 | # another one just in case there is one 129 | if msg is None: 130 | return poll_for_message() 131 | return msg 132 | 133 | 134 | def receive_message() -> bytes: 135 | """ 136 | Receive a MIDI message from FL Studio. 137 | 138 | This busy waits until a message is received within the timeout window. 139 | 140 | ## Raises 141 | * `TimeoutError`: a message was not received within the timeout window 142 | """ 143 | start_time = time.time() 144 | 145 | while time.time() < start_time + consts.TIMEOUT_DURATION: 146 | # Busy wait for a message 147 | if (msg := poll_for_message()) is not None: 148 | return msg 149 | 150 | raise FlapiTimeoutError( 151 | "Flapi didn't receive a message within the timeout window. Is FL " 152 | "Studio running?" 153 | ) 154 | 155 | 156 | def hello() -> bool: 157 | """ 158 | Send a "client hello" message to FL Studio to attempt to establish a 159 | connection. 160 | """ 161 | client_id = get_context().client_id 162 | log.debug(f"Attempt hello with {client_id=}") 163 | assert client_id is not None 164 | start = time.time() 165 | try: 166 | send_msg(consts.SYSEX_HEADER + bytes([ 167 | MessageOrigin.CLIENT, 168 | client_id, 169 | MessageType.CLIENT_HELLO, 170 | ])) 171 | response = receive_message() 172 | assert_response_is_ok(response, MessageType.CLIENT_HELLO) 173 | end = time.time() 174 | log.debug(f"heartbeat: passed in {end - start:.3} seconds") 175 | return True 176 | except FlapiTimeoutError: 177 | log.debug("heartbeat: failed") 178 | return False 179 | 180 | 181 | def client_goodbye(code: int) -> None: 182 | """ 183 | Send a "client goodbye" message to FL Studio to close the connection. 184 | """ 185 | client_id = get_context().client_id 186 | log.debug(f"Attempt hello with {client_id=}") 187 | assert client_id is not None 188 | send_msg( 189 | consts.SYSEX_HEADER 190 | + bytes([ 191 | MessageOrigin.CLIENT, 192 | client_id, 193 | MessageType.CLIENT_GOODBYE, 194 | ]) 195 | + b64encode(str(code).encode()) 196 | ) 197 | try: 198 | res = receive_message() 199 | # We should never reach this point, as receiving the message should 200 | # have raised a SystemExit 201 | log.critical( 202 | f"Failed to SystemExit -- instead received message {res.decode()}" 203 | ) 204 | assert False 205 | except FlapiClientExit: 206 | return 207 | 208 | 209 | def version_query() -> tuple[int, int, int]: 210 | """ 211 | Query and return the version of Flapi installed to FL Studio. 212 | """ 213 | client_id = get_context().client_id 214 | assert client_id is not None 215 | log.debug("version_query") 216 | send_msg( 217 | consts.SYSEX_HEADER 218 | + bytes([MessageOrigin.CLIENT, client_id, MessageType.VERSION_QUERY]) 219 | ) 220 | response = receive_message() 221 | log.debug("version_query: got response") 222 | 223 | assert_response_is_ok(response, MessageType.VERSION_QUERY) 224 | 225 | # major, minor, revision 226 | version = response[2:] 227 | assert len(version) == 3 228 | 229 | return (version[0], version[1], version[2]) 230 | 231 | 232 | def fl_exec(code: str) -> None: 233 | """ 234 | Output Python code to FL Studio, where it will be executed. 235 | """ 236 | client_id = get_context().client_id 237 | assert client_id is not None 238 | log.debug(f"fl_exec: {code}") 239 | send_msg( 240 | consts.SYSEX_HEADER 241 | + bytes([MessageOrigin.CLIENT, client_id, MessageType.EXEC]) 242 | + b64encode(code.encode()) 243 | ) 244 | response = receive_message() 245 | log.debug("fl_exec: got response") 246 | 247 | assert_response_is_ok(response, MessageType.EXEC) 248 | 249 | 250 | def fl_eval(expression: str) -> Any: 251 | """ 252 | Output a Python expression to FL Studio, where it will be evaluated, with 253 | the result being returned. 254 | """ 255 | client_id = get_context().client_id 256 | assert client_id is not None 257 | log.debug(f"fl_eval: {expression}") 258 | send_msg( 259 | consts.SYSEX_HEADER 260 | + bytes([MessageOrigin.CLIENT, client_id, MessageType.EVAL]) 261 | + b64encode(expression.encode()) 262 | ) 263 | response = receive_message() 264 | log.debug("fl_eval: got response") 265 | 266 | assert_response_is_ok(response, MessageType.EVAL) 267 | 268 | # Value is ok, eval and return it 269 | return decode_python_object(response[2:]) 270 | 271 | 272 | def fl_print(text: str): 273 | """ 274 | Print the given text to FL Studio's Python console. 275 | """ 276 | client_id = get_context().client_id 277 | assert client_id is not None 278 | log.debug(f"fl_print (not expecting response): {text}") 279 | send_msg( 280 | consts.SYSEX_HEADER 281 | + bytes([MessageOrigin.CLIENT, client_id, MessageType.STDOUT]) 282 | + b64encode(text.encode()) 283 | ) 284 | -------------------------------------------------------------------------------- /flapi/__context.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Context 3 | 4 | Code for keeping track of the Flapi context, so that commands can be forwarded 5 | to the FL Studio API correctly. 6 | """ 7 | from dataclasses import dataclass 8 | from mido.ports import BaseIOPort # type: ignore 9 | from typing import Optional, TYPE_CHECKING 10 | from flapi.errors import FlapiContextError 11 | if TYPE_CHECKING: 12 | from flapi.__decorate import ApiCopyType 13 | 14 | 15 | @dataclass 16 | class FlapiContext: 17 | req_port: BaseIOPort 18 | """ 19 | The Mido port that Flapi uses to send requests to FL Studio 20 | """ 21 | 22 | res_port: BaseIOPort 23 | """ 24 | The Mido port that Flapi uses to receive responses from FL Studio 25 | """ 26 | 27 | functions_backup: 'ApiCopyType' 28 | """ 29 | References to all the functions we replaced in the FL Studio API, so that 30 | we can set them back as required. 31 | """ 32 | 33 | client_id: Optional[int] 34 | """ 35 | Unique client ID for this instance of the Flapi client 36 | """ 37 | 38 | 39 | context: Optional[FlapiContext] = None 40 | """ 41 | The current context for Flapi 42 | """ 43 | 44 | 45 | def set_context(new_context: FlapiContext): 46 | """ 47 | Set the context for Flapi 48 | """ 49 | global context 50 | context = new_context 51 | 52 | 53 | def get_context() -> FlapiContext: 54 | """ 55 | Get a reference to the Flapi context 56 | """ 57 | if context is None: 58 | raise FlapiContextError() 59 | return context 60 | 61 | 62 | def pop_context() -> FlapiContext: 63 | """ 64 | Clear the Flapi context, returning its value so that clean-up can be 65 | performed 66 | """ 67 | global context 68 | if context is None: 69 | raise FlapiContextError() 70 | ret = context 71 | context = None 72 | return ret 73 | -------------------------------------------------------------------------------- /flapi/__decorate.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Decorate 3 | 4 | Code for decorating the FL Studio API libraries to enable Flapi 5 | """ 6 | import logging 7 | import inspect 8 | import importlib 9 | from types import FunctionType 10 | from typing import Callable, TypeVar 11 | from typing_extensions import ParamSpec 12 | from functools import wraps 13 | from .__comms import fl_eval 14 | from ._consts import FL_MODULES 15 | from .__util import format_fn_params 16 | 17 | P = ParamSpec('P') 18 | R = TypeVar('R') 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | ApiCopyType = dict[str, dict[str, FunctionType]] 24 | 25 | 26 | def decorate( 27 | module: str, 28 | func_name: str, 29 | func: Callable[P, R], 30 | ) -> Callable[P, R]: 31 | """ 32 | Create a decorator function that wraps the given function, returning the 33 | new function. 34 | """ 35 | @wraps(func) 36 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 37 | params = format_fn_params(args, kwargs) 38 | 39 | return fl_eval(f"{module}.{func_name}({params})") 40 | 41 | return wrapper 42 | 43 | 44 | def add_wrappers() -> ApiCopyType: 45 | """ 46 | For each FL Studio module, replace its items with a decorated version that 47 | evaluates the function inside FL Studio. 48 | """ 49 | log.info("Adding wrappers to API stubs") 50 | 51 | modules: ApiCopyType = {} 52 | for mod_name in FL_MODULES: 53 | modules[mod_name] = {} 54 | 55 | mod = importlib.import_module(mod_name) 56 | # For each function within the module 57 | for func_name, func in inspect.getmembers(mod, inspect.isfunction): 58 | # Decorate it 59 | decorated_func = decorate(mod_name, func_name, func) 60 | # Store the original into the dictionary 61 | modules[mod_name][func_name] = func 62 | # Then replace it with our decorated version 63 | setattr(mod, func_name, decorated_func) 64 | 65 | return modules 66 | 67 | 68 | def restore_original_functions(backup: ApiCopyType): 69 | """ 70 | Restore the original FL Studio API Stubs functions - called when 71 | deactivating Flapi. 72 | """ 73 | log.info("Removing wrappers from API stubs") 74 | for mod_name, functions in backup.items(): 75 | mod = importlib.import_module(mod_name) 76 | 77 | # For each function within the module 78 | for func_name, og_func in functions.items(): 79 | # Replace it with the original version 80 | setattr(mod, func_name, og_func) 81 | -------------------------------------------------------------------------------- /flapi/__enable.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Initialize 3 | 4 | Code for initializing/closing Flapi 5 | """ 6 | import logging 7 | import random 8 | import mido # type: ignore 9 | from typing import Protocol, Generic, TypeVar, Optional 10 | from mido.ports import BaseOutput, BaseInput # type: ignore 11 | from . import _consts as consts 12 | from .__context import set_context, get_context, pop_context, FlapiContext 13 | from .__comms import ( 14 | fl_exec, 15 | hello, 16 | version_query, 17 | poll_for_message, 18 | client_goodbye, 19 | ) 20 | from .__decorate import restore_original_functions, add_wrappers 21 | from .errors import FlapiPortError, FlapiConnectionError, FlapiVersionError 22 | 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | T = TypeVar('T', BaseInput, BaseOutput, covariant=True) 28 | 29 | 30 | class OpenPortFn(Protocol, Generic[T]): 31 | """Function that opens a Mido port""" 32 | 33 | def __call__(self, *, name: str, virtual: bool = False) -> T: 34 | ... 35 | 36 | 37 | def open_port( 38 | port_name: str, 39 | port_names: list[str], 40 | open: OpenPortFn[T], 41 | ) -> Optional[T]: 42 | """ 43 | Connect to a port which matches the given name, and if one cannot be found, 44 | attempt to create it 45 | """ 46 | for curr_port_name in port_names: # type: ignore 47 | # If the only thing after it is a number, we are free to connect to it 48 | # It seems that something appends these numbers to each MIDI device to 49 | # make them more unique or something 50 | if port_name.lower() not in curr_port_name.lower(): 51 | continue 52 | try: 53 | # If this works, it's a match 54 | int(curr_port_name.replace(port_name, '').strip()) 55 | except Exception: 56 | continue 57 | 58 | # Connect to it 59 | return open(name=curr_port_name) # type: ignore 60 | 61 | # If we reach this point, no match was found 62 | return None 63 | 64 | 65 | def enable( 66 | req_port: str = consts.DEFAULT_REQ_PORT, 67 | res_port: str = consts.DEFAULT_RES_PORT, 68 | ) -> bool: 69 | """ 70 | Enable Flapi, connecting it to the given MIDI ports 71 | 72 | 1. Attempt to connect to the port names provided 73 | 2. Decorate the API functions 74 | 3. Attempt to initialize the connection by running setup commands in FL 75 | Studio. 76 | 77 | ## Returns: 78 | 79 | * `bool`: whether the initialization was a success. If this is `False`, you 80 | will need to call `init()` once FL Studio is running and configured 81 | correctly. 82 | """ 83 | log.info(f"Enable Flapi client on ports '{req_port}', '{res_port}'") 84 | # First, connect to all the MIDI ports 85 | res_ports = mido.get_input_names() # type: ignore 86 | req_ports = mido.get_output_names() # type: ignore 87 | 88 | log.info(f"Available request ports are: {req_ports}") 89 | log.info(f"Available response ports are: {res_ports}") 90 | 91 | try: 92 | res = open_port(res_port, res_ports, mido.open_input) # type: ignore 93 | except Exception: 94 | log.exception("Error when connecting to input") 95 | raise 96 | try: 97 | req = open_port(req_port, req_ports, mido.open_output) # type: ignore 98 | except Exception: 99 | log.exception("Error when connecting to output") 100 | raise 101 | 102 | if res is None or req is None: 103 | try: 104 | req = mido.open_output( # type: ignore 105 | name=req_port, 106 | virtual=True, 107 | ) 108 | res = mido.open_input( # type: ignore 109 | name=res_port, 110 | virtual=True, 111 | ) 112 | except NotImplementedError as e: 113 | # Port could not be opened 114 | log.exception("Could not open create new port") 115 | raise FlapiPortError((req_port, res_port)) from e 116 | 117 | # Now decorate all of the API functions 118 | functions_backup = add_wrappers() 119 | 120 | # Register the context 121 | set_context(FlapiContext(req, res, functions_backup, None)) 122 | 123 | return try_init(random.randrange(1, 0x7F)) 124 | 125 | 126 | def init(client_id: int): 127 | """ 128 | Initialize Flapi, so that it can send commands to FL Studio. 129 | """ 130 | if not try_init(client_id): 131 | raise FlapiConnectionError( 132 | "FL Studio did not connect to Flapi - is it running?") 133 | 134 | 135 | def try_init(client_id: int) -> bool: 136 | """ 137 | Attempt to initialize Flapi, returning whether the operation was a success. 138 | """ 139 | assert get_context().client_id is None 140 | get_context().client_id = client_id 141 | # Poll for any new messages from FL Studio and handle them as required 142 | poll_for_message() 143 | # Attempt to send a heartbeat message - if we get a response, we're already 144 | # connected 145 | if hello(): 146 | setup_server() 147 | return True 148 | else: 149 | get_context().client_id = None 150 | return False 151 | 152 | 153 | def setup_server(): 154 | """ 155 | Perform the required setup on the server side, importing modules, and the 156 | like. 157 | """ 158 | # Make sure the versions are correct 159 | version_check() 160 | 161 | # Finally, import all of the required modules in FL Studio 162 | fl_exec(f"import {', '.join(consts.FL_MODULES)}") 163 | 164 | log.info("Server initialization succeeded") 165 | 166 | 167 | def version_check(): 168 | """ 169 | Ensure that the server version matches the client version. 170 | 171 | If not, raise an exception. 172 | """ 173 | server_version = version_query() 174 | client_version = consts.VERSION 175 | 176 | if server_version < client_version: 177 | raise FlapiVersionError( 178 | f"Server version {server_version} does not match client version " 179 | f"{client_version}. Please update the server by running " 180 | f"`flapi install`" 181 | ) 182 | if client_version < server_version: 183 | raise FlapiVersionError( 184 | f"Server version {server_version} does not match client version " 185 | f"{client_version}. Please update the client using your Python " 186 | f"package manager. If you are using pip, run " 187 | f"`pip install --upgrade flapi`." 188 | ) 189 | # If we reach this point, the versions match 190 | 191 | 192 | def disable(code: int = 0): 193 | """ 194 | Disable Flapi, closing its MIDI ports and its connection to FL Studio. 195 | 196 | This restores the original functions for the FL Studio API. 197 | 198 | ## Args 199 | 200 | * `code` (`int`, optional): the exit code to relay to the server. Defaults 201 | to `0`. 202 | """ 203 | # Send a client goodbye 204 | client_goodbye(code) 205 | 206 | # Close all the ports 207 | ctx = pop_context() 208 | ctx.req_port.close() 209 | ctx.res_port.close() 210 | 211 | # Then restore the functions 212 | restore_original_functions(ctx.functions_backup) 213 | -------------------------------------------------------------------------------- /flapi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi 3 | 4 | Remotely control FL Studio using the MIDI Controller Scripting API. 5 | 6 | ```py 7 | >>> import flapi 8 | >>> flapi.enable() # Connect to a MIDI port 9 | >>> flapi.init() # Establish the connection with FL Studio 10 | >>> import transport 11 | >>> transport.start() # FL Studio starts playing 12 | ``` 13 | """ 14 | from .__enable import enable, init, try_init, disable 15 | from .__comms import hello, fl_exec, fl_eval, fl_print 16 | from . import errors 17 | from ._consts import VERSION 18 | 19 | 20 | # Set up the version string 21 | __version__ = ".".join(str(n) for n in VERSION) 22 | del VERSION 23 | 24 | 25 | __all__ = [ 26 | "enable", 27 | "init", 28 | "try_init", 29 | "disable", 30 | "hello", 31 | "fl_exec", 32 | "fl_eval", 33 | "fl_print", 34 | "errors", 35 | ] 36 | -------------------------------------------------------------------------------- /flapi/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Main 3 | 4 | A simple program to run Flapi commands 5 | """ 6 | import click 7 | from click_default_group import DefaultGroup # type: ignore 8 | from .cli import install, repl, uninstall 9 | from ._consts import VERSION 10 | 11 | 12 | @click.group(cls=DefaultGroup, default='repl', default_if_no_args=True) 13 | @click.version_option(".".join(str(n) for n in VERSION)) 14 | def cli(): 15 | pass 16 | 17 | 18 | cli.add_command(install) 19 | cli.add_command(uninstall) 20 | cli.add_command(repl) 21 | 22 | 23 | if __name__ == '__main__': 24 | cli(auto_envvar_prefix="FLAPI") 25 | -------------------------------------------------------------------------------- /flapi/__util.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Util 3 | 4 | Helper functions 5 | """ 6 | import pickle 7 | from base64 import b64decode 8 | from typing import Any 9 | 10 | 11 | def bytes_to_str(msg: bytes) -> str: 12 | """ 13 | Helper to give a nicer representation of bytes 14 | """ 15 | return f"{repr([hex(i) for i in msg])} ({repr(msg)})" 16 | 17 | 18 | def decode_python_object(data: bytes) -> Any: 19 | """ 20 | Encode Python object to send to the client 21 | """ 22 | return pickle.loads(b64decode(data)) 23 | 24 | 25 | def format_fn_params(args, kwargs): 26 | args_str = ", ".join(repr(a) for a in args) 27 | kwargs_str = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items()) 28 | 29 | # Overall parameters string (avoid invalid syntax by removing extra 30 | # commas) 31 | return f"{args_str}, {kwargs_str}"\ 32 | .removeprefix(", ")\ 33 | .removesuffix(", ") 34 | -------------------------------------------------------------------------------- /flapi/_consts.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Consts 3 | 4 | Constants used by Flapi 5 | """ 6 | from enum import IntEnum 7 | 8 | VERSION = (1, 0, 1) 9 | """ 10 | The version of Flapi in the format (major, minor, revision) 11 | """ 12 | 13 | TIMEOUT_DURATION = 0.1 14 | """ 15 | The amount of time to wait for a response before giving an error 16 | """ 17 | 18 | 19 | SYSEX_HEADER = bytes([ 20 | # 0xF0, # Begin sysex (added by Mido) 21 | 0x7D, # Non-commercial use byte number (see 22 | # https://midi.org/specifications/midi1-specifications/midi-1-0-core-specifications) 23 | 0x46, # 'F' 24 | 0x6C, # 'l' 25 | 0x61, # 'a' 26 | 0x70, # 'p' 27 | 0x69, # 'i' 28 | ]) 29 | """ 30 | Header for Sysex messages sent by Flapi, excluding the `0xF0` status byte 31 | """ 32 | 33 | 34 | MAX_DATA_LEN = 1000 35 | """ 36 | Maximum number of bytes to use for additional message data. 37 | """ 38 | 39 | 40 | class MessageOrigin(IntEnum): 41 | """ 42 | Origin of a Flapi message 43 | """ 44 | CLIENT = 0x00 45 | """ 46 | Message originates from the Flapi client (library) 47 | """ 48 | 49 | INTERNAL = 0x02 50 | """ 51 | Message internal to Flapi server (communication between ports in FL Studio) 52 | """ 53 | 54 | SERVER = 0x01 55 | """ 56 | Message originates from Flapi server (FL Studio) 57 | """ 58 | 59 | 60 | class MessageType(IntEnum): 61 | """ 62 | Type of a Flapi message 63 | """ 64 | 65 | CLIENT_HELLO = 0x00 66 | """ 67 | Hello message, used to connect to the client 68 | """ 69 | 70 | CLIENT_GOODBYE = 0x01 71 | """ 72 | Message from server instructing client to exit. Used so that we can have a 73 | working `exit` function when using the server-side REPL, and to cleanly 74 | disconnect from the server. 75 | """ 76 | 77 | SERVER_GOODBYE = 0x02 78 | """ 79 | Message from server notifying client that it is shutting down. 80 | """ 81 | 82 | VERSION_QUERY = 0x03 83 | """ 84 | Query the server version - this is used to ensure that the server is 85 | running a matching version of Flapi, so that there aren't any bugs with 86 | communication. 87 | """ 88 | 89 | REGISTER_MESSAGE_TYPE = 0x04 90 | """ 91 | Register a new message type for the client. 92 | """ 93 | 94 | EXEC = 0x05 95 | """ 96 | Exec message - this is used to run an `exec` command in FL Studio, with no 97 | return type (just a success, or an exception raised). 98 | """ 99 | 100 | STDOUT = 0x06 101 | """ 102 | Message contains text to write into stdout. 103 | """ 104 | 105 | 106 | class MessageStatus(IntEnum): 107 | """ 108 | Status of a Flapi message 109 | """ 110 | OK = 0x00 111 | """ 112 | Message was processed correctly. 113 | """ 114 | 115 | ERR = 0x01 116 | """ 117 | Processing of message raised an exception. 118 | """ 119 | 120 | FAIL = 0x02 121 | """ 122 | The message could not be processed 123 | 124 | The error message is attached in the remaining bytes. 125 | """ 126 | 127 | 128 | DEVICE_ENQUIRY_MESSAGE = bytes([ 129 | # 0xF0 - begin sysex (omitted by Mido) 130 | 0x7E, # Universal sysex message 131 | 0x00, # Device ID (assume zero?) 132 | 0x06, # General information 133 | 0x01, # Identity request 134 | # 0xF7 - end sysex (omitted by Mido) 135 | ]) 136 | """ 137 | A universal device enquiry message, sent by FL Studio to attempt to identify 138 | the type of the connected device. 139 | """ 140 | 141 | DEVICE_ENQUIRY_RESPONSE = bytes([ 142 | # 0xF0 - begin sysex (omitted by Mido) 143 | 0x7E, # Universal sysex message 144 | 0x00, # Device ID (assume zero) 145 | 0x06, # General information 146 | 0x02, # Identity reply 147 | 0x7D, # Non-commercial use byte number 148 | 0x46, # 'F' 149 | 0x6c, # 'l' 150 | 0x61, # 'a' 151 | 0x70, # 'p' 152 | 0x69, # 'i' 153 | VERSION[0], # Major version 154 | VERSION[1], # Minor version 155 | VERSION[2], # Revision version 156 | # 0xF7 - end sysex (omitted by Mido) 157 | ]) 158 | 159 | 160 | DEFAULT_REQ_PORT = "Flapi Request" 161 | """ 162 | MIDI port to use/create for sending requests to FL Studio 163 | """ 164 | 165 | 166 | DEFAULT_RES_PORT = "Flapi Response" 167 | """ 168 | MIDI port to use/create for receiving responses from FL Studio 169 | """ 170 | 171 | 172 | FL_MODULES = [ 173 | "playlist", 174 | "channels", 175 | "mixer", 176 | "patterns", 177 | "arrangement", 178 | "ui", 179 | "transport", 180 | "plugins", 181 | "general", 182 | ] 183 | """ 184 | Modules we need to decorate within FL Studio 185 | """ 186 | -------------------------------------------------------------------------------- /flapi/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > CLI 3 | 4 | Code for running the Flapi CLI 5 | """ 6 | from .install import install 7 | from .uninstall import uninstall 8 | from .repl import repl 9 | 10 | 11 | __all__ = [ 12 | 'install', 13 | 'uninstall', 14 | 'repl', 15 | ] 16 | -------------------------------------------------------------------------------- /flapi/cli/consts.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > CLI > Consts 3 | 4 | Constants used by the Flapi CLI 5 | """ 6 | import os 7 | from pathlib import Path 8 | 9 | 10 | DEFAULT_IL_DATA_DIR = Path(os.path.expanduser("~/Documents/Image-Line")) 11 | """ 12 | The default location of the Image-Line data directory 13 | """ 14 | 15 | 16 | CONNECTION_TIMEOUT = 60.0 17 | """ 18 | The maximum duration to wait for a connection with FL Studio 19 | """ 20 | -------------------------------------------------------------------------------- /flapi/cli/install.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Install 3 | 4 | Simple script for installing the Flapi server into FL Studio 5 | """ 6 | import click 7 | from shutil import copytree, rmtree 8 | from pathlib import Path 9 | from . import consts 10 | from .util import yn_prompt, output_dir, server_dir 11 | 12 | 13 | @click.command() 14 | @click.option( 15 | "-d", 16 | "--data-dir", 17 | default=consts.DEFAULT_IL_DATA_DIR, 18 | type=Path, 19 | prompt=True, 20 | help="The path of the Image-Line data directory. Set to '-' for default", 21 | ) 22 | @click.option( 23 | "-y", 24 | "--yes", 25 | is_flag=True, 26 | help="Always overwrite the server installation" 27 | ) 28 | @click.option( 29 | "--dev", 30 | is_flag=True, 31 | help="Install a live (development) server" 32 | ) 33 | def install(data_dir: Path, yes: bool = False, dev: bool = False): 34 | """ 35 | Install the Flapi server to FL Studio 36 | """ 37 | if data_dir == Path("-"): 38 | data_dir = consts.DEFAULT_IL_DATA_DIR 39 | # Determine scripts folder location 40 | output_location = output_dir(data_dir) 41 | 42 | if output_location.exists(): 43 | print(f"Warning: output directory '{output_location}' exists!") 44 | if yes: 45 | print("--yes used, continuing") 46 | else: 47 | if not yn_prompt("Overwrite? [y/N]: ", default=False): 48 | print("Operation cancelled") 49 | exit(1) 50 | rmtree(output_location) 51 | 52 | # Determine where we are, so we can locate the script folder 53 | script_location = server_dir() 54 | 55 | # Now copy the script folder to the output folder 56 | if dev: 57 | output_location.symlink_to(script_location, True) 58 | else: 59 | copytree(script_location, output_location) 60 | 61 | print( 62 | "Success! Make sure you restart FL Studio so the server is registered" 63 | ) 64 | -------------------------------------------------------------------------------- /flapi/cli/repl.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > REPL 3 | 4 | Starts a simple REPL to interact with FL Studio, using IPython (if available) 5 | or Python's integrated shell. 6 | """ 7 | import code 8 | import click 9 | import sys 10 | import os 11 | import time 12 | import random 13 | from typing import Optional 14 | from traceback import print_exc 15 | import flapi 16 | from flapi import ( 17 | enable, 18 | init, 19 | try_init, 20 | disable, 21 | fl_exec, 22 | fl_eval, 23 | fl_print, 24 | ) 25 | from .. import _consts as consts 26 | from ..errors import FlapiServerExit 27 | from . import consts as cli_consts 28 | from .util import handle_verbose 29 | try: 30 | import IPython 31 | from IPython import start_ipython 32 | from traitlets.config.loader import Config as IPythonConfig 33 | except ImportError: 34 | IPython = None # type: ignore 35 | start_ipython = None # type: ignore 36 | IPythonConfig = None # type: ignore 37 | 38 | 39 | SHELL_SCOPE = { 40 | "enable": enable, 41 | "init": init, 42 | "disable": disable, 43 | "fl_exec": fl_exec, 44 | "fl_eval": fl_eval, 45 | "fl_print": fl_print, 46 | } 47 | 48 | 49 | def wait_for_connection(max_wait: float) -> bool: 50 | """ 51 | Busy wait until we establish a connection with FL Studio 52 | 53 | Return whether wait was a success 54 | """ 55 | def ellipsis(delta: float) -> str: 56 | pos = int(delta * 2) % 6 57 | if pos == 0: 58 | return ". " 59 | elif pos == 1: 60 | return ".. " 61 | elif pos == 2: 62 | return "..." 63 | elif pos == 3: 64 | return " .." 65 | elif pos == 4: 66 | return " ." 67 | else: # pos == 5 68 | return " " 69 | 70 | start_time = time.time() 71 | while not try_init(random.randrange(1, 0x7F)): 72 | delta = time.time() - start_time 73 | if delta > max_wait: 74 | return False 75 | 76 | # Print progress 77 | print( 78 | f" {ellipsis(delta)} Connecting to FL Studio ({int(delta)}" 79 | f"/{int(max_wait)}s)", 80 | end='\r', 81 | ) 82 | 83 | # Yucky thing to ensure that we write all the way to the end of the line 84 | msg = "Connected to FL Studio" 85 | print(msg + ' ' * (os.get_terminal_size().columns - len(msg))) 86 | return True 87 | 88 | 89 | def exec_lines(lines: list[str]) -> bool: 90 | """ 91 | Attempt to execute the given lines on the server. 92 | 93 | Returns `True` if the lines were executed, or `False` if the code is 94 | incomplete. 95 | 96 | Raises an exception if the code is complete but has some kind of error. 97 | """ 98 | source = "\n".join(lines) 99 | 100 | # First check if the lines actually compile 101 | # This raises an error if the lines are complete, but invalid 102 | try: 103 | if code.compile_command(source) is None: 104 | return False 105 | except Exception: 106 | print_exc() 107 | return True 108 | 109 | # Determine if the code is a statement, an expression, or invalid 110 | # https://stackoverflow.com/a/3876268/6335363 111 | try: 112 | code.compile_command(source, symbol='eval') 113 | is_statement = False 114 | except SyntaxError: 115 | is_statement = True 116 | 117 | if code == "exit": 118 | exit() 119 | try: 120 | if is_statement: 121 | fl_exec(source) 122 | else: 123 | res = fl_eval(source) 124 | print(repr(res)) 125 | except FlapiServerExit: 126 | print( 127 | "Error: the Flapi server exited, likely because FL Studio was " 128 | "closed." 129 | ) 130 | exit(1) 131 | except Exception: 132 | print_exc() 133 | 134 | return True 135 | 136 | 137 | def start_server_shell(): 138 | """ 139 | A simple REPL where all code is run server-side 140 | """ 141 | lines = [] 142 | 143 | last_was_interrupted = False 144 | 145 | while True: 146 | try: 147 | line = input(">>> " if not len(lines) else "... ") 148 | except KeyboardInterrupt: 149 | if last_was_interrupted: 150 | disable() 151 | exit() 152 | else: 153 | print("\nKeyboard interrupt. Press again to quit") 154 | last_was_interrupted = True 155 | continue 156 | 157 | last_was_interrupted = False 158 | lines.append(line) 159 | 160 | # If we fully executed the lines, clear the buffer 161 | if exec_lines(lines): 162 | lines = [] 163 | 164 | 165 | def start_python_shell(): 166 | """ 167 | Start up Python's built-in shell 168 | """ 169 | code.interact( 170 | banner="", 171 | local=SHELL_SCOPE, 172 | ) 173 | 174 | 175 | def start_ipython_shell(): 176 | """ 177 | Start up an Ipython shell 178 | """ 179 | assert IPython is not None 180 | assert start_ipython is not None 181 | assert IPythonConfig is not None 182 | config = IPythonConfig() 183 | config.TerminalInteractiveShell.banner1 \ 184 | = f"IPython version: {IPython.__version__}" 185 | start_ipython(argv=[], user_ns=SHELL_SCOPE, config=config) 186 | 187 | 188 | @click.command() 189 | @click.option( 190 | "-s", 191 | "--shell", 192 | type=click.Choice(["ipython", "python", "server"], case_sensitive=False), 193 | help="The shell to use with Flapi.", 194 | default=None, 195 | ) 196 | @click.option( 197 | "--req", 198 | type=str, 199 | help="The name of the MIDI port to send requests on", 200 | default=consts.DEFAULT_REQ_PORT, 201 | ) 202 | @click.option( 203 | "--res", 204 | type=str, 205 | help="The name of the MIDI port to receive responses on", 206 | default=consts.DEFAULT_RES_PORT, 207 | ) 208 | @click.option( 209 | "-t", 210 | "--timeout", 211 | type=float, 212 | help="Maximum time to wait to establish a connection with FL Studio", 213 | default=cli_consts.CONNECTION_TIMEOUT, 214 | ) 215 | @click.option('-v', '--verbose', count=True) 216 | def repl( 217 | shell: Optional[str] = None, 218 | req: str = consts.DEFAULT_REQ_PORT, 219 | res: str = consts.DEFAULT_RES_PORT, 220 | timeout: float = cli_consts.CONNECTION_TIMEOUT, 221 | verbose: int = 0, 222 | ): 223 | """Main function to set up the Python shell""" 224 | handle_verbose(verbose) 225 | print("Flapi interactive shell") 226 | print(f"Client version: {flapi.__version__}") 227 | print(f"Python version: {sys.version}") 228 | 229 | # Set up the connection 230 | status = enable(req, res) 231 | 232 | if not status: 233 | status = wait_for_connection(timeout) 234 | 235 | if shell == "server": 236 | if status: 237 | start_server_shell() 238 | else: 239 | print("Flapi could not connect to FL Studio.") 240 | print( 241 | "Please verify that FL Studio is running and the server is " 242 | "installed" 243 | ) 244 | print("Then, run this command again.") 245 | exit(1) 246 | 247 | if not status: 248 | print("Flapi could not connect to FL Studio.") 249 | print( 250 | "Please verify that FL Studio is running and the server is " 251 | "installed" 252 | ) 253 | print("Then, run `init()` to create the connection.") 254 | 255 | print("Imported functions:") 256 | print(", ".join(SHELL_SCOPE.keys())) 257 | 258 | if shell == "python": 259 | return start_python_shell() 260 | elif shell == "ipython": 261 | if IPython is None: 262 | print("Error: IPython is not installed!") 263 | exit(1) 264 | return start_ipython_shell() 265 | else: 266 | # Default: launch IPython if possible, but fall back to the default 267 | # shell 268 | if IPython is not None: 269 | return start_ipython_shell() 270 | else: 271 | return start_python_shell() 272 | -------------------------------------------------------------------------------- /flapi/cli/uninstall.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Uninstall 3 | 4 | Simple script for removing the Flapi server FL Studio 5 | """ 6 | import click 7 | from shutil import rmtree 8 | from pathlib import Path 9 | from . import consts 10 | from .util import output_dir 11 | 12 | 13 | @click.command() 14 | @click.option( 15 | "-d", 16 | "--data-dir", 17 | default=consts.DEFAULT_IL_DATA_DIR, 18 | type=Path, 19 | prompt=True, 20 | help="The path of the Image-Line data directory. Set to '-' for default", 21 | ) 22 | @click.confirmation_option( 23 | prompt="Are you sure you want to uninstall the Flapi server?", 24 | ) 25 | def uninstall(data_dir: Path = consts.DEFAULT_IL_DATA_DIR): 26 | """ 27 | Uninstall the Flapi server 28 | """ 29 | if data_dir == Path("-"): 30 | data_dir = consts.DEFAULT_IL_DATA_DIR 31 | # Determine scripts folder location 32 | server_location = output_dir(data_dir) 33 | 34 | # Remove it 35 | rmtree(server_location) 36 | print("Success!") 37 | -------------------------------------------------------------------------------- /flapi/cli/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > CLI > Util 3 | 4 | Helper functions for CLI 5 | """ 6 | from typing import Optional 7 | from pathlib import Path 8 | import logging 9 | 10 | 11 | def handle_verbose(verbose: int): 12 | if verbose == 0: 13 | return 14 | elif verbose == 1: 15 | logging.basicConfig(level="INFO") 16 | else: 17 | logging.basicConfig(level="DEBUG") 18 | 19 | 20 | def yn_prompt(prompt: str, default: Optional[bool] = None) -> bool: 21 | """ 22 | Yes/no prompt 23 | """ 24 | while True: 25 | res = input(prompt) 26 | if res == 'y': 27 | return True 28 | if res == 'n': 29 | return False 30 | if res == '' and default is not None: 31 | return default 32 | else: 33 | print("Invalid response") 34 | 35 | 36 | def output_dir(data_dir: Path) -> Path: 37 | """ 38 | Return the path to the directory where the script should be installed 39 | """ 40 | return data_dir.joinpath( 41 | "FL Studio", "Settings", "Hardware", "Flapi Server") 42 | 43 | 44 | def server_dir() -> Path: 45 | """ 46 | Return the current location of the Flapi server script 47 | """ 48 | return Path(__file__).parent.parent.joinpath("server") 49 | -------------------------------------------------------------------------------- /flapi/client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client 3 | 4 | Implementations of `FlapiBaseClient` (intended to be extended by developers) 5 | and `FlapiClient` (intended to be consumed by users). 6 | """ 7 | from ..flapi_msg import FlapiMsg 8 | from .base_client import FlapiBaseClient 9 | from .client import FlapiClient 10 | 11 | __all__ = [ 12 | 'FlapiMsg', 13 | 'FlapiBaseClient', 14 | 'FlapiClient', 15 | ] 16 | -------------------------------------------------------------------------------- /flapi/client/base_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client / Base Client 3 | 4 | Class implementing a basic Flapi client. 5 | """ 6 | from base64 import b64decode, b64encode 7 | import logging 8 | import time 9 | import inspect 10 | from typing import Any, Optional, Protocol, Self, cast, Callable 11 | from flapi import _consts as consts 12 | from flapi.types import ServerMessageHandler 13 | from flapi._consts import MessageOrigin, MessageType, MessageStatus 14 | import random 15 | 16 | from ..flapi_msg import FlapiMsg 17 | from .ports import connect_to_ports 18 | from .comms import FlapiComms 19 | from ..errors import ( 20 | FlapiInvalidMsgError, 21 | FlapiClientExit, 22 | FlapiServerExit, 23 | FlapiServerError, 24 | FlapiClientError, 25 | FlapiTimeoutError, 26 | FlapiConnectionError, 27 | ) 28 | 29 | 30 | log = logging.getLogger(__name__) 31 | 32 | 33 | class StdoutCallback(Protocol): 34 | """ 35 | Callback function for when stdout is produced. 36 | """ 37 | def __call__(self, text: str) -> Any: 38 | ... 39 | 40 | 41 | class UnknownMsgCallback(Protocol): 42 | """ 43 | Callback function for when an unknown message is received. 44 | """ 45 | def __call__(self, msg: bytes) -> Any: 46 | ... 47 | 48 | 49 | 50 | 51 | 52 | def default_unknown_msg_callback(msg: bytes) -> Any: 53 | """ 54 | Default callback for unknown messages. 55 | 56 | ## Raises 57 | 58 | * `FlapiInvalidMsgError` 59 | """ 60 | raise FlapiInvalidMsgError(msg) 61 | 62 | 63 | def assert_response_is_ok(msg: FlapiMsg, expected_msg_type: MessageType): 64 | """ 65 | Ensure the message type is correct, and handle the message status 66 | 67 | * MSG_STATUS_OK: take no action 68 | * MSG_STATUS_ERR: raise the exception 69 | * MSG_STATUS_FAIL: raise an exception describing the failure 70 | """ 71 | if msg.msg_type != expected_msg_type: 72 | expected = expected_msg_type 73 | raise FlapiClientError( 74 | f"Expected message type '{expected}', received '{msg.msg_type}'") 75 | 76 | if msg.status_code == MessageStatus.OK: 77 | return 78 | elif msg.status_code == MessageStatus.ERR: 79 | raise eval(b64decode(msg.additional_data).decode()) 80 | elif msg.status_code == MessageStatus.FAIL: 81 | raise FlapiServerError(b64decode(msg.additional_data).decode()) 82 | 83 | 84 | class FlapiBaseClient: 85 | """ 86 | An implementation of the core Flapi client functionality, as defined in the 87 | Flapi Protocol documentation. 88 | 89 | This is wrapped by the `FlapiClient`, which registers specialised handlers 90 | to improve usability in Python. 91 | """ 92 | 93 | def __init__( 94 | self, 95 | stdout_callback: StdoutCallback = lambda text: print(text), 96 | unknown_msg_callback: UnknownMsgCallback = default_unknown_msg_callback 97 | ) -> None: 98 | """ 99 | Create a FlapiBaseClient, ready to connect to FL Studio. 100 | 101 | Note that initially, this client does not attempt to connect to FL 102 | Studio. To do so, you should call `client.open()` then 103 | `client.hello()`. 104 | 105 | For example: 106 | 107 | ```py 108 | client = FlapiClient().open().hello() 109 | ``` 110 | """ 111 | self.__comms: Optional[FlapiComms] = None 112 | """Communication channel with FL Studio""" 113 | 114 | self.stdout_callback = stdout_callback 115 | """Callback for when stdout is received from FL Studio""" 116 | self.unknown_msg_callback = unknown_msg_callback 117 | """Callback for when an unknown message is received from FL Studio""" 118 | 119 | self.__client_id: Optional[int] = None 120 | """ 121 | Internal client ID. Used to determine if we are connected to FL Studio. 122 | """ 123 | 124 | def __del__(self) -> None: 125 | # When this object is dropped, we should close our connection 126 | try: 127 | self.close() 128 | except (FlapiConnectionError, FlapiTimeoutError) as e: 129 | # If anything went wrong, silence the error (FL Studio probably 130 | # closed) 131 | log.warning( 132 | f"Error when cleaning up connection to Flapi server: {e}" 133 | ) 134 | pass 135 | 136 | @property 137 | def is_open(self) -> bool: 138 | """ 139 | Whether the client is connected to MIDI ports. 140 | 141 | Note that this represents the connection to the MIDI ports, NOT the 142 | connection to the Flapi server. 143 | """ 144 | return self.__comms is not None 145 | 146 | @property 147 | def comms(self) -> FlapiComms: 148 | """ 149 | The communication channel with FL Studio. 150 | """ 151 | if self.__comms is None: 152 | raise RuntimeError( 153 | "Flapi client is not connected to a MIDI port, so doesn't " 154 | "have an open communication channel." 155 | ) 156 | return self.__comms 157 | 158 | def open( 159 | self, 160 | req_port: str = consts.DEFAULT_REQ_PORT, 161 | res_port: str = consts.DEFAULT_RES_PORT, 162 | ) -> Self: 163 | """ 164 | Open a connection on the given MIDI ports. 165 | 166 | Note that this does not establish a connection to the Flapi server. To 167 | do that, call `client.hello()`. 168 | 169 | ## Args 170 | 171 | * `req_port` (`str`): name of MIDI port to send requests on 172 | * `res_port` (`str`): name of MIDI port to receive responses on 173 | 174 | ## Returns 175 | 176 | * `Self`: a reference to this client, to allow for pipeline-like 177 | initialization. 178 | """ 179 | req, res = connect_to_ports(req_port, res_port) 180 | self.__comms = FlapiComms(req, res) 181 | 182 | return self 183 | 184 | def close(self) -> None: 185 | """ 186 | Close the connection to the given MIDI ports. 187 | """ 188 | # If we're connected to the Flapi server, we should say goodbye first 189 | if self.is_connected: 190 | self.goodbye() 191 | # Deleting our reference to `_comms` should cause the connection to the 192 | # ports to be dropped immediately. 193 | self.__comms = None 194 | 195 | @property 196 | def client_id(self) -> int: 197 | """ 198 | ID of the Flapi client. 199 | """ 200 | if self.__client_id is None: 201 | raise RuntimeError( 202 | "Flapi is not connected to FL Studio, so doesn't have a " 203 | "client_id" 204 | ) 205 | return self.__client_id 206 | 207 | @property 208 | def is_connected(self) -> bool: 209 | """ 210 | Whether the client is connected to the Flapi server. 211 | 212 | Note that this represents the connection to the Flapi server, NOT the 213 | connection to the MIDI ports. 214 | """ 215 | return self.__client_id is not None 216 | 217 | def hello(self, /, timeout: float = 0) -> Self: 218 | """ 219 | Establish the connection with the Flapi server. 220 | 221 | This repeatedly tries to attempt to the Flapi server using random 222 | client IDs. 223 | 224 | ## Args 225 | 226 | * `timeout` (`float`, optional): amount of time to spend trying to 227 | connect to FL Studio. When this time is exceeded, a 228 | `FlapiConnectionError` is raised. This defaults to zero, meaning that 229 | the client will only attempt to connect using each possible 230 | `client_id` once (rather than repeatedly trying until the timeout is 231 | exceeded). 232 | 233 | ## Returns 234 | 235 | * `Self`: a reference to this client, to allow for pipeline-like 236 | initialization. 237 | """ 238 | start_time = time.monotonic() 239 | first_iteration = True 240 | 241 | while time.monotonic() <= start_time + timeout or first_iteration: 242 | first_iteration = False 243 | 244 | # Select potential client IDs randomly 245 | for client_id in random.sample(range(1, 128), len(range(1, 128))): 246 | self.__client_id = client_id 247 | log.debug(f"Attempt hello with {client_id=}") 248 | self.comms.send_message( 249 | client_id, 250 | MessageType.CLIENT_HELLO, 251 | MessageStatus.OK, 252 | ) 253 | try: 254 | msg = self.__receive_and_dispatch() 255 | except FlapiTimeoutError: 256 | # No response, means device not accepted. 257 | continue 258 | 259 | # If message isn't ok, that's someone else's problem. 260 | # This may raise a `FlapiServerError` if something went 261 | # horribly wrong in the server. 262 | assert_response_is_ok(msg, MessageType.CLIENT_HELLO) 263 | return self 264 | 265 | else: 266 | # Timeout exceeded. Either the connection pool is full, 267 | # or FL Studio isn't running. 268 | raise FlapiConnectionError() 269 | 270 | def goodbye(self, code: int = 0) -> None: 271 | """ 272 | Disconnect from the Flapi server. 273 | 274 | ## Args 275 | 276 | * `code` (`int`): the "exit code" to use when disconnecting. 277 | """ 278 | self.comms.send_message( 279 | self.client_id, 280 | MessageType.CLIENT_GOODBYE, 281 | MessageStatus.OK, 282 | b64encode(str(code).encode()) 283 | ) 284 | msg = self.__receive_and_dispatch() 285 | assert_response_is_ok(msg, MessageType.CLIENT_GOODBYE) 286 | self.__client_id = None 287 | 288 | def __unknown_msg(self, msg: bytes) -> None: 289 | """ 290 | Handler for unknown messages. This checks for a universal device 291 | enquiry message, and forwards other messages onto the unknown message 292 | callback. 293 | """ 294 | # Handle universal device enquiry 295 | # TODO: Implement handling of device enquiry message using the stubs 296 | # wrapper client. The base client may want to forward this onto another 297 | # controller (eg if using Flapi as an intermediary testing tool). 298 | if msg[1:-1] == consts.DEVICE_ENQUIRY_MESSAGE: 299 | log.debug('Received universal device enquiry') 300 | self.comms.send_message_raw(consts.DEVICE_ENQUIRY_RESPONSE) 301 | return 302 | # Otherwise, pass it to our callback 303 | self.unknown_msg_callback(msg) 304 | 305 | def __receive_and_dispatch(self) -> FlapiMsg: 306 | """ 307 | Receive an event, and handle system messages. 308 | """ 309 | while True: 310 | msg = self.comms.receive_message() 311 | 312 | if isinstance(msg, bytes): 313 | self.__unknown_msg(msg) 314 | # Try again for a message 315 | continue 316 | else: 317 | # FlapiMsg 318 | assert isinstance(msg, FlapiMsg) 319 | 320 | # Handle loopback (prevent us from receiving our own messages) 321 | if msg.origin != MessageOrigin.SERVER: 322 | continue 323 | 324 | # Handle other clients (prevent us from receiving their 325 | # messages) 326 | # We still accept client ID zero, since it targets all devices 327 | if msg.client_id not in [0, self.__client_id]: 328 | continue 329 | 330 | # Handle FL Studio stdout 331 | if msg.msg_type == MessageType.STDOUT: 332 | text = b64decode(msg.additional_data).decode() 333 | log.debug(f"Received server stdout: {text}") 334 | self.stdout_callback(text) 335 | continue 336 | 337 | # Handle exit command 338 | if msg.msg_type == MessageType.CLIENT_GOODBYE: 339 | code = int(b64decode(msg.additional_data).decode()) 340 | log.info(f"Received exit command with code {code}") 341 | raise FlapiClientExit(code) 342 | 343 | # Handle server disconnect 344 | if msg.msg_type == MessageType.SERVER_GOODBYE: 345 | raise FlapiServerExit() 346 | 347 | # Normal processing 348 | return msg 349 | 350 | def version_query(self) -> tuple[int, int, int]: 351 | """ 352 | Send a version query message to the Flapi server. 353 | 354 | Returns: 355 | * `tuple[int, int, int]`: the version, in the format 356 | `(major, minor, patch)`. 357 | """ 358 | self.comms.send_message( 359 | self.client_id, 360 | MessageType.VERSION_QUERY, 361 | MessageStatus.OK 362 | ) 363 | msg = self.__receive_and_dispatch() 364 | assert_response_is_ok(msg, MessageType.VERSION_QUERY) 365 | 366 | # Would be nice if mypy could infer the correctness of this 367 | return cast(tuple[int, int, int], tuple(msg.additional_data[:3])) 368 | 369 | def register_message_type( 370 | self, 371 | server_side_handler: ServerMessageHandler, 372 | ) -> Callable[[bytes], FlapiMsg]: 373 | """ 374 | Register a new message type on the server. 375 | 376 | ## Args 377 | 378 | * server_side_handler (`ServerMessageHandler`): function to 379 | declare on the server for handling this new message type. 380 | 381 | ## Returns 382 | 383 | * `Callable[[bytes], FlapiMsg]`: a function which can be used to send 384 | this type of message using this client. 385 | """ 386 | function_source = inspect.getsource(server_side_handler) 387 | 388 | self.comms.send_message( 389 | self.client_id, 390 | MessageType.REGISTER_MESSAGE_TYPE, 391 | MessageStatus.OK, 392 | b64encode(function_source.encode()) 393 | ) 394 | msg = self.__receive_and_dispatch() 395 | assert_response_is_ok(msg, MessageType.REGISTER_MESSAGE_TYPE) 396 | 397 | new_type = msg.additional_data[0] 398 | 399 | def send_message(data: bytes) -> FlapiMsg: 400 | """ 401 | Send a message using the new message type to the Flapi server 402 | 403 | Args: 404 | data (bytes): data to send 405 | 406 | Returns: 407 | FlapiMsg: response message 408 | """ 409 | self.comms.send_message( 410 | self.client_id, 411 | new_type, 412 | MessageStatus.OK, 413 | data 414 | ) 415 | return self.__receive_and_dispatch() 416 | 417 | return send_message 418 | 419 | def exec(self, code: str) -> None: 420 | """ 421 | Execute the given code on the Flapi server 422 | 423 | Args: 424 | code (str): code to execute 425 | """ 426 | self.comms.send_message( 427 | self.client_id, 428 | MessageType.EXEC, 429 | MessageStatus.OK, 430 | b64encode(code.encode()) 431 | ) 432 | msg = self.__receive_and_dispatch() 433 | assert_response_is_ok(msg, MessageType.EXEC) 434 | -------------------------------------------------------------------------------- /flapi/client/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client / Client 3 | Class using the Flapi base client to implement a Pythonic system for 4 | communicating with the Flapi server. 5 | """ 6 | from typing import Any, Optional 7 | from base64 import b64decode, b64encode 8 | import pickle 9 | 10 | from flapi import _consts as consts 11 | from flapi._consts import MessageStatus 12 | from .base_client import FlapiBaseClient 13 | from flapi.errors import FlapiServerError 14 | 15 | 16 | class FlapiClient: 17 | """ 18 | Implementation that wraps around the base Flapi client to implement 19 | additional features. 20 | """ 21 | def __init__( 22 | self, 23 | req_port: str = consts.DEFAULT_REQ_PORT, 24 | res_port: str = consts.DEFAULT_RES_PORT, 25 | ) -> None: 26 | self.__client = FlapiBaseClient().open(req_port, res_port).hello() 27 | 28 | def pickle_eval( 29 | client_id: int, 30 | status_code: int, 31 | msg_data: Optional[bytes], 32 | scope: dict[str, Any], 33 | ) -> tuple[int, bytes]: 34 | """ 35 | Implementation of an eval message type using `pickle` to encode 36 | response data. 37 | """ 38 | import pickle # noqa: F811 39 | from base64 import b64decode, b64encode # noqa: F811 40 | 41 | assert msg_data is not None 42 | source = b64decode(msg_data).decode() 43 | 44 | try: 45 | result = eval(source, globals(), scope) 46 | except Exception as e: 47 | return (1, b64encode(pickle.dumps(e))) 48 | 49 | return (0, b64encode(pickle.dumps(result))) 50 | 51 | self.__pickle_eval = self.__client.register_message_type(pickle_eval) 52 | 53 | def exec(self, code: str) -> None: 54 | """ 55 | Execute the given code on the Flapi server 56 | Args: 57 | code (str): code to execute 58 | """ 59 | self.__client.exec(code) 60 | 61 | def eval(self, code: str) -> Any: 62 | """ 63 | Evaluate the given code on the Flapi server 64 | Args: 65 | code (str): code to execute 66 | """ 67 | result = self.__pickle_eval(b64encode(code.encode())) 68 | if result.status_code == MessageStatus.ERR: 69 | # An error occurred while executing the code, raise it as an 70 | # exception after decoding it. 71 | raise pickle.loads(b64decode(result.additional_data)) 72 | elif result.status_code == MessageStatus.OK: 73 | return pickle.loads(b64decode(result.additional_data)) 74 | else: 75 | raise FlapiServerError(b64decode(result.additional_data).decode()) 76 | -------------------------------------------------------------------------------- /flapi/client/comms.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client / Comms 3 | 4 | Client-side implementation of message send/receive. 5 | """ 6 | import time 7 | from flapi.types import MidoPort, MidoMsg 8 | from flapi import _consts as consts 9 | from flapi._consts import MessageType, MessageOrigin, MessageStatus 10 | from ..flapi_msg import FlapiMsg 11 | from ..errors import FlapiInvalidMsgError, FlapiTimeoutError 12 | 13 | 14 | # NOTE: Currently not using async code, since I can't think of a good 15 | # (type-safe) way to make it work nicely with wrapping the API stubs, since I 16 | # don't know how I can transform them all to be async. 17 | # Plus making it async will probably cause concurrency issues. 18 | 19 | # import asyncio 20 | # from typing import AsyncIterable 21 | # 22 | # 23 | # def make_event_stream(): 24 | # """ 25 | # Creates an event callback and event stream, which can be attached to a 26 | # Mido IO port, allowing queue values to be iterated asynchronously. 27 | # 28 | # ## Returns 29 | # 30 | # * `callback`: callback function, which adds events to the queue. This 31 | # should be passed to `mido.open_input(callback=callback)`. 32 | # * `stream`: async event stream. Events are repeatedly yielded from the 33 | # event queue. 34 | # 35 | # Source: https://stackoverflow.com/a/56280107/6335363 36 | # """ 37 | # loop = asyncio.get_event_loop() 38 | # queue = asyncio.Queue() 39 | # def callback(message): 40 | # loop.call_soon_threadsafe(queue.put_nowait, message) 41 | # async def stream(): 42 | # while True: 43 | # yield await queue.get() 44 | # return callback, stream() 45 | 46 | class FlapiComms: 47 | def __init__( 48 | self, 49 | req_port: MidoPort, 50 | res_port: MidoPort, 51 | ) -> None: 52 | """ 53 | Flapi communications manager. 54 | 55 | This class is responsible for exchanging MIDI messages with FL Studio. 56 | """ 57 | self.req_port = req_port 58 | self.res_port = res_port 59 | 60 | # Variables used to collect split MIDI messages 61 | self.__incoming_data: FlapiMsg | None = None 62 | 63 | def send_message( 64 | self, 65 | client_id: int, 66 | message_type: MessageType | int, 67 | status: MessageStatus, 68 | additional_data: bytes | None = None, 69 | ) -> None: 70 | """ 71 | Send a message to the Flapi server. 72 | 73 | This handles splitting additional data to ensure we avoid buffer 74 | overflows. 75 | """ 76 | msg = FlapiMsg( 77 | MessageOrigin.CLIENT, 78 | client_id, 79 | message_type, 80 | status, 81 | additional_data, 82 | ) 83 | # Send all messages 84 | for m in msg.to_bytes(): 85 | self.req_port.send(MidoMsg("sysex", data=m)) 86 | 87 | def send_message_raw(self, data: bytes) -> None: 88 | """ 89 | Send a raw MIDI message. 90 | 91 | ## Args 92 | 93 | * data (`bytes`): data to send 94 | """ 95 | self.req_port.send(MidoMsg("sysex", data=data)) 96 | 97 | def try_receive_message(self) -> FlapiMsg | bytes | None: 98 | """ 99 | Receive a message from the Flapi server, targeting the given client. 100 | 101 | This handles joining additional data. 102 | 103 | Note that messages may target different clients. 104 | 105 | ## Returns 106 | 107 | * `FlapiMsg`: when a complete Flapi API message is received. 108 | * `bytes`: when any other MIDI message is received. 109 | * `None`: when no message has been received. 110 | """ 111 | mido_msg = self.res_port.receive(block=False) 112 | if mido_msg is None: 113 | return None 114 | 115 | # We received something 116 | # Make sure to remove the start and end bits to simplify processing 117 | try: 118 | msg = FlapiMsg(bytes(mido_msg.bytes()[1:-1])) 119 | except FlapiInvalidMsgError: 120 | # Error parsing FlapiMsg, return plain bytes 121 | return mido_msg.bytes() 122 | # Check if we need to append to a previous message 123 | if self.__incoming_data is not None: 124 | self.__incoming_data.append(msg) 125 | else: 126 | self.__incoming_data = msg 127 | 128 | if self.__incoming_data.continuation: 129 | # Continuation byte set, message not finalised 130 | return None 131 | else: 132 | # Continuation byte not set, message finished 133 | temp = self.__incoming_data 134 | self.__incoming_data = None 135 | return temp 136 | 137 | def receive_message(self) -> FlapiMsg | bytes: 138 | """ 139 | Wait for a message response. 140 | 141 | Note that messages may target different clients. 142 | 143 | ## Raises 144 | * `FlapiTimeoutError`: `consts.TIMEOUT_DURATION` was exceeded. 145 | 146 | ## Returns 147 | 148 | * `FlapiMsg`: when a complete Flapi API message is received. 149 | * `bytes`: when any other MIDI message is received. 150 | """ 151 | start_time = time.time() 152 | 153 | while time.time() < start_time + consts.TIMEOUT_DURATION: 154 | # Busy wait for a message 155 | if (msg := self.try_receive_message()) is not None: 156 | return msg 157 | 158 | raise FlapiTimeoutError( 159 | "Flapi didn't receive a message within the timeout window. Is FL " 160 | "Studio running?" 161 | ) 162 | -------------------------------------------------------------------------------- /flapi/client/ports.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client / Ports 3 | 4 | Code for accessing MIDI ports. 5 | """ 6 | import logging 7 | import mido # type: ignore 8 | from flapi.types import MidoPort 9 | from typing import Protocol, Optional 10 | from ..errors import FlapiPortError 11 | 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class OpenPortFn(Protocol): 17 | """Function that opens a Mido port""" 18 | 19 | def __call__(self, *, name: str, virtual: bool = False) -> MidoPort: 20 | ... 21 | 22 | 23 | def open_port( 24 | port_name: str, 25 | port_names: list[str], 26 | open: OpenPortFn, 27 | ) -> Optional[MidoPort]: 28 | """ 29 | Connect to a port which matches the given name, and if one cannot be found, 30 | attempt to create it 31 | """ 32 | for curr_port_name in port_names: # type: ignore 33 | # If the only thing after it is a number, we are free to connect to it 34 | # It seems that something appends these numbers to each MIDI device to 35 | # make them more unique or something 36 | if port_name.lower() not in curr_port_name.lower(): 37 | continue 38 | try: 39 | # If this works, it's a match 40 | int(curr_port_name.replace(port_name, '').strip()) 41 | except Exception: 42 | continue 43 | 44 | # Connect to it 45 | return open(name=curr_port_name) # type: ignore 46 | 47 | # If we reach this point, no match was found 48 | return None 49 | 50 | 51 | def connect_to_ports( 52 | req_port: str, 53 | res_port: str, 54 | ) -> tuple[MidoPort, MidoPort]: 55 | """ 56 | Enable Flapi, connecting it to the given MIDI ports 57 | 58 | 1. Attempt to connect to the port names provided 59 | 2. Decorate the API functions 60 | 3. Attempt to initialize the connection by running setup commands in FL 61 | Studio. 62 | 63 | ## Returns: 64 | 65 | * `bool`: whether the initialization was a success. If this is `False`, you 66 | will need to call `init()` once FL Studio is running and configured 67 | correctly. 68 | """ 69 | # First, connect to all the MIDI ports 70 | res_ports = mido.get_input_names() # type: ignore 71 | req_ports = mido.get_output_names() # type: ignore 72 | 73 | log.info(f"Available request ports are: {req_ports}") 74 | log.info(f"Available response ports are: {res_ports}") 75 | 76 | try: 77 | res = open_port(res_port, res_ports, mido.open_input) # type: ignore 78 | except Exception: 79 | log.exception("Error when connecting to input") 80 | raise 81 | try: 82 | req = open_port(req_port, req_ports, mido.open_output) # type: ignore 83 | except Exception: 84 | log.exception("Error when connecting to output") 85 | raise 86 | 87 | if res is None or req is None: 88 | try: 89 | req = mido.open_output( # type: ignore 90 | name=req_port, 91 | virtual=True, 92 | ) 93 | res = mido.open_input( # type: ignore 94 | name=res_port, 95 | virtual=True, 96 | ) 97 | except NotImplementedError as e: 98 | # Port could not be opened 99 | log.exception("Could not open create new port") 100 | raise FlapiPortError((req_port, res_port)) from e 101 | 102 | assert req is not None and res is not None 103 | return req, res 104 | -------------------------------------------------------------------------------- /flapi/device_flapi_receive.py: -------------------------------------------------------------------------------- 1 | # name=Flapi Request 2 | # supportedDevices=Flapi Request 3 | """ 4 | # Flapi / Server / Flapi Receive 5 | 6 | Responsible for receiving request messages from the Flapi Client. 7 | 8 | It attaches to the "Flapi Request" device and handles messages before sending 9 | a response via the "Flapi Respond" script. 10 | """ 11 | import sys 12 | import logging 13 | import device 14 | from pathlib import Path 15 | from typing import Optional 16 | from base64 import b64decode, b64encode 17 | 18 | # Add the dir containing flapi to the PATH, so that imports work 19 | sys.path.append(str(Path(__file__).parent.parent)) 20 | 21 | # These imports need lint ignores, since they depend on the path modification 22 | # above 23 | 24 | from flapi import _consts as consts # noqa: E402 25 | from flapi._consts import ( # noqa: E402 26 | MessageStatus, 27 | MessageOrigin, 28 | MessageType, 29 | ) 30 | from flapi.server.capout import Capout # noqa: E402 31 | from flapi.server.client_context import ClientContext # noqa: E402 32 | from flapi.flapi_msg import FlapiMsg # noqa: E402 33 | from flapi.types import ScopeType 34 | 35 | try: 36 | from fl_classes import FlMidiMsg 37 | except ImportError: 38 | pass 39 | 40 | 41 | log = logging.getLogger(__name__) 42 | 43 | 44 | def send_stdout(text: str): 45 | """ 46 | Callback for Capout, sending stdout to the client console 47 | """ 48 | # Target all devices 49 | for msg in FlapiMsg( 50 | MessageOrigin.SERVER, 51 | capout.target, 52 | MessageType.STDOUT, 53 | MessageStatus.OK, 54 | b64encode(text.encode()), 55 | ).to_bytes(): 56 | # We should only have one receiver (at index 0) 57 | device.dispatch(0, 0xF0, msg) 58 | 59 | 60 | capout = Capout(send_stdout) 61 | 62 | 63 | ############################################################################### 64 | 65 | 66 | clients: dict[int, ClientContext] = {} 67 | 68 | 69 | def version_query( 70 | client_id: int, 71 | status_code: int, 72 | msg_data: Optional[bytes], 73 | context: ClientContext, 74 | ) -> tuple[int, bytes]: 75 | """ 76 | Request the server version 77 | """ 78 | return MessageStatus.OK, bytes(consts.VERSION) 79 | 80 | 81 | def register_message_type( 82 | client_id: int, 83 | status_code: int, 84 | msg_data: Optional[bytes], 85 | context: ClientContext, 86 | ) -> tuple[int, bytes]: 87 | """ 88 | Register a new message type 89 | """ 90 | assert msg_data 91 | # TODO 92 | 93 | 94 | def OnInit(): 95 | print("\n".join([ 96 | "Flapi request server", 97 | f"Server version: {'.'.join(str(n) for n in consts.VERSION)}", 98 | f"Device name: {device.getName()}", 99 | f"Device assigned: {bool(device.isAssigned())}", 100 | f"FL Studio port number: {device.getPortNumber()}", 101 | ])) 102 | 103 | 104 | def OnSysEx(event: 'FlMidiMsg'): 105 | msg = FlapiMsg(event.sysex) 106 | -------------------------------------------------------------------------------- /flapi/device_flapi_respond.py: -------------------------------------------------------------------------------- 1 | # name=Flapi Response 2 | # supportedDevices=Flapi Response 3 | # receiveFrom=Flapi Request 4 | """ 5 | # Flapi / Server / Flapi Respond 6 | 7 | Responsible for sending response messages from the Flapi Server within FL 8 | Studio. 9 | 10 | It attaches to the "Flapi Response" device and sends MIDI messages back to the 11 | Flapi client. 12 | """ 13 | import device 14 | from _consts import MessageOrigin, MessageType, SYSEX_HEADER, VERSION 15 | 16 | try: 17 | from fl_classes import FlMidiMsg 18 | except ImportError: 19 | pass 20 | 21 | 22 | def OnInit(): 23 | print("\n".join([ 24 | "Flapi response server", 25 | f"Server version: {'.'.join(str(n) for n in VERSION)}", 26 | f"Device name: {device.getName()}", 27 | f"Device assigned: {bool(device.isAssigned())}", 28 | f"FL Studio port number: {device.getPortNumber()}", 29 | ])) 30 | 31 | 32 | # def print_msg(name: str, msg: bytes): 33 | # print(f"{name}: {[hex(b) for b in msg]}") 34 | 35 | 36 | def OnSysEx(event: 'FlMidiMsg'): 37 | header = event.sysex[1:len(SYSEX_HEADER)+1] # Sysex header 38 | # print_msg("Header", header) 39 | # Remaining sysex data 40 | sysex_data = event.sysex[len(SYSEX_HEADER)+1:] 41 | # print_msg("Data", sysex_data) 42 | 43 | # Ignore events that don't target the respond script 44 | if header != SYSEX_HEADER: 45 | return 46 | 47 | # Check message origin 48 | if sysex_data[0] != MessageOrigin.INTERNAL: 49 | return 50 | 51 | # Forward message back to client 52 | # print_msg( 53 | # "Result", 54 | # ( 55 | # bytes([0xF0]) 56 | # + SYSEX_HEADER 57 | # + bytes([MessageOrigin.SERVER]) 58 | # + sysex_data[1:] 59 | # ) 60 | # ) 61 | 62 | device.midiOutSysex( 63 | bytes([0xF0]) 64 | + SYSEX_HEADER 65 | + bytes([MessageOrigin.SERVER]) 66 | + sysex_data[1:] 67 | ) 68 | 69 | 70 | def OnDeInit(): 71 | """ 72 | Send server goodbye message 73 | """ 74 | device.midiOutSysex( 75 | bytes([0xF0]) 76 | + SYSEX_HEADER 77 | + bytes([MessageOrigin.SERVER]) 78 | # Target all clients by giving 0x00 client ID 79 | + bytes([0x00]) 80 | + bytes([MessageType.SERVER_GOODBYE]) 81 | + bytes([0xF7]) 82 | ) 83 | -------------------------------------------------------------------------------- /flapi/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Errors 3 | 4 | Error classes used within FlApi 5 | """ 6 | from .__util import bytes_to_str 7 | 8 | 9 | class FlapiPortError(IOError): 10 | """ 11 | Unable to open a MIDI port 12 | 13 | On Windows, this happens when trying to create a virtual MIDI port, since 14 | it is currently impossible to do so without a kernel-mode driver for some 15 | reason. 16 | """ 17 | 18 | def __init__(self, port_names: tuple[str, str]) -> None: 19 | super().__init__( 20 | f"Could not create ports {port_names}. On Windows, you need to " 21 | f"use software such as Loop MIDI " 22 | f"(https://www.tobias-erichsen.de/software/loopmidi.html) to " 23 | f"create the required ports yourself, as doing so requires a " 24 | f"kernel-mode driver, which cannot be bundled in a Python library." 25 | ) 26 | 27 | 28 | class FlapiConnectionError(ConnectionError): 29 | """ 30 | Flapi was able to connect to the MIDI port, but didn't receive a response 31 | from the server. 32 | """ 33 | 34 | 35 | class FlapiContextError(Exception): 36 | """ 37 | Flapi wasn't initialised, so its context could not be loaded 38 | """ 39 | 40 | def __init__(self) -> None: 41 | """ 42 | Flapi wasn't initialised, so its context could not be loaded 43 | """ 44 | super().__init__( 45 | "Could not find Flapi context. Perhaps you haven't initialised " 46 | "Flapi by calling `flapi.enable()`." 47 | ) 48 | 49 | 50 | class FlapiVersionError(Exception): 51 | """ 52 | The version of the Flapi server doesn't match that of the Flapi client 53 | """ 54 | 55 | 56 | class FlapiInvalidMsgError(ValueError): 57 | """ 58 | Flapi unexpectedly received a MIDI message that it could not process 59 | """ 60 | 61 | def __init__(self, msg: bytes, context: str = "") -> None: 62 | """ 63 | Flapi unexpectedly received a MIDI message that it could not process 64 | """ 65 | super().__init__( 66 | f"Flapi received a message that it didn't understand. Perhaps " 67 | f"another device is communicating on Flapi's MIDI port. Message " 68 | f"received: {bytes_to_str(msg)}\n" 69 | + f"Context: {context}" if context else "" 70 | ) 71 | 72 | 73 | class FlapiServerError(Exception): 74 | """ 75 | An unexpected error occurred on the server side. 76 | 77 | Ensure that the Flapi server and client have matching versions. 78 | """ 79 | 80 | def __init__(self, msg: str) -> None: 81 | """ 82 | An unexpected error occurred on the server side. 83 | 84 | Ensure that the Flapi server and client have matching versions. 85 | """ 86 | super().__init__( 87 | f"An unexpected server error occurred due to a miscommunication. " 88 | f"Please ensure the Flapi server version matches that of the " 89 | f"Flapi client by running the `flapi install` command. " 90 | f"If they do match, please open a bug report. " 91 | f"Failure message: {msg}" 92 | ) 93 | 94 | 95 | class FlapiServerExit(Exception): 96 | """ 97 | The Flapi server exited. 98 | """ 99 | 100 | def __init__(self) -> None: 101 | """ 102 | The Flapi server exited. 103 | """ 104 | super().__init__( 105 | "The Flapi server exited, likely because FL Studio was closed." 106 | ) 107 | 108 | 109 | class FlapiClientExit(SystemExit): 110 | """ 111 | The flapi client requested to exit 112 | """ 113 | 114 | def __init__(self, code: int) -> None: 115 | """ 116 | The flapi client requested to exit 117 | """ 118 | super().__init__(code, "The flapi client requested to exit") 119 | 120 | 121 | class FlapiClientError(Exception): 122 | """ 123 | An unexpected error occurred on the client side. 124 | 125 | Ensure that the Flapi server and client have matching versions. 126 | """ 127 | 128 | def __init__(self, msg: str) -> None: 129 | """ 130 | An unexpected error occurred on the client side. 131 | 132 | Ensure that the Flapi server and client have matching versions. 133 | """ 134 | super().__init__( 135 | f"An unexpected client error occurred due to a miscommunication. " 136 | f"Please ensure the Flapi server version matches that of the " 137 | f"Flapi client by running the `flapi install` command. " 138 | f"If they do match, please open a bug report. " 139 | f"Failure message: {msg}" 140 | ) 141 | 142 | 143 | class FlapiTimeoutError(TimeoutError): 144 | """ 145 | Flapi didn't receive a MIDI message within the timeout window 146 | """ 147 | -------------------------------------------------------------------------------- /flapi/flapi_msg.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Client / Flapi Msg 3 | 4 | Wrapper class for MIDI messages sent/received by Flapi. 5 | """ 6 | from flapi import _consts as consts 7 | from flapi._consts import MessageType, MessageOrigin, MessageStatus 8 | from typing import overload 9 | import itertools as iter 10 | from .errors import FlapiInvalidMsgError 11 | 12 | 13 | class FlapiMsg: 14 | """ 15 | Wrapper for Flapi messages, allowing for convenient access to their 16 | properties. 17 | """ 18 | @overload 19 | def __init__( 20 | self, 21 | data: bytes, 22 | /, 23 | ) -> None: 24 | ... 25 | 26 | @overload 27 | def __init__( 28 | self, 29 | origin: MessageOrigin, 30 | client_id: int, 31 | msg_type: MessageType | int, 32 | status_code: MessageStatus, 33 | additional_data: bytes | None = None, 34 | /, 35 | ) -> None: 36 | ... 37 | 38 | def __init__( 39 | self, 40 | origin_data: MessageOrigin | bytes, 41 | client_id: int | None = None, 42 | msg_type: MessageType | int | None = None, 43 | status: MessageStatus | None = None, 44 | additional_data: bytes | None = None, 45 | /, 46 | ) -> None: 47 | if isinstance(origin_data, (MessageOrigin, int)): 48 | self.origin: MessageOrigin = origin_data 49 | self.client_id: int = client_id # type: ignore 50 | self.continuation = False 51 | self.msg_type: MessageType | int = msg_type # type: ignore 52 | self.status_code: MessageStatus = status # type: ignore 53 | self.additional_data: bytes = ( 54 | additional_data 55 | if additional_data is not None 56 | else bytes() 57 | ) 58 | else: 59 | # Check header validity 60 | header = origin_data[1:7] 61 | if header != consts.SYSEX_HEADER: 62 | raise FlapiInvalidMsgError(origin_data) 63 | 64 | # Extract data 65 | self.origin = MessageOrigin(origin_data[7]) 66 | self.client_id = bytes(origin_data)[8] 67 | # Continuation byte is used to control whether additional messages 68 | # can be appended 69 | self.continuation = bool(origin_data[9]) 70 | self.msg_type = bytes(origin_data)[10] 71 | self.status_code = MessageStatus(origin_data[11]) 72 | self.additional_data = origin_data[12:-1] 73 | # Trim off the 0xF7 from the end ^^ 74 | 75 | def append(self, other: 'FlapiMsg') -> None: 76 | """ 77 | Append another Flapi message to this message. 78 | 79 | This works by merging the data bytes. 80 | 81 | ## Args 82 | 83 | * `other` (`FlapiMsg`): other message to append. 84 | """ 85 | if not self.continuation: 86 | raise FlapiInvalidMsgError( 87 | b''.join(other.to_bytes()), 88 | "Cannot append to FlapiMsg if continuation byte is not set", 89 | ) 90 | 91 | # Check other properties are the same 92 | assert self.origin == other.origin 93 | assert self.client_id == other.client_id 94 | assert self.msg_type == other.msg_type 95 | assert self.status_code == other.status_code 96 | 97 | self.continuation = other.continuation 98 | self.additional_data += other.additional_data 99 | 100 | def to_bytes(self) -> list[bytes]: 101 | """ 102 | Convert the message into bytes, in preparation for being sent. 103 | 104 | This automatically handles the splitting of MIDI messages. 105 | 106 | Note that each message does not contain the leading 0xF0, or trailing 107 | 0xF7 required by sysex messages. This is because Mido adds these 108 | automatically. 109 | 110 | ## Returns 111 | 112 | * `list[bytes]`: MIDI message(s) to send. 113 | """ 114 | msgs: list[bytes] = [] 115 | 116 | # Append in reverse, so we can easily detect the last element (which 117 | # shouldn't have its "continuation" byte set) 118 | first = True 119 | for data in reversed(list( 120 | iter.batched(self.additional_data, consts.MAX_DATA_LEN) 121 | )): 122 | msgs.insert(0, bytes( 123 | consts.SYSEX_HEADER 124 | + bytes([ 125 | self.origin, 126 | self.client_id, 127 | first, 128 | self.msg_type, 129 | self.status_code, 130 | ]) 131 | + bytes(data) 132 | )) 133 | first = False 134 | 135 | return msgs 136 | -------------------------------------------------------------------------------- /flapi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/flapi/py.typed -------------------------------------------------------------------------------- /flapi/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyGuthridge/Flapi/b2aab28ff3827e558d4e145bf34215108717258f/flapi/server/__init__.py -------------------------------------------------------------------------------- /flapi/server/capout.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi > Script > Capout 3 | 4 | Simple class for managing the capturing of stdout in FL Studio. 5 | 6 | TODO: Set up a callback to be triggered whenever a 7 | """ 8 | import sys 9 | try: 10 | # This is the module in most Python installs, used for type safety 11 | from io import StringIO, TextIOBase 12 | except ImportError: 13 | # This is the module in FL Studio for some reason 14 | from _io import StringIO, _TextIOBase as TextIOBase # type: ignore 15 | try: 16 | from typing import Optional, Callable, Self 17 | except ImportError: 18 | pass 19 | 20 | 21 | class CapoutBuffer(TextIOBase): 22 | """ 23 | Custom buffer wrapping a StringIO, so that we can implement a callback 24 | whenever buffer is flushed, and flush it to the client. 25 | 26 | This is probably awful design, but it seems to work so I'm keeping it until 27 | I feel like writing something nicer. 28 | """ 29 | 30 | def __init__(self, callback: 'Callable[[str], None]') -> None: 31 | self.__callback = callback 32 | self.__buf = StringIO() 33 | 34 | def close(self): 35 | return self.__buf.close() 36 | 37 | @property 38 | def closed(self) -> bool: 39 | return self.__buf.closed 40 | 41 | def fileno(self) -> int: 42 | return self.__buf.fileno() 43 | 44 | def flush(self) -> None: 45 | self.__buf.flush() 46 | self.__buf.seek(0) 47 | text = self.__buf.read() 48 | self.__callback(text) 49 | self.__buf = StringIO() 50 | 51 | def isatty(self) -> bool: 52 | return self.__buf.isatty() 53 | 54 | def readable(self) -> bool: 55 | return self.__buf.readable() 56 | 57 | def readline(self, size=-1, /) -> str: 58 | return self.__buf.readline(size) 59 | 60 | def readlines(self, hint=-1, /) -> list[str]: 61 | return self.__buf.readlines(hint) 62 | 63 | def seek(self, offset: int, whence=0, /) -> int: 64 | return self.__buf.seek(offset, whence) 65 | 66 | def seekable(self) -> bool: 67 | return self.__buf.seekable() 68 | 69 | def tell(self) -> int: 70 | return self.__buf.tell() 71 | 72 | def truncate(self, size: 'Optional[int]' = None, /) -> int: 73 | return self.__buf.truncate(size) 74 | 75 | def writable(self) -> bool: 76 | return self.__buf.writable() 77 | 78 | def writelines(self, lines: list[str], /) -> None: 79 | return self.__buf.writelines(lines) 80 | 81 | @property 82 | def encoding(self): 83 | return self.__buf.encoding 84 | 85 | @property 86 | def errors(self): 87 | return self.__buf.errors 88 | 89 | @property 90 | def newlines(self): 91 | return self.__buf.newlines 92 | 93 | @property 94 | def buffer(self): 95 | return self.__buf.buffer 96 | 97 | def detach(self): 98 | return self.__buf.detach() 99 | 100 | def read(self, size=-1, /) -> str: 101 | return self.__buf.read(size) 102 | 103 | def write(self, s: str, /) -> int: 104 | return self.__buf.write(s) 105 | 106 | 107 | class Capout: 108 | """ 109 | Capture stdout in FL Studio 110 | """ 111 | 112 | def __init__(self, callback: 'Callable[[str], None]') -> None: 113 | self.enabled = False 114 | self.real_stdout = sys.stdout 115 | self.fake_stdout = CapoutBuffer(callback) 116 | self.target = 0 117 | 118 | def __call__(self, target: int) -> Self: 119 | self.target = target 120 | return self 121 | 122 | def __enter__(self) -> None: 123 | self.enable() 124 | 125 | def __exit__(self, exc_type, exc_val, exc_tb) -> None: 126 | self.flush() 127 | self.disable() 128 | self.target = 0 129 | 130 | def flush(self) -> None: 131 | if self.enabled: 132 | self.fake_stdout.flush() 133 | 134 | def enable(self): 135 | self.enabled = True 136 | sys.stdout = self.fake_stdout 137 | 138 | def disable(self): 139 | self.enabled = False 140 | sys.stdout = self.real_stdout 141 | 142 | def fl_print(self, *args, **kwargs) -> None: 143 | """ 144 | Print to FL Studio's output 145 | """ 146 | print(*args, **kwargs, file=self.real_stdout) 147 | 148 | def client_print(self, *args, **kwargs) -> None: 149 | """ 150 | Print to the client's output 151 | """ 152 | print(*args, **kwargs, file=self.fake_stdout) 153 | -------------------------------------------------------------------------------- /flapi/server/client_context.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi Server / Client Context 3 | """ 4 | from flapi.types import ServerMessageHandler, ScopeType 5 | 6 | 7 | class ClientContext: 8 | """ 9 | Context of a Flapi client. 10 | 11 | Contains the execution scope of the client as well as its registered 12 | callbacks for the various message types. 13 | """ 14 | def __init__(self) -> None: 15 | self.scope: ScopeType = {} 16 | self.message_handlers: dict[int, ServerMessageHandler] = {} 17 | -------------------------------------------------------------------------------- /flapi/types/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Types 3 | 4 | Type definitions used by Flapi. 5 | """ 6 | from .mido_types import MidoPort, MidoMsg 7 | from .scope import ScopeType 8 | from .message_handler import ServerMessageHandler 9 | 10 | 11 | __all__ = [ 12 | 'MidoPort', 13 | 'MidoMsg', 14 | 'ServerMessageHandler', 15 | 'ScopeType', 16 | ] 17 | -------------------------------------------------------------------------------- /flapi/types/message_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Types / Message Handler 3 | """ 4 | from typing import Optional, Protocol 5 | from flapi.server.client_context import ClientContext 6 | 7 | 8 | class ServerMessageHandler(Protocol): 9 | """ 10 | Function to be executed on the Flapi server. This function will be called 11 | whenever a message of this type is sent to the server. 12 | 13 | ## Args of handler function 14 | 15 | * `client_id`: ID of client. 16 | * `status_code`: status code sent by client. 17 | * `msg_data`: optional additional bytes. 18 | * `scope`: local scope to use when executing arbitrary code. 19 | 20 | ## Returns of handler function 21 | 22 | * `int` status code 23 | * `bytes` additional data 24 | """ 25 | def __call__( 26 | self, 27 | client_id: int, 28 | status_code: int, 29 | msg_data: Optional[bytes], 30 | context: ClientContext, 31 | ) -> int | tuple[int, bytes]: 32 | ... 33 | -------------------------------------------------------------------------------- /flapi/types/mido_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Types / Mido 3 | 4 | Type definitions for classes and interfaces used by Mido. 5 | """ 6 | import mido # type: ignore 7 | from typing import Protocol, overload, Literal, TYPE_CHECKING 8 | 9 | 10 | MessageType = Literal["sysex", "note_on", "note_off"] 11 | 12 | 13 | if TYPE_CHECKING: 14 | class MidoMsg: 15 | def __init__(self, type: str, *, data: bytes | None = None) -> None: 16 | super().__init__() 17 | 18 | def bytes(self) -> bytes: 19 | ... 20 | 21 | class MidoPort(Protocol): 22 | def send(self, msg: MidoMsg): 23 | ... 24 | 25 | @overload 26 | def receive(self, block: Literal[True] = True) -> MidoMsg: 27 | ... 28 | 29 | @overload 30 | def receive(self, block: Literal[False]) -> MidoMsg | None: 31 | ... 32 | 33 | def receive(self, block: bool = True) -> MidoMsg | None: 34 | ... 35 | else: 36 | MidoMsg = mido.Message 37 | MidoPort = mido.ports.BaseIOPort 38 | -------------------------------------------------------------------------------- /flapi/types/scope.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flapi / Types / Scope 3 | """ 4 | from typing import Any 5 | 6 | 7 | ScopeType = dict[str, Any] 8 | """ 9 | Represents a variable scope in Python 10 | """ 11 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asttokens" 5 | version = "2.4.1" 6 | description = "Annotate AST trees with source code positions" 7 | optional = true 8 | python-versions = "*" 9 | groups = ["main"] 10 | markers = "extra == \"ipython\"" 11 | files = [ 12 | {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, 13 | {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, 14 | ] 15 | 16 | [package.dependencies] 17 | six = ">=1.12.0" 18 | 19 | [package.extras] 20 | astroid = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\""] 21 | test = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\"", "pytest"] 22 | 23 | [[package]] 24 | name = "click" 25 | version = "8.1.8" 26 | description = "Composable command line interface toolkit" 27 | optional = false 28 | python-versions = ">=3.7" 29 | groups = ["main"] 30 | files = [ 31 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 32 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 33 | ] 34 | 35 | [package.dependencies] 36 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 37 | 38 | [[package]] 39 | name = "click-default-group" 40 | version = "1.2.4" 41 | description = "click_default_group" 42 | optional = false 43 | python-versions = ">=2.7" 44 | groups = ["main"] 45 | files = [ 46 | {file = "click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f"}, 47 | {file = "click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e"}, 48 | ] 49 | 50 | [package.dependencies] 51 | click = "*" 52 | 53 | [package.extras] 54 | test = ["pytest"] 55 | 56 | [[package]] 57 | name = "colorama" 58 | version = "0.4.6" 59 | description = "Cross-platform colored terminal text." 60 | optional = false 61 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 62 | groups = ["main"] 63 | markers = "extra == \"ipython\" and sys_platform == \"win32\" or platform_system == \"Windows\"" 64 | files = [ 65 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 66 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 67 | ] 68 | 69 | [[package]] 70 | name = "decorator" 71 | version = "5.1.1" 72 | description = "Decorators for Humans" 73 | optional = true 74 | python-versions = ">=3.5" 75 | groups = ["main"] 76 | markers = "extra == \"ipython\"" 77 | files = [ 78 | {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 79 | {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 80 | ] 81 | 82 | [[package]] 83 | name = "executing" 84 | version = "2.0.1" 85 | description = "Get the currently executing AST node of a frame, and other information" 86 | optional = true 87 | python-versions = ">=3.5" 88 | groups = ["main"] 89 | markers = "extra == \"ipython\"" 90 | files = [ 91 | {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, 92 | {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, 93 | ] 94 | 95 | [package.extras] 96 | tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] 97 | 98 | [[package]] 99 | name = "fl-studio-api-stubs" 100 | version = "37.0.1" 101 | description = "Stub code, type definitions and documentation for the FL Studio Python API" 102 | optional = false 103 | python-versions = "<4.0,>=3.10" 104 | groups = ["main"] 105 | files = [ 106 | {file = "fl_studio_api_stubs-37.0.1-py3-none-any.whl", hash = "sha256:34725b81859edd3d424aa74f0673c1874b26bee33af32c513189792fed2cd1b7"}, 107 | {file = "fl_studio_api_stubs-37.0.1.tar.gz", hash = "sha256:e1f3e94d4d115fb2089804ad18166bb6b76dcd54ecd7548f77d6f2af29d5b0b4"}, 108 | ] 109 | 110 | [[package]] 111 | name = "flake8" 112 | version = "7.1.0" 113 | description = "the modular source code checker: pep8 pyflakes and co" 114 | optional = false 115 | python-versions = ">=3.8.1" 116 | groups = ["dev"] 117 | files = [ 118 | {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"}, 119 | {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"}, 120 | ] 121 | 122 | [package.dependencies] 123 | mccabe = ">=0.7.0,<0.8.0" 124 | pycodestyle = ">=2.12.0,<2.13.0" 125 | pyflakes = ">=3.2.0,<3.3.0" 126 | 127 | [[package]] 128 | name = "ipython" 129 | version = "9.2.0" 130 | description = "IPython: Productive Interactive Computing" 131 | optional = true 132 | python-versions = ">=3.11" 133 | groups = ["main"] 134 | markers = "extra == \"ipython\"" 135 | files = [ 136 | {file = "ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6"}, 137 | {file = "ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b"}, 138 | ] 139 | 140 | [package.dependencies] 141 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 142 | decorator = "*" 143 | ipython-pygments-lexers = "*" 144 | jedi = ">=0.16" 145 | matplotlib-inline = "*" 146 | pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} 147 | prompt_toolkit = ">=3.0.41,<3.1.0" 148 | pygments = ">=2.4.0" 149 | stack_data = "*" 150 | traitlets = ">=5.13.0" 151 | 152 | [package.extras] 153 | all = ["ipython[doc,matplotlib,test,test-extra]"] 154 | black = ["black"] 155 | doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"] 156 | matplotlib = ["matplotlib"] 157 | test = ["packaging", "pytest", "pytest-asyncio (<0.22)", "testpath"] 158 | test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"] 159 | 160 | [[package]] 161 | name = "ipython-pygments-lexers" 162 | version = "1.1.1" 163 | description = "Defines a variety of Pygments lexers for highlighting IPython code." 164 | optional = true 165 | python-versions = ">=3.8" 166 | groups = ["main"] 167 | markers = "extra == \"ipython\"" 168 | files = [ 169 | {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, 170 | {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, 171 | ] 172 | 173 | [package.dependencies] 174 | pygments = "*" 175 | 176 | [[package]] 177 | name = "jedi" 178 | version = "0.19.1" 179 | description = "An autocompletion tool for Python that can be used for text editors." 180 | optional = true 181 | python-versions = ">=3.6" 182 | groups = ["main"] 183 | markers = "extra == \"ipython\"" 184 | files = [ 185 | {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, 186 | {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, 187 | ] 188 | 189 | [package.dependencies] 190 | parso = ">=0.8.3,<0.9.0" 191 | 192 | [package.extras] 193 | docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] 194 | qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] 195 | testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] 196 | 197 | [[package]] 198 | name = "matplotlib-inline" 199 | version = "0.1.7" 200 | description = "Inline Matplotlib backend for Jupyter" 201 | optional = true 202 | python-versions = ">=3.8" 203 | groups = ["main"] 204 | markers = "extra == \"ipython\"" 205 | files = [ 206 | {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, 207 | {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, 208 | ] 209 | 210 | [package.dependencies] 211 | traitlets = "*" 212 | 213 | [[package]] 214 | name = "mccabe" 215 | version = "0.7.0" 216 | description = "McCabe checker, plugin for flake8" 217 | optional = false 218 | python-versions = ">=3.6" 219 | groups = ["dev"] 220 | files = [ 221 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 222 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 223 | ] 224 | 225 | [[package]] 226 | name = "mido" 227 | version = "1.3.3" 228 | description = "MIDI Objects for Python" 229 | optional = false 230 | python-versions = "~=3.7" 231 | groups = ["main"] 232 | files = [ 233 | {file = "mido-1.3.3-py3-none-any.whl", hash = "sha256:01033c9b10b049e4436fca2762194ca839b09a4334091dd3c34e7f4ae674fd8a"}, 234 | {file = "mido-1.3.3.tar.gz", hash = "sha256:1aecb30b7f282404f17e43768cbf74a6a31bf22b3b783bdd117a1ce9d22cb74c"}, 235 | ] 236 | 237 | [package.dependencies] 238 | packaging = "*" 239 | python-rtmidi = {version = ">=1.5.4,<1.6.0", optional = true, markers = "extra == \"ports-rtmidi\""} 240 | 241 | [package.extras] 242 | build-docs = ["sphinx (>=4.3.2,<4.4.0)", "sphinx-rtd-theme (>=1.2.2,<1.3.0)"] 243 | check-manifest = ["check-manifest (>=0.49)"] 244 | dev = ["mido[build-docs]", "mido[check-manifest]", "mido[lint-code]", "mido[lint-reuse]", "mido[release]", "mido[test-code]"] 245 | lint-code = ["ruff (>=0.1.6,<0.2.0)"] 246 | lint-reuse = ["reuse (>=1.1.2,<1.2.0)"] 247 | ports-all = ["mido[ports-pygame]", "mido[ports-rtmidi-python]", "mido[ports-rtmidi]"] 248 | ports-pygame = ["PyGame (>=2.5,<3.0)"] 249 | ports-rtmidi = ["python-rtmidi (>=1.5.4,<1.6.0)"] 250 | ports-rtmidi-python = ["rtmidi-python (>=0.2.2,<0.3.0)"] 251 | release = ["twine (>=4.0.2,<4.1.0)"] 252 | test-code = ["pytest (>=7.4.0,<7.5.0)"] 253 | 254 | [[package]] 255 | name = "mypy" 256 | version = "1.14.1" 257 | description = "Optional static typing for Python" 258 | optional = false 259 | python-versions = ">=3.8" 260 | groups = ["dev"] 261 | files = [ 262 | {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, 263 | {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, 264 | {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, 265 | {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, 266 | {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, 267 | {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, 268 | {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, 269 | {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, 270 | {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, 271 | {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, 272 | {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, 273 | {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, 274 | {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, 275 | {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, 276 | {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, 277 | {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, 278 | {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, 279 | {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, 280 | {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, 281 | {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, 282 | {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, 283 | {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, 284 | {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, 285 | {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, 286 | {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, 287 | {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, 288 | {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, 289 | {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, 290 | {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, 291 | {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, 292 | {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, 293 | {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, 294 | {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, 295 | {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, 296 | {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, 297 | {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, 298 | {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, 299 | {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, 300 | ] 301 | 302 | [package.dependencies] 303 | mypy_extensions = ">=1.0.0" 304 | typing_extensions = ">=4.6.0" 305 | 306 | [package.extras] 307 | dmypy = ["psutil (>=4.0)"] 308 | faster-cache = ["orjson"] 309 | install-types = ["pip"] 310 | mypyc = ["setuptools (>=50)"] 311 | reports = ["lxml"] 312 | 313 | [[package]] 314 | name = "mypy-extensions" 315 | version = "1.0.0" 316 | description = "Type system extensions for programs checked with the mypy type checker." 317 | optional = false 318 | python-versions = ">=3.5" 319 | groups = ["dev"] 320 | files = [ 321 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 322 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 323 | ] 324 | 325 | [[package]] 326 | name = "packaging" 327 | version = "23.2" 328 | description = "Core utilities for Python packages" 329 | optional = false 330 | python-versions = ">=3.7" 331 | groups = ["main"] 332 | files = [ 333 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 334 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 335 | ] 336 | 337 | [[package]] 338 | name = "parso" 339 | version = "0.8.4" 340 | description = "A Python Parser" 341 | optional = true 342 | python-versions = ">=3.6" 343 | groups = ["main"] 344 | markers = "extra == \"ipython\"" 345 | files = [ 346 | {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, 347 | {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, 348 | ] 349 | 350 | [package.extras] 351 | qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] 352 | testing = ["docopt", "pytest"] 353 | 354 | [[package]] 355 | name = "pexpect" 356 | version = "4.9.0" 357 | description = "Pexpect allows easy control of interactive console applications." 358 | optional = true 359 | python-versions = "*" 360 | groups = ["main"] 361 | markers = "extra == \"ipython\" and sys_platform != \"win32\" and sys_platform != \"emscripten\"" 362 | files = [ 363 | {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, 364 | {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, 365 | ] 366 | 367 | [package.dependencies] 368 | ptyprocess = ">=0.5" 369 | 370 | [[package]] 371 | name = "prompt-toolkit" 372 | version = "3.0.47" 373 | description = "Library for building powerful interactive command lines in Python" 374 | optional = true 375 | python-versions = ">=3.7.0" 376 | groups = ["main"] 377 | markers = "extra == \"ipython\"" 378 | files = [ 379 | {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, 380 | {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, 381 | ] 382 | 383 | [package.dependencies] 384 | wcwidth = "*" 385 | 386 | [[package]] 387 | name = "ptyprocess" 388 | version = "0.7.0" 389 | description = "Run a subprocess in a pseudo terminal" 390 | optional = true 391 | python-versions = "*" 392 | groups = ["main"] 393 | markers = "extra == \"ipython\" and sys_platform != \"win32\" and sys_platform != \"emscripten\"" 394 | files = [ 395 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 396 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 397 | ] 398 | 399 | [[package]] 400 | name = "pure-eval" 401 | version = "0.2.2" 402 | description = "Safely evaluate AST nodes without side effects" 403 | optional = true 404 | python-versions = "*" 405 | groups = ["main"] 406 | markers = "extra == \"ipython\"" 407 | files = [ 408 | {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, 409 | {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, 410 | ] 411 | 412 | [package.extras] 413 | tests = ["pytest"] 414 | 415 | [[package]] 416 | name = "pycodestyle" 417 | version = "2.12.0" 418 | description = "Python style guide checker" 419 | optional = false 420 | python-versions = ">=3.8" 421 | groups = ["dev"] 422 | files = [ 423 | {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, 424 | {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, 425 | ] 426 | 427 | [[package]] 428 | name = "pyflakes" 429 | version = "3.2.0" 430 | description = "passive checker of Python programs" 431 | optional = false 432 | python-versions = ">=3.8" 433 | groups = ["dev"] 434 | files = [ 435 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 436 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 437 | ] 438 | 439 | [[package]] 440 | name = "pygments" 441 | version = "2.18.0" 442 | description = "Pygments is a syntax highlighting package written in Python." 443 | optional = true 444 | python-versions = ">=3.8" 445 | groups = ["main"] 446 | markers = "extra == \"ipython\"" 447 | files = [ 448 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 449 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 450 | ] 451 | 452 | [package.extras] 453 | windows-terminal = ["colorama (>=0.4.6)"] 454 | 455 | [[package]] 456 | name = "python-rtmidi" 457 | version = "1.5.8" 458 | description = "A Python binding for the RtMidi C++ library implemented using Cython." 459 | optional = false 460 | python-versions = ">=3.8" 461 | groups = ["main"] 462 | files = [ 463 | {file = "python_rtmidi-1.5.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efc07413b30b0039c0d35abe25a81d740c7405124eb58eed141a8f24388e6fe0"}, 464 | {file = "python_rtmidi-1.5.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:844bd12840c9d4e03dfc89b2cd57c55dcbf5ed7246504d69c6c661732249b19c"}, 465 | {file = "python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:8bbaf7c7164471712a93ac60c8f9ed146b336a294a5103223bbaf8f10709a0bf"}, 466 | {file = "python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:878ce085dfb65c0974810a7e919f73708cbb4c0430c7924b78f25aea1dd4ebee"}, 467 | {file = "python_rtmidi-1.5.8-cp310-cp310-win_amd64.whl", hash = "sha256:f2138005c6bd3d8b9af05df383679f6d0827d16056e68a941110732310dcb7dd"}, 468 | {file = "python_rtmidi-1.5.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30d117193dcad8af67c600c405f53eb096e4ff84849760be14c97270af334922"}, 469 | {file = "python_rtmidi-1.5.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e234dca7f9d783dd3f1e9c9c5c2f295f02b7af3085301d6eed3b428cf49d327"}, 470 | {file = "python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:271d625c489fffb39b3edc5aba67f7c8e29a04a0a0f056ce19e5a888a08b4c59"}, 471 | {file = "python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:46bbf32c8a4bf6c8f0df1c02a68689d0757f13cb7a69f27ccbbed3d7b2365918"}, 472 | {file = "python_rtmidi-1.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:cfea32c91752fa7aecfe3d6827535c190ba0e646a9accd6604f4fc70cf4b780f"}, 473 | {file = "python_rtmidi-1.5.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5443634597eb340cdec0734f76267a827c2d366f00a6f9195141c78828016ac2"}, 474 | {file = "python_rtmidi-1.5.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29d9c9d9f82ce679fecad7bb4cb79f3a24574ea84600e377194b4cc1baacec0e"}, 475 | {file = "python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:25f5a5db7be98911c41ca5bebb262fcf9a7c89600b88fd3c207ceafd3101e721"}, 476 | {file = "python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cec30924e305f55284594ccf35a71dee7216fd308dfa2dec1b3ed03e6f243803"}, 477 | {file = "python_rtmidi-1.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:052c89933cae4fca354012d8ca7248f4f9e1e3f062471409d48415a7f7d7e59e"}, 478 | {file = "python_rtmidi-1.5.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7bce7f17c71a71d8ef0bfeae3cb8a7652dd02f0d5067de882e1ee44eb38518db"}, 479 | {file = "python_rtmidi-1.5.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d5da765184150fb946043d59be4039b36a8060ede025f109ef20492dbf99075"}, 480 | {file = "python_rtmidi-1.5.8-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:a5582983ad57ea7f0a7797ddc3e258efb00f8326113b6ddfa85b5165a4151806"}, 481 | {file = "python_rtmidi-1.5.8-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:c60dd180e5130fb87571e71aea30e2ef0512131aab45865a7d67063ed8e52ca4"}, 482 | {file = "python_rtmidi-1.5.8-cp38-cp38-win_amd64.whl", hash = "sha256:26149186367341bf5b0a3ac17b495f6a25950bd3da6b4f13d25ac0a9ce8208dd"}, 483 | {file = "python_rtmidi-1.5.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82e61bc1b51aa91d9e615827056e80f78dbe364248eecd61698b233f7af903f6"}, 484 | {file = "python_rtmidi-1.5.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a706e9850e22acc57fa840c60fdc4541baafe462a05ff7631a6d9eb91c65e171"}, 485 | {file = "python_rtmidi-1.5.8-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5966172ed28add6ff2b76d389702931bfc7ff3cc741c0e4b0d1aaae269ab7a8e"}, 486 | {file = "python_rtmidi-1.5.8-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:29661939f9b7bd1a4e29835f50f4790e741dacd21a5cb143297aefb51deefdec"}, 487 | {file = "python_rtmidi-1.5.8-cp39-cp39-win_amd64.whl", hash = "sha256:dd2bcbea822488fca6b8d9fc7e78a91da12914f3b88dc086f051cb65a643449f"}, 488 | {file = "python_rtmidi-1.5.8.tar.gz", hash = "sha256:7f9ade68b068ae09000ecb562ae9521da3a234361ad5449e83fc734544d004fa"}, 489 | ] 490 | 491 | [[package]] 492 | name = "six" 493 | version = "1.16.0" 494 | description = "Python 2 and 3 compatibility utilities" 495 | optional = true 496 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 497 | groups = ["main"] 498 | markers = "extra == \"ipython\"" 499 | files = [ 500 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 501 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 502 | ] 503 | 504 | [[package]] 505 | name = "stack-data" 506 | version = "0.6.3" 507 | description = "Extract data from python stack frames and tracebacks for informative displays" 508 | optional = true 509 | python-versions = "*" 510 | groups = ["main"] 511 | markers = "extra == \"ipython\"" 512 | files = [ 513 | {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, 514 | {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, 515 | ] 516 | 517 | [package.dependencies] 518 | asttokens = ">=2.1.0" 519 | executing = ">=1.2.0" 520 | pure-eval = "*" 521 | 522 | [package.extras] 523 | tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] 524 | 525 | [[package]] 526 | name = "traitlets" 527 | version = "5.14.3" 528 | description = "Traitlets Python configuration system" 529 | optional = true 530 | python-versions = ">=3.8" 531 | groups = ["main"] 532 | markers = "extra == \"ipython\"" 533 | files = [ 534 | {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, 535 | {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, 536 | ] 537 | 538 | [package.extras] 539 | docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] 540 | test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] 541 | 542 | [[package]] 543 | name = "typing-extensions" 544 | version = "4.13.2" 545 | description = "Backported and Experimental Type Hints for Python 3.8+" 546 | optional = false 547 | python-versions = ">=3.8" 548 | groups = ["main", "dev"] 549 | files = [ 550 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 551 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 552 | ] 553 | 554 | [[package]] 555 | name = "wcwidth" 556 | version = "0.2.13" 557 | description = "Measures the displayed width of unicode strings in a terminal" 558 | optional = true 559 | python-versions = "*" 560 | groups = ["main"] 561 | markers = "extra == \"ipython\"" 562 | files = [ 563 | {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, 564 | {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, 565 | ] 566 | 567 | [extras] 568 | ipython = ["ipython"] 569 | 570 | [metadata] 571 | lock-version = "2.1" 572 | python-versions = "^3.12" 573 | content-hash = "380e6f5d87e601e4fd442b3d6cdc30ef5966b278ac8391d31b28d18dcbb08385" 574 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "flapi" 3 | version = "1.0.1" 4 | description = "Remotely control FL Studio using the MIDI Controller Scripting API" 5 | authors = ["Maddy Guthridge "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/MaddyGuthridge/Flapi" 9 | 10 | keywords = [ 11 | "fl", 12 | "studio", 13 | "fl studio", 14 | "midi", 15 | "script", 16 | "midi controller scripting", 17 | "remote", 18 | "remote control", 19 | ] 20 | 21 | classifiers = [ 22 | "Programming Language :: Python :: 3", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: Microsoft :: Windows", 26 | "Operating System :: MacOS :: MacOS X", 27 | "Development Status :: 4 - Beta", 28 | "Environment :: Other Environment", 29 | "Typing :: Typed", 30 | ] 31 | 32 | include = ["py.typed"] 33 | 34 | 35 | packages = [{ include = "flapi" }] 36 | 37 | [tool.poetry.urls] 38 | "Online Documentation" = "https://maddyguthridge.github.io/Flapi" 39 | "Bug Tracker" = "https://github.com/MaddyGuthridge/Flapi/issues" 40 | 41 | [tool.poetry.scripts] 42 | flapi = "flapi.__main__:cli" 43 | 44 | [tool.mypy] 45 | exclude = ['flapi/server/*'] 46 | python_version = "3.12" 47 | 48 | [tool.poetry.dependencies] 49 | python = "^3.12" 50 | fl-studio-api-stubs = ">=37.0.0" 51 | mido = { extras = ["ports-rtmidi"], version = "^1.3.2" } 52 | typing-extensions = "^4.9.0" 53 | ipython = { version = ">=8.18.1,<10.0.0", optional = true } 54 | click = "^8.1.7" 55 | click-default-group = "^1.2.4" 56 | 57 | [tool.poetry.extras] 58 | ipython = ["ipython"] 59 | 60 | [tool.poetry.group.dev.dependencies] 61 | mypy = "^1.8.0" 62 | flake8 = "^7.0.0" 63 | 64 | [build-system] 65 | requires = ["poetry-core"] 66 | build-backend = "poetry.core.masonry.api" 67 | --------------------------------------------------------------------------------