├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── hooks └── prepublish.js ├── midi-script ├── .gitignore ├── AbletonJS.py ├── Application.py ├── ApplicationView.py ├── Browser.py ├── BrowserItem.py ├── Clip.py ├── ClipSlot.py ├── Config.py ├── CuePoint.py ├── Device.py ├── DeviceParameter.py ├── Interface.py ├── Internal.py ├── Live │ └── .gitkeep ├── Logging.py ├── Midi.py ├── MixerDevice.py ├── Scene.py ├── Session.py ├── Socket.py ├── Song.py ├── SongView.py ├── Track.py ├── TrackView.py ├── __init__.py ├── setup.cfg └── version.py ├── package.json ├── src ├── index.ts ├── ns │ ├── application-view.spec.ts │ ├── application-view.ts │ ├── application.spec.ts │ ├── application.ts │ ├── browser-item.ts │ ├── browser.spec.ts │ ├── browser.ts │ ├── clip-slot.ts │ ├── clip.ts │ ├── cue-point.ts │ ├── device-parameter.ts │ ├── device.ts │ ├── index.ts │ ├── internal.ts │ ├── midi.ts │ ├── mixer-device.spec.ts │ ├── mixer-device.ts │ ├── scene.ts │ ├── session.spec.ts │ ├── session.ts │ ├── song-view.spec.ts │ ├── song-view.ts │ ├── song.spec.ts │ ├── song.ts │ ├── track-view.spec.ts │ ├── track-view.ts │ └── track.ts └── util │ ├── cache.ts │ ├── color.ts │ ├── logger.ts │ ├── note.ts │ ├── package-version.spec.ts │ ├── package-version.ts │ └── tests.ts ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [leolabs] 2 | ko_fi: leolabs 3 | custom: https://paypal.me/leolabs 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | # Edit at https://www.gitignore.io/?templates=node 4 | 5 | build/ 6 | 7 | # Built files 8 | *.js 9 | *.d.ts 10 | /ns/ 11 | !/hooks/**/* 12 | 13 | .vscode/settings.json 14 | 15 | test.ts 16 | test-script.ts 17 | test-udp.ts 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | lerna-debug.log* 27 | 28 | # Diagnostic reports (https://nodejs.org/api/report.html) 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | 43 | # nyc test coverage 44 | .nyc_output 45 | 46 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 47 | .grunt 48 | 49 | # Bower dependency directory (https://bower.io/) 50 | bower_components 51 | 52 | # node-waf configuration 53 | .lock-wscript 54 | 55 | # Compiled binary addons (https://nodejs.org/api/addons.html) 56 | build/Release 57 | 58 | # Dependency directories 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # TypeScript v1 declaration files 63 | typings/ 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | .env.test 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | 87 | # next.js build output 88 | .next 89 | 90 | # nuxt.js build output 91 | .nuxt 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "proseWrap": "always" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Leo Bernard 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ableton.js 2 | 3 | [![Current Version](https://img.shields.io/npm/v/ableton-js.svg)](https://www.npmjs.com/package/ableton-js/) 4 | 5 | Ableton.js lets you control your instance or instances of Ableton using Node.js. 6 | It tries to cover as many functions as possible. 7 | 8 | This package is still a work-in-progress. My goal is to expose all of 9 | [Ableton's MIDI Remote Script](https://nsuspray.github.io/Live_API_Doc/11.0.0.xml) 10 | functions to TypeScript. If you'd like to contribute, please feel free to do so. 11 | 12 | ## Sponsored Message 13 | 14 | I've used Ableton.js to build a setlist manager called 15 | [AbleSet](https://ableset.app). AbleSet allows you to easily manage and control 16 | your Ableton setlists from any device, re-order songs and add notes to them, and 17 | get an overview of the current state of your set. 18 | 19 | [![AbleSet Header](https://public-files.gumroad.com/variants/oplxt68bsgq1hu61t8bydfkgppr5/baaca0eb0e33dc4f9d45910b8c86623f0144cea0fe0c2093c546d17d535752eb)](https://ableset.app/?utm_campaign=ableton-js) 20 | 21 | ## Prerequisites 22 | 23 | To use this library, you'll need to install and activate the MIDI Remote Script 24 | in Ableton.js. To do that, copy the `midi-script` folder of this repo to 25 | Ableton's Remote Scripts folder and rename it to `AbletonJS`. The MIDI Remote 26 | Scripts folder is usually located at 27 | `~/Music/Ableton/User Library/Remote Scripts` 28 | 29 | After starting Ableton Live, add the script to your list of control surfaces: 30 | 31 | ![Ableton Live Settings](https://i.imgur.com/a34zJca.png) 32 | 33 | If you've forked this project on macOS, you can also use yarn to do that for 34 | you. Running `yarn ableton10:start` or `yarn ableton11:start` (depending on your 35 | app version) will copy the `midi-script` folder, open Ableton and show a stream 36 | of log messages until you kill it. 37 | 38 | ## Using Ableton.js 39 | 40 | This library exposes an `Ableton` class which lets you control the entire 41 | application. You can instantiate it once and use TS to explore available 42 | features. 43 | 44 | Example: 45 | 46 | ```typescript 47 | import { Ableton } from "ableton-js"; 48 | 49 | // Log all messages to the console 50 | const ableton = new Ableton({ logger: console }); 51 | 52 | const test = async () => { 53 | // Establishes a connection with Live 54 | await ableton.start(); 55 | 56 | // Observe the current playback state and tempo 57 | ableton.song.addListener("is_playing", (p) => console.log("Playing:", p)); 58 | ableton.song.addListener("tempo", (t) => console.log("Tempo:", t)); 59 | 60 | // Get the current tempo 61 | const tempo = await ableton.song.get("tempo"); 62 | console.log("Current tempo:", tempo); 63 | 64 | // Set the tempo 65 | await ableton.song.set("tempo", 85); 66 | }; 67 | 68 | test(); 69 | ``` 70 | 71 | ## Events 72 | 73 | There are a few events you can use to get more under-the-hood insights: 74 | 75 | ```ts 76 | // A connection to Ableton is established 77 | ab.on("connect", (e) => console.log("Connect", e)); 78 | 79 | // Connection to Ableton was lost, 80 | // also happens when you load a new project 81 | ab.on("disconnect", (e) => console.log("Disconnect", e)); 82 | 83 | // A raw message was received from Ableton 84 | ab.on("message", (m) => console.log("Message:", m)); 85 | 86 | // A received message could not be parsed 87 | ab.on("error", (e) => console.error("Error:", e)); 88 | 89 | // Fires on every response with the current ping 90 | ab.on("ping", (ping) => console.log("Ping:", ping, "ms")); 91 | ``` 92 | 93 | ## Protocol 94 | 95 | Ableton.js uses UDP to communicate with the MIDI Script. Each message is a JSON 96 | object containing required data and a UUID so request and response can be 97 | associated with each other. 98 | 99 | ### Used Ports 100 | 101 | Both the client and the server bind to a random available port and store that 102 | port in a local file so the other side knows which port to send messages to. 103 | 104 | ### Compression and Chunking 105 | 106 | To allow sending large JSON payloads, requests to and responses from the MIDI 107 | Script are compressed using gzip and chunked to fit into the maximum allowed 108 | package size. The first byte of every message chunk contains the chunk index 109 | (0x00-0xFF) followed by the gzipped chunk. The last chunk always has the index 110 | 0xFF. This indicates to the JS library that the previous received messages 111 | should be stiched together, unzipped, and processed. 112 | 113 | ### Caching 114 | 115 | Certain props are cached on the client to reduce the bandwidth over UDP. To do 116 | this, the Ableton plugin generates an MD5 hash of the prop, called ETag, and 117 | sends it to the client along with the data. 118 | 119 | The client stores both the ETag and the data in an LRU cache and sends the 120 | latest stored ETag to the plugin the next time the same prop is requested. If 121 | the data still matches the ETag, the plugin responds with a placeholder object 122 | and the client returns the cached data. 123 | 124 | ### Commands 125 | 126 | A command payload consists of the following properties: 127 | 128 | ```js 129 | { 130 | "uuid": "a20f25a0-83e2-11e9-bbe1-bd3a580ef903", // A unique command id 131 | "ns": "song", // The command namespace 132 | "nsid": null, // The namespace id, for example to address a specific track or device 133 | "name": "get_prop", // Command name 134 | "args": { "prop": "current_song_time" }, // Command arguments 135 | "etag": "4e0794e44c7eb58bdbbbf7268e8237b4", // MD5 hash of the data if it might be cached locally 136 | "cache": true // If this is true, the plugin will calculate an etag and return a placeholder if it matches the provided one 137 | } 138 | ``` 139 | 140 | The MIDI Script answers with a JSON object looking like this: 141 | 142 | ```js 143 | { 144 | "data": 0.0, // The command's return value, can be of any JSON-compatible type 145 | "event": "result", // This can be 'result' or 'error' 146 | "uuid": "a20f25a0-83e2-11e9-bbe1-bd3a580ef903" // The same UUID that was used to send the command 147 | } 148 | ``` 149 | 150 | If you're getting a cached prop, the JSON object could look like this: 151 | 152 | ```js 153 | { 154 | "data": { "data": 0.0, "etag": "4e0794e44c7eb58bdbbbf7268e8237b4" }, 155 | "event": "result", // This can be 'result' or 'error' 156 | "uuid": "a20f25a0-83e2-11e9-bbe1-bd3a580ef903" // The same UUID that was used to send the command 157 | } 158 | ``` 159 | 160 | Or, if the data hasn't changed, it looks like this: 161 | 162 | ```js 163 | { 164 | "data": { "__cached": true }, 165 | "event": "result", // This can be 'result' or 'error' 166 | "uuid": "a20f25a0-83e2-11e9-bbe1-bd3a580ef903" // The same UUID that was used to send the command 167 | } 168 | ``` 169 | 170 | ### Events 171 | 172 | To attach an event listener to a specific property, the client sends a command 173 | object: 174 | 175 | ```js 176 | { 177 | "uuid": "922d54d0-83e3-11e9-ba7c-917478f8b91b", // A unique command id 178 | "ns": "song", // The command namespace 179 | "name": "add_listener", // The command to add an event listener 180 | "args": { 181 | "prop": "current_song_time", // The property that should be watched 182 | "eventId": "922d2dc0-83e3-11e9-ba7c-917478f8b91b" // A unique event id 183 | } 184 | } 185 | ``` 186 | 187 | The MIDI Script answers with a JSON object looking like this to confirm that the 188 | listener has been attached: 189 | 190 | ```js 191 | { 192 | "data": "922d2dc0-83e3-11e9-ba7c-917478f8b91b", // The unique event id 193 | "event": "result", // Should be result, is error when something goes wrong 194 | "uuid": "922d54d0-83e3-11e9-ba7c-917478f8b91b" // The unique command id 195 | } 196 | ``` 197 | 198 | From now on, when the observed property changes, the MIDI Script sends an event 199 | object: 200 | 201 | ```js 202 | { 203 | "data": 68.0, // The new value, can be any JSON-compatible type 204 | "event": "922d2dc0-83e3-11e9-ba7c-917478f8b91b", // The event id 205 | "uuid": null // Is always null and may be removed in future versions 206 | } 207 | ``` 208 | 209 | Note that for some values, this event is emitted multiple times per second. 210 | 20-30 updates per second are not unusual. 211 | 212 | ### Connection Events 213 | 214 | The MIDI Script sends events when it starts and when it shuts down. These look 215 | like this: 216 | 217 | ```js 218 | { 219 | "data": null, // Is always null 220 | "event": "connect", // Can be connect or disconnect 221 | "uuid": null // Is always null and may be removed in future versions 222 | } 223 | ``` 224 | 225 | When you open a new Project in Ableton, the script will shut down and start 226 | again. 227 | 228 | When Ableton.js receives a disconnect event, it clears all current event 229 | listeners and pending commands. It is usually a good idea to attach all event 230 | listeners and get properties each time the `connect` event is emitted. 231 | 232 | ### Findings 233 | 234 | In this section, I'll note interesting pieces of information related to 235 | Ableton's Python framework that I stumble upon during the development of this 236 | library. 237 | 238 | - It seems like Ableton's listener to `output_meter_level` doesn't quite work as 239 | well as expected, hanging every few 100ms. Listening to `output_meter_left` or 240 | `output_meter_right` works better. See 241 | [Issue #4](https://github.com/leolabs/ableton-js/issues/4) 242 | - The `playing_status` listener of clip slots never fires in Ableton. See 243 | [Issue #25](https://github.com/leolabs/ableton-js/issues/25) 244 | 245 | ## Contributing 246 | 247 | If you'd like to add features to this project or submit a bugfix, please feel 248 | free to open a pull request. Before committing changes to any of the TypeScript 249 | files, please run `yarn format` to format the code using Prettier. 250 | -------------------------------------------------------------------------------- /hooks/prepublish.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const package = require("../package.json"); 4 | 5 | const internalPath = path.join(__dirname, "..", "midi-script", "version.py"); 6 | const file = fs.readFileSync(internalPath); 7 | 8 | const replaced = file 9 | .toString() 10 | .replace(/version = "(.+\..+\..+?)"$/m, `version = "${package.version}"`); 11 | 12 | fs.writeFileSync(internalPath, replaced); 13 | -------------------------------------------------------------------------------- /midi-script/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | _Framework/ 6 | 7 | ### Python ### 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ -------------------------------------------------------------------------------- /midi-script/AbletonJS.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import time 3 | 4 | from .version import version 5 | from .Config import DEBUG, FAST_POLLING 6 | from .Logging import logger 7 | from .Socket import Socket 8 | from .Interface import Interface 9 | from .Application import Application 10 | from .Session import Session 11 | from .ApplicationView import ApplicationView 12 | from .Browser import Browser 13 | from .BrowserItem import BrowserItem 14 | from .CuePoint import CuePoint 15 | from .Device import Device 16 | from .DeviceParameter import DeviceParameter 17 | from .MixerDevice import MixerDevice 18 | from .Scene import Scene 19 | from .Song import Song 20 | from .SongView import SongView 21 | from .Track import Track 22 | from .TrackView import TrackView 23 | from .Internal import Internal 24 | from .ClipSlot import ClipSlot 25 | from .Clip import Clip 26 | from .Midi import Midi 27 | 28 | from _Framework.ControlSurface import ControlSurface 29 | import Live 30 | 31 | 32 | class AbletonJS(ControlSurface): 33 | def __init__(self, c_instance): 34 | super(AbletonJS, self).__init__(c_instance) 35 | 36 | logger.info("Starting AbletonJS " + version + "...") 37 | 38 | self.tracked_midi = set() 39 | 40 | Socket.set_message(self.show_message) 41 | self.socket = Socket(self.command_handler) 42 | 43 | self.handlers = { 44 | "application": Application(c_instance, self.socket, self.application()), 45 | "application-view": ApplicationView(c_instance, self.socket, self.application()), 46 | # added for red box control 47 | "session": Session(c_instance, self.socket, self), 48 | "browser": Browser(c_instance, self.socket, self.application()), 49 | "browser-item": BrowserItem(c_instance, self.socket), 50 | "cue-point": CuePoint(c_instance, self.socket), 51 | "device": Device(c_instance, self.socket), 52 | "device-parameter": DeviceParameter(c_instance, self.socket), 53 | "internal": Internal(c_instance, self.socket), 54 | "midi": Midi(c_instance, self.socket, self.tracked_midi, self.request_rebuild_midi_map), 55 | "mixer-device": MixerDevice(c_instance, self.socket), 56 | "scene": Scene(c_instance, self.socket), 57 | "song": Song(c_instance, self.socket), 58 | "song-view": SongView(c_instance, self.socket), 59 | "track": Track(c_instance, self.socket), 60 | "track-view": TrackView(c_instance, self.socket), 61 | "clip_slot": ClipSlot(c_instance, self.socket), 62 | "clip": Clip(c_instance, self.socket), 63 | } 64 | 65 | self._last_tick = time.time() * 1000 66 | self.tick() 67 | 68 | if FAST_POLLING: 69 | self.recv_loop = Live.Base.Timer( 70 | callback=self.socket.process, interval=10, repeat=True) 71 | 72 | self.recv_loop.start() 73 | 74 | def tick(self): 75 | tick_time = time.time() * 1000 76 | 77 | if tick_time - self._last_tick > 200: 78 | logger.warning("UDP tick is lagging, delta: " + 79 | str(round(tick_time - self._last_tick)) + "ms") 80 | 81 | self._last_tick = tick_time 82 | self.socket.process() 83 | 84 | process_time = time.time() * 1000 85 | 86 | if process_time - tick_time > 100: 87 | logger.warning("UDP processing is taking long, delta: " + 88 | str(round(tick_time - process_time)) + "ms") 89 | 90 | self.schedule_message(1, self.tick) 91 | 92 | def build_midi_map(self, midi_map_handle): 93 | script_handle = self._c_instance.handle() 94 | for midi in self.tracked_midi: 95 | if midi[0] == "cc": 96 | Live.MidiMap.forward_midi_cc( 97 | script_handle, midi_map_handle, midi[1], midi[2]) 98 | elif midi[0] == "note": 99 | Live.MidiMap.forward_midi_note( 100 | script_handle, midi_map_handle, midi[1], midi[2]) 101 | 102 | def receive_midi(self, midi_bytes): 103 | self.handlers["midi"].send_midi(midi_bytes) 104 | 105 | def disconnect(self): 106 | logger.info("Disconnecting") 107 | if FAST_POLLING: 108 | self.recv_loop.stop() 109 | self.socket.send("disconnect") 110 | self.socket.shutdown() 111 | Interface.listeners.clear() 112 | super(AbletonJS, self).disconnect() 113 | 114 | def command_handler(self, payload): 115 | 116 | namespace = payload["ns"] 117 | 118 | # Don't clutter the logs 119 | if not (namespace == "internal" and payload["name"] == "get_prop" and payload["args"]["prop"] == "ping") and DEBUG: 120 | logger.debug("Received command: " + str(payload)) 121 | 122 | if namespace in self.handlers: 123 | handler = self.handlers[namespace] 124 | handler.handle(payload) 125 | else: 126 | self.socket.send("error", "No handler for namespace " + 127 | str(namespace), payload["uuid"]) 128 | -------------------------------------------------------------------------------- /midi-script/Application.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class Application(Interface): 6 | def __init__(self, c_instance, socket, application): 7 | super(Application, self).__init__(c_instance, socket) 8 | self.application = application 9 | 10 | def get_ns(self, nsid=None): 11 | return self.application 12 | 13 | def get_major_version(self, ns): 14 | return ns.get_major_version() 15 | 16 | def get_minor_version(self, ns): 17 | return ns.get_minor_version() 18 | 19 | def get_bugfix_version(self, ns): 20 | return ns.get_bugfix_version() 21 | 22 | def get_version(self, ns): 23 | return str(ns.get_major_version()) + "." + str(ns.get_minor_version()) + "." + str(ns.get_bugfix_version()) 24 | -------------------------------------------------------------------------------- /midi-script/ApplicationView.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class ApplicationView(Interface): 6 | def __init__(self, c_instance, socket, application): 7 | super(ApplicationView, self).__init__(c_instance, socket) 8 | self.application = application 9 | 10 | def get_ns(self, nsid=None): 11 | return self.application.view 12 | -------------------------------------------------------------------------------- /midi-script/Browser.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .BrowserItem import BrowserItem 4 | 5 | 6 | class Browser(Interface): 7 | def __init__(self, c_instance, socket, application): 8 | super(Browser, self).__init__(c_instance, socket) 9 | self.application = application 10 | 11 | def get_ns(self, nsid=None): 12 | return self.application.browser 13 | 14 | def get_audio_effects(self, ns): 15 | return map(BrowserItem.serialize_browser_item, ns.audio_effects.children) 16 | 17 | def get_clips(self, ns): 18 | return map(BrowserItem.serialize_browser_item, ns.clips.children) 19 | 20 | def get_colors(self, ns): 21 | return map(BrowserItem.serialize_browser_item, ns.colors) 22 | 23 | def get_current_project(self, ns): 24 | return map(BrowserItem.serialize_browser_item, ns.current_project.children) 25 | 26 | def get_drums(self, ns): 27 | return map(BrowserItem.serialize_browser_item, ns.drums.children) 28 | 29 | def get_instruments(self, ns): 30 | return map(BrowserItem.serialize_browser_item, ns.instruments.children) 31 | 32 | def get_max_for_live(self, ns): 33 | return map(BrowserItem.serialize_browser_item, ns.max_for_live.children) 34 | 35 | def get_midi_effects(self, ns): 36 | return map(BrowserItem.serialize_browser_item, ns.midi_effects.children) 37 | 38 | def get_packs(self, ns): 39 | return map(BrowserItem.serialize_browser_item, ns.packs.children) 40 | 41 | def get_plugins(self, ns): 42 | return map(BrowserItem.serialize_browser_item, ns.plugins.children) 43 | 44 | def get_samples(self, ns): 45 | return map(BrowserItem.serialize_browser_item, ns.samples.children) 46 | 47 | def get_sounds(self, ns): 48 | return map(BrowserItem.serialize_browser_item, ns.sounds.children) 49 | 50 | def get_user_folders(self, ns): 51 | return map(BrowserItem.serialize_browser_item, ns.user_folders) 52 | 53 | def get_user_library(self, ns): 54 | return map(BrowserItem.serialize_browser_item, ns.user_library.children) 55 | 56 | def get_hotswap_target(self, ns): 57 | return BrowserItem.serialize_browser_item(ns.hotswap_target) 58 | 59 | def load_item(self, ns, id): 60 | return ns.load_item(self.get_obj(id)) 61 | 62 | def preview_item(self, ns, id): 63 | return ns.preview_item(self.get_obj(id)) 64 | 65 | def stop_preview(self, ns): 66 | return ns.stop_preview() 67 | -------------------------------------------------------------------------------- /midi-script/BrowserItem.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class BrowserItem(Interface): 6 | @staticmethod 7 | def serialize_browser_item(browser_item): 8 | if browser_item is None: 9 | return None 10 | browser_item_id = Interface.save_obj(browser_item) 11 | return { 12 | "id": browser_item_id, 13 | "name": browser_item.name, 14 | "is_loadable": browser_item.is_loadable, 15 | "is_selected": browser_item.is_selected, 16 | "is_device": browser_item.is_device, 17 | "is_folder": browser_item.is_folder, 18 | "source": browser_item.source, 19 | "uri": browser_item.uri, 20 | } 21 | 22 | def __init__(self, c_instance, socket): 23 | super(BrowserItem, self).__init__(c_instance, socket) 24 | 25 | def get_children(self, ns): 26 | return map(BrowserItem.serialize_browser_item, ns.children) 27 | -------------------------------------------------------------------------------- /midi-script/Clip.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class Clip(Interface): 6 | @staticmethod 7 | def serialize_clip(clip): 8 | if clip is None: 9 | return None 10 | 11 | clip_id = Interface.save_obj(clip) 12 | return { 13 | "id": clip_id, 14 | "name": clip.name, 15 | "color": clip.color, 16 | "color_index": clip.color_index, 17 | "is_audio_clip": clip.is_audio_clip, 18 | "is_midi_clip": clip.is_midi_clip, 19 | "start_time": clip.start_time, 20 | "end_time": clip.end_time, 21 | "muted": clip.muted 22 | } 23 | 24 | def __init__(self, c_instance, socket): 25 | super(Clip, self).__init__(c_instance, socket) 26 | 27 | def get_notes(self, ns, from_time=0, from_pitch=0, time_span=99999999999999, pitch_span=128): 28 | return ns.get_notes(from_time, from_pitch, time_span, pitch_span) 29 | 30 | def get_warp_markers(self, ns): 31 | dict_markers = [] 32 | for warp_marker in ns.warp_markers: 33 | dict_markers.append({ 34 | "beat_time": warp_marker.beat_time, 35 | "sample_time": warp_marker.sample_time, 36 | }) 37 | return dict_markers 38 | 39 | def set_notes(self, ns, notes): 40 | return ns.set_notes(tuple(notes)) 41 | 42 | def replace_selected_notes(self, ns, notes): 43 | return ns.replace_selected_notes(tuple(notes)) 44 | -------------------------------------------------------------------------------- /midi-script/ClipSlot.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .Clip import Clip 4 | 5 | 6 | class ClipSlot(Interface): 7 | @staticmethod 8 | def serialize_clip_slot(clip_slot): 9 | if clip_slot is None: 10 | return None 11 | 12 | clip_slot_id = Interface.save_obj(clip_slot) 13 | return { 14 | "id": clip_slot_id, 15 | "color": clip_slot.color, 16 | "has_clip": clip_slot.has_clip, 17 | "is_playing": clip_slot.is_playing, 18 | "is_recording": clip_slot.is_recording, 19 | "is_triggered": clip_slot.is_triggered 20 | } 21 | 22 | def __init__(self, c_instance, socket): 23 | super(ClipSlot, self).__init__(c_instance, socket) 24 | 25 | def get_clip(self, ns): 26 | return Clip.serialize_clip(ns.clip) 27 | 28 | def get_playing_status(self, ns): 29 | return str(ns.playing_status) 30 | 31 | def duplicate_clip_to(self, ns, slot_id): 32 | return ns.duplicate_clip_to(Interface.get_obj(slot_id)) 33 | -------------------------------------------------------------------------------- /midi-script/Config.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | 3 | FAST_POLLING = True 4 | -------------------------------------------------------------------------------- /midi-script/CuePoint.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class CuePoint(Interface): 6 | @staticmethod 7 | def serialize_cue_point(cue_point): 8 | if cue_point is None: 9 | return None 10 | 11 | cue_point_id = Interface.save_obj(cue_point) 12 | return {"id": cue_point_id, "name": cue_point.name, "time": cue_point.time} 13 | 14 | def __init__(self, c_instance, socket): 15 | super(CuePoint, self).__init__(c_instance, socket) 16 | -------------------------------------------------------------------------------- /midi-script/Device.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .DeviceParameter import DeviceParameter 4 | 5 | 6 | class Device(Interface): 7 | @staticmethod 8 | def serialize_device(device): 9 | if device is None: 10 | return None 11 | 12 | device_id = Interface.save_obj(device) 13 | return { 14 | "id": device_id, 15 | "name": device.name, 16 | "type": str(device.type), 17 | "class_name": device.class_name, 18 | } 19 | 20 | def __init__(self, c_instance, socket): 21 | super(Device, self).__init__(c_instance, socket) 22 | 23 | def get_parameters(self, ns): 24 | return map(DeviceParameter.serialize_device_parameter, ns.parameters) 25 | 26 | def get_type(self, ns): 27 | return str(ns.type) 28 | -------------------------------------------------------------------------------- /midi-script/DeviceParameter.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | 4 | 5 | class DeviceParameter(Interface): 6 | @staticmethod 7 | def serialize_device_parameter(param): 8 | if param is None: 9 | return None 10 | 11 | device_parameter_id = Interface.save_obj(param) 12 | return { 13 | "id": device_parameter_id, 14 | "name": param.name, 15 | "value": param.value, 16 | "is_quantized": param.is_quantized 17 | } 18 | 19 | def __init__(self, c_instance, socket): 20 | super(DeviceParameter, self).__init__(c_instance, socket) 21 | -------------------------------------------------------------------------------- /midi-script/Interface.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | 4 | from .Config import DEBUG 5 | from .Logging import logger 6 | 7 | 8 | class Interface(object): 9 | obj_ids = dict() 10 | listeners = dict() 11 | 12 | @staticmethod 13 | def save_obj(obj): 14 | try: 15 | obj_id = "live_" + str(obj._live_ptr) 16 | except: 17 | obj_id = "id_" + str(id(obj)) 18 | 19 | Interface.obj_ids[obj_id] = obj 20 | return obj_id 21 | 22 | @staticmethod 23 | def get_obj(obj_id): 24 | return Interface.obj_ids[obj_id] 25 | 26 | def __init__(self, c_instance, socket): 27 | self.ableton = c_instance 28 | self.socket = socket 29 | 30 | def log_debug(self, message): 31 | if DEBUG: 32 | logger.debug(message) 33 | 34 | def get_ns(self, nsid): 35 | return Interface.obj_ids[nsid] 36 | 37 | def send_result(self, result, uuid, etag, cache): 38 | """Sends an empty response if the etag matches the result, or the result together with an etag.""" 39 | if not cache: 40 | return self.socket.send("result", result, uuid) 41 | 42 | def jsonReplace(o): 43 | return str(o) 44 | 45 | response = json.dumps(result, default=jsonReplace, ensure_ascii=False) 46 | hash = hashlib.md5(response.encode("utf-8", "replace")).hexdigest() 47 | 48 | if hash == etag: 49 | return self.socket.send("result", {"__cached": True}, uuid) 50 | else: 51 | return self.socket.send("result", {"data": result, "etag": hash}, uuid) 52 | 53 | def handle(self, payload): 54 | name = payload.get("name") 55 | uuid = payload.get("uuid") 56 | etag = payload.get("etag") 57 | args = payload.get("args", {}) 58 | cache = payload.get("cache", False) 59 | ns = self.get_ns(payload.get("nsid")) 60 | 61 | try: 62 | # Try self-defined functions first 63 | if hasattr(self, name) and callable(getattr(self, name)): 64 | result = getattr(self, name)(ns=ns, **args) 65 | self.send_result(result, uuid, etag, cache) 66 | # Check if the function exists in the Ableton API as fallback 67 | elif hasattr(ns, name) and callable(getattr(ns, name)): 68 | if isinstance(args, dict): 69 | result = getattr(ns, name)(**args) 70 | self.send_result(result, uuid, etag, cache) 71 | elif isinstance(args, list): 72 | result = getattr(ns, name)(*args) 73 | self.send_result(result, uuid, etag, cache) 74 | else: 75 | self.socket.send("error", "Function call failed: " + str(args) + 76 | " are invalid arguments", uuid) 77 | else: 78 | self.socket.send("error", "Function call failed: " + payload["name"] + 79 | " doesn't exist or isn't callable", uuid) 80 | except Exception as e: 81 | logger.error("Handler Error:") 82 | logger.exception(e) 83 | self.socket.send("error", str(e.args[0]), uuid) 84 | 85 | def add_listener(self, ns, prop, eventId, nsid="Default"): 86 | try: 87 | add_fn = getattr(ns, "add_" + prop + "_listener") 88 | except: 89 | raise Exception("Listener " + str(prop) + " does not exist.") 90 | 91 | key = str(nsid) + ":" + prop 92 | self.log_debug("Listener key: " + key) 93 | 94 | if key in self.listeners: 95 | self.log_debug("Key already has a listener") 96 | return self.listeners[key]["id"] 97 | 98 | def fn(): 99 | value = self.get_prop(ns, prop) 100 | return self.socket.send(eventId, value) 101 | 102 | self.log_debug("Attaching listener: " + 103 | key + ", event ID: " + eventId) 104 | add_fn(fn) 105 | self.listeners[key] = {"id": eventId, "fn": fn} 106 | return eventId 107 | 108 | def remove_listener(self, ns, prop, nsid="Default"): 109 | key = str(nsid) + ":" + prop 110 | self.log_debug("Remove key: " + key) 111 | if key not in self.listeners: 112 | raise Exception("Listener " + str(prop) + " does not exist.") 113 | 114 | try: 115 | remove_fn = getattr(ns, "remove_" + prop + "_listener") 116 | remove_fn(self.listeners[key]["fn"]) 117 | self.listeners.pop(key, None) 118 | return True 119 | except Exception as e: 120 | raise Exception("Listener " + str(prop) + 121 | " could not be removed: " + str(e)) 122 | 123 | def get_prop(self, ns, prop): 124 | try: 125 | get_fn = getattr(self, "get_" + prop) 126 | except: 127 | def get_fn(ns): 128 | result = getattr(ns, prop) 129 | return result 130 | 131 | return get_fn(ns) 132 | 133 | def set_prop(self, ns, prop, value): 134 | try: 135 | set_fn = getattr(self, "set_" + prop) 136 | except: 137 | def set_fn(ns, value): return setattr(ns, prop, value) 138 | 139 | return set_fn(ns, value) 140 | -------------------------------------------------------------------------------- /midi-script/Internal.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .version import version 4 | 5 | 6 | class Internal(Interface): 7 | def __init__(self, c_instance, socket): 8 | super(Internal, self).__init__(c_instance, socket) 9 | 10 | def get_ns(self, nsid): 11 | return self 12 | 13 | def get_ping(self, nsid): 14 | return True 15 | 16 | def get_version(self, ns): 17 | return version 18 | 19 | def set_client_port(self, nsid, port): 20 | self.socket.set_client_port(port) 21 | return True 22 | -------------------------------------------------------------------------------- /midi-script/Live/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/ableton-js/af90efb981826914783be320d0b24bf249ac179b/midi-script/Live/.gitkeep -------------------------------------------------------------------------------- /midi-script/Logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("AbletonJS") 4 | -------------------------------------------------------------------------------- /midi-script/Midi.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .Interface import Interface 4 | from .Logging import logger 5 | 6 | 7 | class Midi(Interface): 8 | event_id = None 9 | 10 | def __init__(self, c_instance, socket, tracked_midi, update_midi_callback): 11 | super(Midi, self).__init__(c_instance, socket) 12 | self.outputs = set() 13 | self.tracked_midi = tracked_midi 14 | self.update_midi = update_midi_callback 15 | 16 | def get_ns(self, nsid): 17 | return self 18 | 19 | def set_midi_outputs(self, ns, outputs): 20 | self.outputs.clear() 21 | for output in outputs: 22 | try: 23 | midi_type = output.get("type") 24 | if midi_type != "cc" and midi_type != "note": 25 | raise ValueError("invalid midi type " + str(midi_type)) 26 | self.outputs.add((midi_type, output.get( 27 | "channel"), output.get("target"))) 28 | except ValueError as e: 29 | logger.error(e) 30 | except: 31 | logger.error("invalid midi output requested: " + str(output)) 32 | 33 | def remove_midi_listener(self, fn): 34 | self.event_id = None 35 | self.tracked_midi.clear() 36 | self.update_midi() 37 | 38 | def add_listener(self, ns, prop, eventId, nsid="Default"): 39 | if prop != "midi": 40 | raise Exception("Listener " + str(prop) + " does not exist.") 41 | 42 | if self.event_id is not None: 43 | logger.warning("MIDI listener already exists") 44 | return self.event_id 45 | 46 | logger.info("Attaching MIDI listener") 47 | 48 | self.tracked_midi.clear() 49 | self.tracked_midi.update(self.outputs) 50 | self.update_midi() 51 | self.event_id = eventId 52 | 53 | return eventId 54 | 55 | def send_midi(self, midi_bytes): 56 | if self.event_id is not None: 57 | self.socket.send(self.event_id, {"bytes": midi_bytes}) 58 | -------------------------------------------------------------------------------- /midi-script/MixerDevice.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .DeviceParameter import DeviceParameter 4 | from .Interface import Interface 5 | 6 | 7 | class MixerDevice(Interface): 8 | @staticmethod 9 | def serialize_mixer_device(mixer_device): 10 | if mixer_device is None: 11 | return None 12 | 13 | device_id = Interface.save_obj(mixer_device) 14 | return {"id": device_id, "volume": mixer_device.volume} 15 | 16 | def __init__(self, c_instance, socket): 17 | super(MixerDevice, self).__init__(c_instance, socket) 18 | 19 | def get_crossfader(self, ns): 20 | return DeviceParameter.serialize_device_parameter(ns.crossfader) 21 | 22 | def get_cue_volume(self, ns): 23 | return DeviceParameter.serialize_device_parameter(ns.cue_volume) 24 | 25 | def get_left_split_stereo(self, ns): 26 | return DeviceParameter.serialize_device_parameter(ns.left_split_stereo) 27 | 28 | def get_panning(self, ns): 29 | return DeviceParameter.serialize_device_parameter(ns.panning) 30 | 31 | def get_right_split_stereo(self, ns): 32 | return DeviceParameter.serialize_device_parameter(ns.right_split_stereo) 33 | 34 | def get_sends(self, ns): 35 | return map(DeviceParameter.serialize_device_parameter, ns.sends) 36 | 37 | def get_song_tempo(self, ns): 38 | return DeviceParameter.serialize_device_parameter(ns.song_tempo) 39 | 40 | def get_track_activator(self, ns): 41 | return DeviceParameter.serialize_device_parameter(ns.track_activator) 42 | 43 | def get_volume(self, ns): 44 | return DeviceParameter.serialize_device_parameter(ns.volume) 45 | -------------------------------------------------------------------------------- /midi-script/Scene.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .ClipSlot import ClipSlot 4 | 5 | 6 | class Scene(Interface): 7 | @staticmethod 8 | def serialize_scene(scene): 9 | if scene is None: 10 | return None 11 | 12 | scene_id = Interface.save_obj(scene) 13 | return {"id": scene_id, "name": scene.name, "color": scene.color} 14 | 15 | def __init__(self, c_instance, socket): 16 | super(Scene, self).__init__(c_instance, socket) 17 | 18 | def get_clip_slots(self, ns): 19 | return map(ClipSlot.serialize_clip_slot, ns.clip_slots) 20 | -------------------------------------------------------------------------------- /midi-script/Session.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .Logging import logger 4 | 5 | from _Framework.SessionComponent import SessionComponent 6 | 7 | 8 | class Session(Interface): 9 | def __init__(self, c_instance, socket, controlSurface): 10 | super(Session, self).__init__(c_instance, socket) 11 | 12 | self.controlSurface = controlSurface 13 | self.sessionComponent = SessionComponent 14 | 15 | def get_ns(self, nsid=None): 16 | return self 17 | 18 | def setup_session_box(self, ns, num_tracks=2, num_scenes=2): 19 | """ 20 | Creates the session with the red ring box. 21 | """ 22 | with self.controlSurface.component_guard(): 23 | logger.info( 24 | "Setting up session box with " + str(num_tracks) + " tracks and " + str(num_scenes) + " scenes.") 25 | self.session = self.sessionComponent(num_tracks, num_scenes) 26 | self.session.set_offsets(0, 0) 27 | self.controlSurface.set_highlighting_session_component( 28 | self.session) 29 | return True 30 | 31 | def set_session_offset(self, ns, track_offset, scene_offset): 32 | """ 33 | Sets the offset of the SessionComponent instance. 34 | """ 35 | logger.info( 36 | "Moving session box offset to " + str(track_offset) + " and " + scene_offset + ".") 37 | 38 | if hasattr(self, 'session'): 39 | self.session.set_offsets(track_offset, scene_offset) 40 | else: 41 | logger.error("Session box not set up.") 42 | return True 43 | -------------------------------------------------------------------------------- /midi-script/Socket.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import json 3 | import struct 4 | import zlib 5 | import os 6 | import tempfile 7 | import sys 8 | 9 | from .Logging import logger 10 | 11 | import Live 12 | 13 | 14 | def split_by_n(seq, n): 15 | '''A generator to divide a sequence into chunks of n units.''' 16 | while seq: 17 | yield seq[:n] 18 | seq = seq[n:] 19 | 20 | 21 | server_port_file = "ableton-js-server.port" 22 | client_port_file = "ableton-js-client.port" 23 | 24 | server_port_path = os.path.join(tempfile.gettempdir(), server_port_file) 25 | client_port_path = os.path.join(tempfile.gettempdir(), client_port_file) 26 | 27 | 28 | class Socket(object): 29 | @staticmethod 30 | def set_message(func): 31 | Socket.show_message = func 32 | 33 | def __init__(self, handler): 34 | self.input_handler = handler 35 | self._server_addr = ("127.0.0.1", 0) 36 | self._client_addr = ("127.0.0.1", 39031) 37 | self._last_error = "" 38 | self._socket = None 39 | self._chunk_limit = None 40 | self._message_id = 0 41 | self._receive_buffer = bytearray() 42 | 43 | self.read_remote_port() 44 | self.init_socket(True) 45 | 46 | def log_error_once(self, msg): 47 | if self._last_error != msg: 48 | self._last_error = msg 49 | logger.error(msg) 50 | 51 | def set_client_port(self, port): 52 | logger.info("Setting client port: " + str(port)) 53 | self.show_message("Client connected on port " + str(port)) 54 | self._client_addr = ("127.0.0.1", int(port)) 55 | 56 | def read_last_server_port(self): 57 | try: 58 | with open(server_port_path) as file: 59 | port = int(file.read()) 60 | 61 | logger.info("Stored server port: " + str(port)) 62 | return port 63 | except Exception as e: 64 | logger.error("Couldn't read stored server port:") 65 | logger.exception(e) 66 | return None 67 | 68 | def read_remote_port(self): 69 | '''Reads the port our client is listening on''' 70 | 71 | try: 72 | os.stat(client_port_path) 73 | except Exception as e: 74 | self.log_error_once("Couldn't stat remote port file:") 75 | return 76 | 77 | try: 78 | old_port = self._client_addr[1] 79 | 80 | with open(client_port_path) as file: 81 | port = int(file.read()) 82 | 83 | if port != old_port: 84 | logger.info("[" + str(id(self)) + "] Client port changed from " + 85 | str(old_port) + " to " + str(port)) 86 | self._client_addr = ("127.0.0.1", port) 87 | 88 | if self._socket: 89 | self.send("connect", {"port": self._server_addr[1]}) 90 | except Exception as e: 91 | self.log_error_once( 92 | "Couldn't read remote port file: " + str(e.args)) 93 | 94 | def shutdown(self): 95 | logger.info("Shutting down...") 96 | self._socket.close() 97 | self._socket = None 98 | 99 | def init_socket(self, try_stored=False): 100 | logger.info( 101 | "Initializing socket, from stored: " + str(try_stored)) 102 | 103 | try: 104 | stored_port = self.read_last_server_port() 105 | 106 | # Try the port we used last time first 107 | if try_stored and stored_port: 108 | self._server_addr = ("127.0.0.1", stored_port) 109 | else: 110 | self._server_addr = ("127.0.0.1", 0) 111 | 112 | self._socket = socket.socket( 113 | socket.AF_INET, socket.SOCK_DGRAM) 114 | self._socket.setblocking(0) 115 | self._socket.bind(self._server_addr) 116 | port = self._socket.getsockname()[1] 117 | 118 | # Get the chunk limit of the socket, minus 100 for some headroom 119 | self._chunk_limit = self._socket.getsockopt( 120 | socket.SOL_SOCKET, socket.SO_SNDBUF) - 100 121 | 122 | logger.info("Chunk limit: " + str(self._chunk_limit)) 123 | 124 | # Write the chosen port to a file 125 | try: 126 | if stored_port != port: 127 | with open(server_port_path, "w") as file: 128 | file.write(str(port)) 129 | except Exception as e: 130 | self.log_error_once( 131 | "Couldn't save port in file: " + str(e.args)) 132 | raise e 133 | 134 | try: 135 | self.send("connect", {"port": self._server_addr[1]}) 136 | except Exception as e: 137 | logger.error("Couldn't send connect to " + 138 | str(self._client_addr) + ":") 139 | logger.exception(e) 140 | 141 | self.show_message("Started server on port " + str(port)) 142 | 143 | logger.info('Started server on: ' + str(self._socket.getsockname()) + 144 | ', client addr: ' + str(self._client_addr)) 145 | except Exception as e: 146 | msg = 'ERROR: Cannot bind to ' + \ 147 | str(self._server_addr) + ': ' + \ 148 | str(e.args) + ', trying again. ' + \ 149 | 'If this keeps happening, try restarting your computer.' 150 | self.log_error_once( 151 | msg + " (Client address: " + str(self._client_addr) + ")") 152 | self.show_message(msg) 153 | t = Live.Base.Timer( 154 | callback=self.init_socket, interval=5000, repeat=False) 155 | t.start() 156 | 157 | def _sendto(self, msg): 158 | '''Send a raw message to the client, compressed and chunked, if necessary''' 159 | compressed = zlib.compress(msg.encode("utf8")) + b'\n' 160 | 161 | if self._socket == None or self._chunk_limit == None: 162 | return 163 | 164 | self._message_id = (self._message_id + 1) % 256 165 | message_id_byte = struct.pack("B", self._message_id) 166 | 167 | if len(compressed) < self._chunk_limit: 168 | self._socket.sendto( 169 | message_id_byte + b'\x00\x01' + compressed, self._client_addr) 170 | else: 171 | chunks = list(split_by_n(compressed, self._chunk_limit)) 172 | count = len(chunks) 173 | count_byte = struct.pack("B", count) 174 | for i, chunk in enumerate(chunks): 175 | logger.info("Sending packet " + str(self._message_id) + 176 | " - " + str(i) + "/" + str(count)) 177 | packet_byte = struct.pack("B", i) 178 | self._socket.sendto( 179 | message_id_byte + packet_byte + count_byte + chunk, self._client_addr) 180 | 181 | def send(self, name, obj=None, uuid=None): 182 | def jsonReplace(o): 183 | try: 184 | return list(o) 185 | except: 186 | pass 187 | 188 | return str(o) 189 | 190 | data = None 191 | 192 | try: 193 | data = json.dumps( 194 | {"event": name, "data": obj, "uuid": uuid}, default=jsonReplace, ensure_ascii=False) 195 | self._sendto(data) 196 | except socket.error as e: 197 | logger.error("Socket error:") 198 | logger.exception(e) 199 | logger.error("Server: " + str(self._server_addr) + ", client: " + 200 | str(self._client_addr) + ", socket: " + str(self._socket)) 201 | logger.error("Data:" + data) 202 | except Exception as e: 203 | logger.error("Error " + name + "(" + str(uuid) + "):") 204 | logger.exception(e) 205 | 206 | def process(self): 207 | try: 208 | while 1: 209 | data = self._socket.recv(65536) 210 | if len(data) and self.input_handler: 211 | self._receive_buffer.extend(data[1:]) 212 | 213 | # \xFF for Live 10 (Python2) and 255 for Live 11 (Python3) 214 | if (data[0] == b'\xFF' or data[0] == 255): 215 | packet = self._receive_buffer 216 | self._receive_buffer = bytearray() 217 | 218 | # Handle Python 2/3 compatibility for zlib.decompress 219 | if sys.version_info[0] < 3: 220 | packet = str(packet) 221 | 222 | unzipped = zlib.decompress(packet) 223 | 224 | # Handle bytes to string conversion for Python 3 225 | if sys.version_info[0] >= 3 and isinstance(unzipped, bytes): 226 | unzipped = unzipped.decode('utf-8') 227 | 228 | payload = json.loads(unzipped) 229 | self.input_handler(payload) 230 | 231 | except socket.error as e: 232 | if (e.errno != 35 and e.errno != 10035 and e.errno != 10054): 233 | logger.error("Socket error:") 234 | logger.exception(e) 235 | return 236 | except Exception as e: 237 | logger.error("Error processing request:") 238 | logger.exception(e) 239 | -------------------------------------------------------------------------------- /midi-script/Song.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .CuePoint import CuePoint 4 | from .Device import Device 5 | from .Scene import Scene 6 | from .Track import Track 7 | 8 | import Live 9 | 10 | PLAY_QUANTIZATIONS = { 11 | 'q_8_bars': Live.Song.Quantization.q_8_bars, 12 | 'q_4_bars': Live.Song.Quantization.q_4_bars, 13 | 'q_2_bars': Live.Song.Quantization.q_2_bars, 14 | 'q_bar': Live.Song.Quantization.q_bar, 15 | 'q_half': Live.Song.Quantization.q_half, 16 | 'q_half_triplet': Live.Song.Quantization.q_half_triplet, 17 | 'q_quarter': Live.Song.Quantization.q_quarter, 18 | 'q_quarter_triplet': Live.Song.Quantization.q_quarter_triplet, 19 | 'q_eight': Live.Song.Quantization.q_eight, 20 | 'q_eight_triplet': Live.Song.Quantization.q_eight_triplet, 21 | 'q_sixtenth': Live.Song.Quantization.q_sixtenth, 22 | 'q_sixtenth_triplet': Live.Song.Quantization.q_sixtenth_triplet, 23 | 'q_thirtytwoth': Live.Song.Quantization.q_thirtytwoth, 24 | 'q_no_q': Live.Song.Quantization.q_no_q 25 | } 26 | 27 | REC_QUANTIZATIONS = { 28 | 'rec_q_eight': Live.Song.RecordingQuantization.rec_q_eight, 29 | 'rec_q_eight_eight_triplet': Live.Song.RecordingQuantization.rec_q_eight_eight_triplet, 30 | 'rec_q_eight_triplet': Live.Song.RecordingQuantization.rec_q_eight_triplet, 31 | 'rec_q_no_q': Live.Song.RecordingQuantization.rec_q_no_q, 32 | 'rec_q_quarter': Live.Song.RecordingQuantization.rec_q_quarter, 33 | 'rec_q_sixtenth': Live.Song.RecordingQuantization.rec_q_sixtenth, 34 | 'rec_q_sixtenth_sixtenth_triplet': Live.Song.RecordingQuantization.rec_q_sixtenth_sixtenth_triplet, 35 | 'rec_q_sixtenth_triplet': Live.Song.RecordingQuantization.rec_q_sixtenth_triplet, 36 | 'rec_q_thirtysecond': Live.Song.RecordingQuantization.rec_q_thirtysecond, 37 | } 38 | 39 | 40 | class Song(Interface): 41 | def __init__(self, c_instance, socket): 42 | super(Song, self).__init__(c_instance, socket) 43 | self.song = self.ableton.song() 44 | 45 | def get_ns(self, nsid): 46 | return self.song 47 | 48 | def create_audio_track(self, ns, index): 49 | return Track.serialize_track(ns.create_audio_track(index)) 50 | 51 | def create_midi_track(self, ns, index): 52 | return Track.serialize_track(ns.create_midi_track(index)) 53 | 54 | def create_return_track(self, ns): 55 | return Track.serialize_track(ns.create_return_track()) 56 | 57 | def create_scene(self, ns, index): 58 | return Scene.serialize_scene(ns.create_scene(index)) 59 | 60 | def get_clip_trigger_quantization(self, ns): 61 | return str(ns.clip_trigger_quantization) 62 | 63 | def get_cue_points(self, ns): 64 | sorted_points = sorted(ns.cue_points, key=lambda cue: cue.time) 65 | return map(CuePoint.serialize_cue_point, sorted_points) 66 | 67 | def get_appointed_device(self, ns): 68 | return Device.serialize_device(ns.appointed_device) 69 | 70 | def get_master_track(self, ns): 71 | return Track.serialize_track(ns.master_track) 72 | 73 | def get_midi_recording_quantization(self, ns): 74 | return str(ns.midi_recording_quantization) 75 | 76 | def get_return_tracks(self, ns): 77 | return map(Track.serialize_track, ns.return_tracks) 78 | 79 | def get_scenes(self, ns): 80 | return map(Scene.serialize_scene, ns.scenes) 81 | 82 | def get_tracks(self, ns): 83 | return map(Track.serialize_track, ns.tracks) 84 | 85 | def get_visible_tracks(self, ns): 86 | return map(Track.serialize_track, ns.visible_tracks) 87 | 88 | def get_data(self, ns, key): 89 | return ns.get_data(key, None) 90 | 91 | def get_current_smpte_song_time(self, ns, timeFormat): 92 | time = ns.get_current_smpte_song_time(timeFormat) 93 | return {'hours': time.hours, 'minutes': time.minutes, 'seconds': time.seconds, 'frames': time.frames} 94 | 95 | def set_appointed_device(self, ns, device_id): 96 | ns.appointed_device = Interface.get_obj(device_id) 97 | 98 | def set_clip_trigger_quantization(self, ns, name): 99 | quantization = PLAY_QUANTIZATIONS.get(str(name), PLAY_QUANTIZATIONS['q_bar']) 100 | ns.clip_trigger_quantization = quantization 101 | 102 | def set_midi_recording_quantization(self, ns, name): 103 | quantization = REC_QUANTIZATIONS.get(str(name), REC_QUANTIZATIONS['rec_q_no_q']) 104 | ns.midi_recording_quantization = quantization 105 | 106 | def safe_start_playing(self, ns): 107 | if not self.song.is_playing: 108 | self.song.start_playing() 109 | return True 110 | 111 | return False 112 | 113 | def safe_stop_playing(self, ns): 114 | if self.song.is_playing: 115 | self.song.stop_playing() 116 | return True 117 | 118 | return False 119 | -------------------------------------------------------------------------------- /midi-script/SongView.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .DeviceParameter import DeviceParameter 4 | from .Scene import Scene 5 | from .Track import Track 6 | from .Clip import Clip 7 | from .ClipSlot import ClipSlot 8 | 9 | 10 | class SongView(Interface): 11 | def __init__(self, c_instance, socket): 12 | super(SongView, self).__init__(c_instance, socket) 13 | 14 | def get_ns(self, nsid): 15 | return self.ableton.song().view 16 | 17 | def get_detail_clip(self, ns): 18 | return Clip.serialize_clip(ns.detail_clip) 19 | 20 | def set_detail_clip(self, ns, clip_id): 21 | ns.detail_clip = Interface.get_obj(clip_id) 22 | 23 | def select_device(self, ns, device_id): 24 | return ns.select_device(Interface.get_obj(device_id)) 25 | 26 | def get_selected_parameter(self, ns): 27 | return DeviceParameter.serialize_device_parameter(ns.selected_parameter) 28 | 29 | def get_selected_track(self, ns): 30 | return Track.serialize_track(ns.selected_track) 31 | 32 | def set_selected_track(self, ns, track_id): 33 | ns.selected_track = Interface.get_obj(track_id) 34 | 35 | def get_selected_scene(self, ns): 36 | return Scene.serialize_scene(ns.selected_scene) 37 | 38 | def set_selected_scene(self, ns, scene_id): 39 | ns.selected_scene = Interface.get_obj(scene_id) 40 | 41 | def get_highlighted_clip_slot(self, ns): 42 | return ClipSlot.serialize_clip_slot(ns.highlighted_clip_slot) 43 | 44 | def set_highlighted_clip_slot(self, ns, slot_id): 45 | ns.highlighted_clip_slot = Interface.get_obj(slot_id) 46 | -------------------------------------------------------------------------------- /midi-script/Track.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .Interface import Interface 4 | from .MixerDevice import MixerDevice 5 | from .Device import Device 6 | from .Clip import Clip 7 | from .ClipSlot import ClipSlot 8 | 9 | 10 | class Track(Interface): 11 | @staticmethod 12 | def serialize_track(track): 13 | if track is None: 14 | return None 15 | 16 | track_id = Interface.save_obj(track) 17 | 18 | solo = False 19 | mute = False 20 | 21 | try: 22 | solo = track.solo 23 | except: 24 | pass 25 | 26 | try: 27 | mute = track.mute 28 | except: 29 | pass 30 | 31 | return { 32 | "id": track_id, 33 | "name": track.name, 34 | "solo": solo, 35 | "mute": mute, 36 | "color": track.color, 37 | "color_index": track.color_index, 38 | "is_foldable": track.is_foldable, 39 | "is_grouped": track.is_grouped 40 | } 41 | 42 | @staticmethod 43 | def serialize_routing_channel(channel): 44 | return {"display_name": channel.display_name, "layout": channel.layout} 45 | 46 | @staticmethod 47 | def serialize_routing_type(type): 48 | return {"display_name": type.display_name, "category": type.category} 49 | 50 | def __init__(self, c_instance, socket): 51 | super(Track, self).__init__(c_instance, socket) 52 | 53 | def get_arrangement_clips(self, ns): 54 | return map(Clip.serialize_clip, ns.arrangement_clips) 55 | 56 | def get_available_input_routing_channels(self, ns): 57 | return map(Track.serialize_routing_channel, ns.available_input_routing_channels) 58 | 59 | def get_available_input_routing_types(self, ns): 60 | return map(Track.serialize_routing_type, ns.available_input_routing_types) 61 | 62 | def get_available_output_routing_channels(self, ns): 63 | return map(Track.serialize_routing_channel, ns.available_output_routing_channels) 64 | 65 | def get_available_output_routing_types(self, ns): 66 | return map(Track.serialize_routing_type, ns.available_output_routing_types) 67 | 68 | def get_devices(self, ns): 69 | return map(Device.serialize_device, ns.devices) 70 | 71 | def get_clip_slots(self, ns): 72 | return map(ClipSlot.serialize_clip_slot, ns.clip_slots) 73 | 74 | def get_group_track(self, ns): 75 | return Track.serialize_track(ns.group_track) 76 | 77 | def get_mixer_device(self, ns): 78 | return MixerDevice.serialize_mixer_device(ns.mixer_device) 79 | 80 | def duplicate_clip_to_arrangement(self, ns, clip_id, time): 81 | return ns.duplicate_clip_to_arrangement(self.get_obj(clip_id), time) 82 | -------------------------------------------------------------------------------- /midi-script/TrackView.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .Interface import Interface 3 | from .Device import Device 4 | 5 | import Live 6 | 7 | INSERT_MODES = {'default': Live.Track.DeviceInsertMode.default, 8 | 'left': Live.Track.DeviceInsertMode.selected_left, 9 | 'right': Live.Track.DeviceInsertMode.selected_right} 10 | 11 | 12 | class TrackView(Interface): 13 | def __init__(self, c_instance, socket): 14 | super(TrackView, self).__init__(c_instance, socket) 15 | 16 | def get_ns(self, nsid): 17 | return Interface.obj_ids[nsid].view 18 | 19 | def get_selected_device(self, ns): 20 | return Device.serialize_device(ns.selected_device) 21 | 22 | def set_device_insert_mode(self, ns, name): 23 | mode = INSERT_MODES.get(str(name), INSERT_MODES['default']) 24 | ns.device_insert_mode = mode 25 | -------------------------------------------------------------------------------- /midi-script/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import sys 3 | 4 | from .AbletonJS import AbletonJS 5 | 6 | 7 | def create_instance(c_instance): 8 | return AbletonJS(c_instance) 9 | -------------------------------------------------------------------------------- /midi-script/setup.cfg: -------------------------------------------------------------------------------- 1 | [install] 2 | prefix= 3 | 4 | [pycodestyle] 5 | ignore = E402 -------------------------------------------------------------------------------- /midi-script/version.py: -------------------------------------------------------------------------------- 1 | version = "3.6.1" 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ableton-js", 3 | "version": "3.6.1", 4 | "description": "Control Ableton Live from Node", 5 | "main": "index.js", 6 | "author": "Leo Bernard ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/leolabs/ableton-js.git" 11 | }, 12 | "files": [ 13 | "**/*.js", 14 | "**/*.d.ts", 15 | "midi-script/*.py" 16 | ], 17 | "scripts": { 18 | "ableton:clean": "rm -f midi-script/AbletonJS/*.pyc", 19 | "ableton:copy-script": "set -- ~/Music/Ableton/User\\ Library/Remote\\ Scripts && mkdir -p \"$1\" && rm -rf \"$1/AbletonJS\" && cp -r \"$(pwd)/midi-script\" \"$1/AbletonJS\" && rm -rf \"$1/AbletonJS/_Framework\"", 20 | "ableton10:launch": "set -- /Applications/Ableton*10* && open \"$1\"", 21 | "ableton11:launch": "set -- /Applications/Ableton*11* && open \"$1\"", 22 | "ableton12:launch": "set -- /Applications/Ableton*12* && open \"$1\"", 23 | "ableton:logs": "tail -n 50 -f ~/Library/Preferences/Ableton/*/Log.txt | grep --line-buffered -i -e AbletonJS", 24 | "ableton:kill": "pkill -KILL -f \"Ableton Live\"", 25 | "ableton10:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton10:launch && yarn ableton:logs", 26 | "ableton11:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton11:launch && yarn ableton:logs", 27 | "ableton12:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton11:launch && yarn ableton:logs", 28 | "prepublishOnly": "yarn build", 29 | "build:doc": "jsdoc2md --files src/**/*.ts --configure ./jsdoc2md.json > ./API.md", 30 | "version": "node hooks/prepublish.js && git add midi-script/version.py && auto-changelog -p -l 100 && git add CHANGELOG.md", 31 | "build": "tsc", 32 | "test": "vitest --run --no-threads", 33 | "format": "prettier -w src/" 34 | }, 35 | "devDependencies": { 36 | "@types/lodash": "^4.14.194", 37 | "@types/node": "^20.3.0", 38 | "@types/node-uuid": "^0.0.28", 39 | "@types/semver": "^7.3.6", 40 | "@types/uuid": "^8.3.0", 41 | "auto-changelog": "^2.3.0", 42 | "p-all": "^3", 43 | "prettier": "^3.3.3", 44 | "tsx": "^3.12.7", 45 | "typescript": "^5.1.3", 46 | "vitest": "^0.32.4" 47 | }, 48 | "dependencies": { 49 | "lodash": "^4.17.21", 50 | "lru-cache": "^7.14.0", 51 | "semver": "^7.3.5", 52 | "uuid": "^8.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import path from "path"; 3 | import dgram from "dgram"; 4 | import { truncate } from "lodash"; 5 | import { EventEmitter } from "events"; 6 | import { v4 } from "uuid"; 7 | import semver from "semver"; 8 | import { unzipSync, deflateSync } from "zlib"; 9 | import LruCache from "lru-cache"; 10 | import { unwatchFile, watchFile } from "fs"; 11 | import { readFile, writeFile } from "fs/promises"; 12 | 13 | import { Song } from "./ns/song"; 14 | import { Internal } from "./ns/internal"; 15 | import { Application } from "./ns/application"; 16 | import { Midi } from "./ns/midi"; 17 | import { getPackageVersion } from "./util/package-version"; 18 | import { Cache, isCached, CacheResponse } from "./util/cache"; 19 | import { Logger } from "./util/logger"; 20 | import { Session } from "./ns/session"; 21 | 22 | const SERVER_PORT_FILE = "ableton-js-server.port"; 23 | const CLIENT_PORT_FILE = "ableton-js-client.port"; 24 | 25 | interface Command { 26 | uuid: string; 27 | ns: string; 28 | nsid?: string; 29 | name: string; 30 | etag?: string; 31 | cache?: boolean; 32 | args?: { [k: string]: any }; 33 | } 34 | 35 | interface Response { 36 | uuid: string; 37 | event: "result" | "error" | "connect" | "disconnect" | string; 38 | data: any; 39 | } 40 | 41 | type DisconnectEventType = "realtime" | "heartbeat"; 42 | type ConnectEventType = DisconnectEventType | "start"; 43 | 44 | interface ConnectionEventEmitter { 45 | on(e: "connect", l: (t: ConnectEventType) => void): this; 46 | on(e: "disconnect", l: (t: DisconnectEventType) => void): this; 47 | on(e: "message", l: (t: any) => void): this; 48 | on(e: "error", l: (t: Error) => void): this; 49 | on(e: "ping", l: (t: number) => void): this; 50 | } 51 | 52 | export interface EventListener { 53 | prop: string; 54 | eventId: string; 55 | listener: (data: any) => any; 56 | } 57 | 58 | export class TimeoutError extends Error { 59 | constructor( 60 | public message: string, 61 | public payload: Command, 62 | ) { 63 | super(message); 64 | } 65 | } 66 | 67 | export class DisconnectError extends Error { 68 | constructor( 69 | public message: string, 70 | public payload: Command, 71 | ) { 72 | super(message); 73 | } 74 | } 75 | 76 | export interface AbletonOptions { 77 | /** 78 | * Name of the file containing the port of the Remote Script. This 79 | * file is expected to be in the OS' tmp directory. 80 | * 81 | * @default ableton-js-server.port 82 | */ 83 | serverPortFile?: string; 84 | 85 | /** 86 | * Name of the file containing the port of the client. This file 87 | * is created in the OS' tmp directory if it doesn't exist yet. 88 | * 89 | * @default ableton-js-client.port 90 | */ 91 | clientPortFile?: string; 92 | 93 | /** 94 | * Defines how regularly ableton-js should ping the Remote Script 95 | * to check if it's still reachable, in milliseconds. 96 | * 97 | * @default 2000 98 | */ 99 | heartbeatInterval?: number; 100 | 101 | /** 102 | * Defines how long ableton-js waits for an answer from the Remote 103 | * Script after sending a command before throwing a timeout error. 104 | * 105 | * @default 2000 106 | */ 107 | commandTimeoutMs?: number; 108 | 109 | /** 110 | * Defines how long ableton-js waits for an answer from the Remote 111 | * Script after sending a command logging a warning about the delay. 112 | * 113 | * @default 1000 114 | */ 115 | commandWarnMs?: number; 116 | 117 | /** 118 | * Options for the response cache. 119 | */ 120 | cacheOptions?: LruCache.Options; 121 | 122 | /** 123 | * Completely disables the cache. 124 | */ 125 | disableCache?: boolean; 126 | 127 | /** 128 | * Set this to allow ableton-js to log messages. If you set this to 129 | * `console`, log messages are printed to the standard output. 130 | */ 131 | logger?: Logger; 132 | } 133 | 134 | export class Ableton extends EventEmitter implements ConnectionEventEmitter { 135 | private client: dgram.Socket | undefined; 136 | private msgMap = new Map< 137 | string, 138 | { 139 | res: (data: any) => any; 140 | rej: (data: any) => any; 141 | clearTimeout: () => any; 142 | } 143 | >(); 144 | private eventListeners = new Map any>>(); 145 | private heartbeatInterval: NodeJS.Timeout | undefined; 146 | private _isConnected = false; 147 | private buffer: Buffer[][] = []; 148 | private latency: number = 0; 149 | 150 | private serverPort: number | undefined; 151 | 152 | public cache?: Cache; 153 | public song = new Song(this); 154 | public session = new Session(this); // added for red session ring control 155 | public application = new Application(this); 156 | public internal = new Internal(this); 157 | public midi = new Midi(this); 158 | 159 | private clientPortFile: string; 160 | private serverPortFile: string; 161 | private logger: Logger | undefined; 162 | private clientState: "closed" | "starting" | "started" = "closed"; 163 | private cancelDisconnectEvents: Array<() => unknown> = []; 164 | 165 | constructor(private options?: AbletonOptions) { 166 | super(); 167 | 168 | this.logger = options?.logger; 169 | 170 | if (!options?.disableCache) { 171 | this.cache = new LruCache({ 172 | max: 500, 173 | ttl: 1000 * 60 * 10, 174 | ...options?.cacheOptions, 175 | }); 176 | } 177 | 178 | this.clientPortFile = path.join( 179 | os.tmpdir(), 180 | this.options?.clientPortFile ?? CLIENT_PORT_FILE, 181 | ); 182 | 183 | this.serverPortFile = path.join( 184 | os.tmpdir(), 185 | this.options?.serverPortFile ?? SERVER_PORT_FILE, 186 | ); 187 | } 188 | 189 | private handleConnect(type: ConnectEventType) { 190 | if (!this._isConnected) { 191 | this._isConnected = true; 192 | this.logger?.info("Live connected", { type }); 193 | this.emit("connect", type); 194 | } 195 | } 196 | 197 | private handleDisconnect(type: DisconnectEventType) { 198 | if (this._isConnected) { 199 | this._isConnected = false; 200 | this.eventListeners.clear(); 201 | 202 | // If the disconnect is caused by missed heartbeats, keep 203 | // pending requests. Live might just be temporarily hanging. 204 | if (type === "realtime") { 205 | this.msgMap.forEach((msg) => msg.clearTimeout()); 206 | this.msgMap.clear(); 207 | } 208 | 209 | this.logger?.info("Live disconnected", { type }); 210 | this.emit("disconnect", type); 211 | } 212 | } 213 | 214 | /** 215 | * If connected, returns immediately. Otherwise, 216 | * it waits for a connection event before returning. 217 | */ 218 | async waitForConnection() { 219 | if (this._isConnected) { 220 | return; 221 | } else { 222 | return Promise.race([ 223 | new Promise((res) => this.once("connect", res)), 224 | this.internal.get("ping").catch(() => new Promise(() => {})), 225 | ]); 226 | } 227 | } 228 | 229 | /** 230 | * Starts the server and waits for a connection with Live to be established. 231 | * 232 | * @param timeoutMs 233 | * If set, the function will throw an error if it can't establish a connection 234 | * in the given time. Should be higher than 2000ms to avoid false positives. 235 | */ 236 | async start(timeoutMs?: number) { 237 | if (this.clientState !== "closed") { 238 | this.logger?.warn( 239 | "Tried calling start, but client is already " + this.clientState, 240 | ); 241 | return this.waitForConnection(); 242 | } 243 | 244 | this.clientState = "starting"; 245 | 246 | // The recvBufferSize is set to macOS' default value, so the 247 | // socket behaves the same on Windows and doesn't drop any packets 248 | this.client = dgram.createSocket({ type: "udp4", recvBufferSize: 786896 }); 249 | this.client.addListener("message", this.handleIncoming.bind(this)); 250 | 251 | this.client.addListener("listening", async () => { 252 | const port = this.client?.address().port; 253 | this.logger?.info("Client is bound and listening", { port }); 254 | 255 | // Write used port to a file so Live can read from it on startup 256 | await writeFile(this.clientPortFile, String(port)); 257 | }); 258 | 259 | this.client.bind(undefined, "127.0.0.1"); 260 | 261 | // Wait for the server port file to exist 262 | const sentPort = await new Promise(async (res) => { 263 | try { 264 | const serverPort = await readFile(this.serverPortFile); 265 | this.serverPort = Number(serverPort.toString()); 266 | this.logger?.info("Server port:", { port: this.serverPort }); 267 | res(false); 268 | } catch (e) { 269 | this.logger?.info( 270 | "Server doesn't seem to be online yet, waiting for it to go online...", 271 | ); 272 | } 273 | 274 | // Set up a watcher in case the server port changes 275 | watchFile(this.serverPortFile, async (curr) => { 276 | if (curr.isFile()) { 277 | const serverPort = await readFile(this.serverPortFile); 278 | const newPort = Number(serverPort.toString()); 279 | 280 | if (!isNaN(newPort) && newPort !== this.serverPort) { 281 | this.logger?.info("Server port changed:", { port: newPort }); 282 | this.serverPort = Number(serverPort.toString()); 283 | 284 | if (this.client) { 285 | try { 286 | const port = this.client.address().port; 287 | this.logger?.info("Sending port to Live:", { port }); 288 | await this.setProp("internal", "", "client_port", port); 289 | res(true); 290 | return; 291 | } catch (e) { 292 | this.logger?.info("Sending port to Live failed", { e }); 293 | } 294 | } 295 | } 296 | 297 | res(false); 298 | } 299 | }); 300 | }); 301 | 302 | // Send used port to Live in case the plugin is already started 303 | if (!sentPort) { 304 | try { 305 | const port = this.client.address().port; 306 | this.logger?.info("Sending port to Live:", { port }); 307 | await this.setProp("internal", "", "client_port", port); 308 | } catch (e) { 309 | this.logger?.info("Live doesn't seem to be loaded yet, waiting..."); 310 | } 311 | } 312 | 313 | this.logger?.info("Checking connection..."); 314 | const connection = this.waitForConnection(); 315 | 316 | if (timeoutMs) { 317 | const timeout = new Promise((_, rej) => 318 | setTimeout(() => rej("Connection timed out."), timeoutMs), 319 | ); 320 | await Promise.race([connection, timeout]); 321 | } else { 322 | await connection; 323 | } 324 | 325 | this.logger?.info("Got connection!"); 326 | 327 | this.clientState = "started"; 328 | this.handleConnect("start"); 329 | 330 | const heartbeat = async () => { 331 | // Add a cancel function to the array of heartbeats 332 | let canceled = false; 333 | const cancel = () => { 334 | canceled = true; 335 | this.logger?.debug("Cancelled heartbeat"); 336 | }; 337 | this.cancelDisconnectEvents.push(cancel); 338 | 339 | try { 340 | await this.internal.get("ping"); 341 | this.handleConnect("heartbeat"); 342 | } catch (e) { 343 | // If the heartbeat has been canceled, don't emit a disconnect event 344 | if (!canceled && this._isConnected) { 345 | this.logger?.warn("Heartbeat failed:", { error: e, canceled }); 346 | this.handleDisconnect("heartbeat"); 347 | } 348 | } finally { 349 | this.cancelDisconnectEvents = this.cancelDisconnectEvents.filter( 350 | (e) => e !== cancel, 351 | ); 352 | } 353 | }; 354 | 355 | this.heartbeatInterval = setInterval( 356 | heartbeat, 357 | this.options?.heartbeatInterval ?? 2000, 358 | ); 359 | heartbeat(); 360 | 361 | this.internal 362 | .get("version") 363 | .then((v) => { 364 | const jsVersion = getPackageVersion(); 365 | if (semver.lt(v, jsVersion)) { 366 | this.logger?.warn( 367 | `The installed version of your AbletonJS plugin (${v}) is lower than the JS library (${jsVersion}).`, 368 | "Please update your AbletonJS plugin to the latest version: https://git.io/JvaOu", 369 | ); 370 | } 371 | }) 372 | .catch(() => {}); 373 | } 374 | 375 | /** Closes the client */ 376 | async close() { 377 | this.logger?.info("Closing the client"); 378 | unwatchFile(this.serverPortFile); 379 | 380 | if (this.heartbeatInterval) { 381 | clearInterval(this.heartbeatInterval); 382 | } 383 | 384 | if (this.client) { 385 | const closePromise = new Promise((res) => 386 | this.client?.once("close", res), 387 | ); 388 | this.client.close(); 389 | await closePromise; 390 | } 391 | 392 | this.clientState = "closed"; 393 | this._isConnected = false; 394 | this.logger?.info("Client closed"); 395 | } 396 | 397 | /** 398 | * Returns the latency between the last command and its response. 399 | * This is a rough measurement, so don't rely too much on it. 400 | */ 401 | getPing() { 402 | return this.latency; 403 | } 404 | 405 | private setPing(latency: number) { 406 | this.latency = latency; 407 | this.emit("ping", this.latency); 408 | } 409 | 410 | private handleIncoming(msg: Buffer, info: dgram.RemoteInfo) { 411 | try { 412 | const messageId = msg[0]; 413 | const messageIndex = msg[1]; 414 | const totalMessages = msg[2]; 415 | const message = msg.subarray(3); 416 | 417 | if (messageIndex === 0 && totalMessages === 1) { 418 | this.handleUncompressedMessage(unzipSync(message).toString()); 419 | return; 420 | } 421 | 422 | if (!this.buffer[messageId]) { 423 | this.buffer[messageId] = []; 424 | } 425 | 426 | this.buffer[messageId][messageIndex] = message; 427 | 428 | if ( 429 | !this.buffer[messageId].includes(undefined as any) && 430 | this.buffer[messageId].length === totalMessages 431 | ) { 432 | this.handleUncompressedMessage( 433 | unzipSync(Buffer.concat(this.buffer[messageId])).toString(), 434 | ); 435 | delete this.buffer[messageId]; 436 | } 437 | } catch (e) { 438 | this.buffer = []; 439 | this.emit("error", e); 440 | } 441 | } 442 | 443 | private handleUncompressedMessage(msg: string) { 444 | this.emit("raw_message", msg); 445 | const data: Response = JSON.parse(msg); 446 | const functionCallback = this.msgMap.get(data.uuid); 447 | 448 | this.emit("message", data); 449 | 450 | if (data.event === "result" && functionCallback) { 451 | this.msgMap.delete(data.uuid); 452 | return functionCallback.res(data.data); 453 | } 454 | 455 | if (data.event === "error" && functionCallback) { 456 | this.msgMap.delete(data.uuid); 457 | return functionCallback.rej(new Error(data.data)); 458 | } 459 | 460 | if (data.event === "disconnect") { 461 | return this.handleDisconnect("realtime"); 462 | } 463 | 464 | if (data.event === "connect") { 465 | // If some heartbeat ping from the old connection is still pending, 466 | // cancel it to prevent a double disconnect/connect event. 467 | this.cancelDisconnectEvents.forEach((cancel) => cancel()); 468 | 469 | if (data.data?.port && data.data?.port !== this.serverPort) { 470 | this.logger?.info("Got new server port via connect:", { 471 | port: data.data.port, 472 | }); 473 | this.serverPort = data.data.port; 474 | } 475 | 476 | return this.handleConnect( 477 | this.clientState === "starting" ? "start" : "realtime", 478 | ); 479 | } 480 | 481 | const eventCallback = this.eventListeners.get(data.event); 482 | if (eventCallback) { 483 | return eventCallback.forEach((cb) => cb(data.data)); 484 | } 485 | 486 | if (data.uuid) { 487 | this.logger?.warn("Message could not be assigned to any request:", { 488 | msg, 489 | }); 490 | } 491 | } 492 | 493 | /** 494 | * Sends a raw command to Ableton. Usually, you won't need this. 495 | * A good starting point in general is the `song` prop. 496 | */ 497 | async sendCommand(command: Omit): Promise { 498 | return new Promise((res, rej) => { 499 | const msgId = v4(); 500 | const payload: Command = { 501 | uuid: msgId, 502 | ...command, 503 | }; 504 | const msg = JSON.stringify(payload); 505 | const timeout = this.options?.commandTimeoutMs ?? 2000; 506 | const arg = truncate(JSON.stringify(command.args), { length: 100 }); 507 | const cls = command.nsid ? `${command.ns}(${command.nsid})` : command.ns; 508 | 509 | const timeoutId = setTimeout(() => { 510 | rej( 511 | new TimeoutError( 512 | `The command ${cls}.${command.name}(${arg}) timed out after ${timeout} ms.`, 513 | payload, 514 | ), 515 | ); 516 | }, timeout); 517 | 518 | const currentTimestamp = Date.now(); 519 | this.msgMap.set(msgId, { 520 | res: (result: any) => { 521 | const duration = Date.now() - currentTimestamp; 522 | 523 | if (duration > (this.options?.commandWarnMs ?? 1000)) { 524 | this.logger?.warn(`Command took longer than expected`, { 525 | command, 526 | duration, 527 | }); 528 | } 529 | 530 | this.setPing(duration); 531 | clearTimeout(timeoutId); 532 | res(result); 533 | }, 534 | rej, 535 | clearTimeout: () => { 536 | clearTimeout(timeoutId); 537 | rej( 538 | new DisconnectError( 539 | `Live disconnected before being able to respond to ${cls}.${command.name}(${arg})`, 540 | payload, 541 | ), 542 | ); 543 | }, 544 | }); 545 | 546 | this.sendRaw(msg); 547 | }); 548 | } 549 | 550 | async sendCachedCommand(command: Omit) { 551 | const args = command.args?.prop ?? JSON.stringify(command.args); 552 | const cacheKey = [command.ns, command.nsid, args].filter(Boolean).join("/"); 553 | const cached = this.cache?.get(cacheKey); 554 | 555 | const result: CacheResponse = await this.sendCommand({ 556 | ...command, 557 | etag: cached?.etag, 558 | cache: true, 559 | }); 560 | 561 | if (isCached(result)) { 562 | if (!cached) { 563 | throw new Error("Tried to get an object that isn't cached."); 564 | } else { 565 | return cached.data; 566 | } 567 | } else { 568 | if (result.etag) { 569 | this.cache?.set(cacheKey, result); 570 | } 571 | 572 | return result.data; 573 | } 574 | } 575 | 576 | async getProp( 577 | ns: string, 578 | nsid: string | undefined, 579 | prop: string, 580 | cache?: boolean, 581 | ) { 582 | const params = { ns, nsid, name: "get_prop", args: { prop } }; 583 | 584 | if (cache && this.cache) { 585 | return this.sendCachedCommand(params); 586 | } else { 587 | return this.sendCommand(params); 588 | } 589 | } 590 | 591 | async setProp( 592 | ns: string, 593 | nsid: string | undefined, 594 | prop: string, 595 | value: any, 596 | ) { 597 | return this.sendCommand({ 598 | ns, 599 | nsid, 600 | name: "set_prop", 601 | args: { prop, value }, 602 | }); 603 | } 604 | 605 | async addPropListener( 606 | ns: string, 607 | nsid: string | undefined, 608 | prop: string, 609 | listener: (data: any) => any, 610 | ) { 611 | const eventId = v4(); 612 | const result = await this.sendCommand({ 613 | ns, 614 | nsid, 615 | name: "add_listener", 616 | args: { prop, nsid, eventId }, 617 | }); 618 | 619 | if (!this.eventListeners.has(result)) { 620 | this.eventListeners.set(result, [listener]); 621 | } else { 622 | this.eventListeners.set(result, [ 623 | ...this.eventListeners.get(result)!, 624 | listener, 625 | ]); 626 | } 627 | 628 | return () => this.removePropListener(ns, nsid, prop, result, listener); 629 | } 630 | 631 | async removePropListener( 632 | ns: string, 633 | nsid: string | undefined, 634 | prop: string, 635 | eventId: string, 636 | listener: (data: any) => any, 637 | ) { 638 | const listeners = this.eventListeners.get(eventId); 639 | if (!listeners) { 640 | return false; 641 | } 642 | 643 | if (listeners.length > 1) { 644 | this.eventListeners.set( 645 | eventId, 646 | listeners.filter((l) => l !== listener), 647 | ); 648 | return true; 649 | } 650 | 651 | if (listeners.length === 1) { 652 | this.eventListeners.delete(eventId); 653 | await this.sendCommand({ 654 | ns, 655 | nsid, 656 | name: "remove_listener", 657 | args: { prop, nsid }, 658 | }); 659 | return true; 660 | } 661 | } 662 | 663 | /** 664 | * Removes all event listeners that were attached to properties. 665 | * This is useful for clearing all listeners when Live 666 | * disconnects, for example. 667 | */ 668 | removeAllPropListeners() { 669 | this.eventListeners.clear(); 670 | } 671 | 672 | async sendRaw(msg: string) { 673 | if (!this.client || !this.serverPort) { 674 | throw new Error( 675 | "The client hasn't been started yet. Please call start() first.", 676 | ); 677 | } 678 | 679 | const buffer = deflateSync(Buffer.from(msg)); 680 | 681 | const byteLimit = this.client.getSendBufferSize() - 100; 682 | const chunks = Math.ceil(buffer.byteLength / byteLimit); 683 | 684 | // Split the message into chunks if it becomes too large 685 | for (let i = 0; i < chunks; i++) { 686 | const chunk = Buffer.concat([ 687 | // Add a counter to the message, the last message is always 255 688 | Buffer.alloc(1, i + 1 === chunks ? 255 : i), 689 | buffer.subarray(i * byteLimit, i * byteLimit + byteLimit), 690 | ]); 691 | this.client.send(chunk, 0, chunk.length, this.serverPort, "127.0.0.1"); 692 | // Add a bit of a delay between sent chunks to reduce the chance of the 693 | // receiving buffer filling up which would cause chunks to be discarded. 694 | await new Promise((res) => setTimeout(res, 20)); 695 | } 696 | } 697 | 698 | isConnected() { 699 | return this._isConnected; 700 | } 701 | } 702 | 703 | export { getPackageVersion } from "./util/package-version"; 704 | -------------------------------------------------------------------------------- /src/ns/application-view.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { GettableProperties } from "./application-view"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | "browse_mode", 7 | "focused_document_view", 8 | ]; 9 | 10 | describe("Application", () => { 11 | it("should be able to read all properties without erroring", async () => { 12 | await withAbleton(async (ab) => { 13 | await Promise.all(gettableProps.map((p) => ab.application.view.get(p))); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/ns/application-view.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export type DocumentView = "Session" | "Arranger"; 5 | export type DetailView = "Detail" | "Detail/Clip" | "Detail/DeviceChain"; 6 | export type View = "Browser" | DocumentView | DetailView; 7 | 8 | export enum NavDirection { 9 | Up, 10 | Down, 11 | Left, 12 | Right, 13 | } 14 | 15 | export interface GettableProperties { 16 | browse_mode: boolean; 17 | focused_document_view: DocumentView; 18 | } 19 | 20 | export interface TransformedProperties {} 21 | 22 | export interface SettableProperties {} 23 | 24 | export interface ObservableProperties { 25 | browse_mode: boolean; 26 | focused_document_view: DocumentView; 27 | } 28 | 29 | export class ApplicationView extends Namespace< 30 | GettableProperties, 31 | TransformedProperties, 32 | SettableProperties, 33 | ObservableProperties 34 | > { 35 | constructor(ableton: Ableton) { 36 | super(ableton, "application-view"); 37 | } 38 | 39 | async availableMainViews(): Promise { 40 | return this.sendCachedCommand("available_main_views"); 41 | } 42 | 43 | async focusView(view: View) { 44 | return this.sendCommand("focus_view", [view]); 45 | } 46 | 47 | async hideView(view: View) { 48 | return this.sendCommand("hide_view", [view]); 49 | } 50 | 51 | async isViewVisible(view: View, mainWindowOnly = true) { 52 | return this.sendCommand("is_view_visible", [view, mainWindowOnly]); 53 | } 54 | 55 | async scrollView(view: View, direction: NavDirection) { 56 | return this.sendCommand("scroll_view", [direction, view, true]); 57 | } 58 | 59 | async showView(view: View) { 60 | return this.sendCommand("show_view", [view]); 61 | } 62 | 63 | async toggleBrowse() { 64 | return this.sendCommand("toggle_browse"); 65 | } 66 | 67 | async zoomView(view: View, direction: NavDirection) { 68 | return this.sendCommand("zoom_view", [direction, view, true]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ns/application.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { GettableProperties } from "./application"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | "major_version", 7 | "minor_version", 8 | "bugfix_version", 9 | "version", 10 | "open_dialog_count", 11 | "current_dialog_message", 12 | "current_dialog_button_count", 13 | ]; 14 | 15 | describe("Application", () => { 16 | it("should be able to read all properties without erroring", async () => { 17 | await withAbleton(async (ab) => { 18 | await Promise.all(gettableProps.map((p) => ab.application.get(p))); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/ns/application.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { ApplicationView } from "./application-view"; 4 | import { Browser, RawBrowser } from "./browser"; 5 | 6 | export interface GettableProperties { 7 | bugfix_version: number; 8 | major_version: number; 9 | minor_version: number; 10 | version: string; 11 | current_dialog_button_count: number; 12 | current_dialog_message: string; 13 | open_dialog_count: number; 14 | browser: RawBrowser; 15 | // More properties are available 16 | } 17 | 18 | export interface TransformedProperties { 19 | browser: Browser; 20 | } 21 | 22 | export interface SettableProperties {} 23 | 24 | export interface ObservableProperties { 25 | open_dialog_count: number; 26 | } 27 | 28 | export class Application extends Namespace< 29 | GettableProperties, 30 | TransformedProperties, 31 | SettableProperties, 32 | ObservableProperties 33 | > { 34 | constructor(ableton: Ableton) { 35 | super(ableton, "application"); 36 | } 37 | 38 | public browser = new Browser(this.ableton); 39 | public view = new ApplicationView(this.ableton); 40 | 41 | public async pressCurrentDialogButton(index: number) { 42 | return this.sendCommand("press_current_dialog_button", [index]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ns/browser-item.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export interface GettableProperties { 5 | children: RawBrowserItem[]; 6 | is_device: boolean; 7 | is_folder: boolean; 8 | is_loadable: boolean; 9 | is_selected: boolean; 10 | name: string; 11 | source: string; 12 | uri: string; 13 | } 14 | 15 | export interface TransformedProperties { 16 | children: BrowserItem[]; 17 | } 18 | 19 | export interface SettableProperties {} 20 | 21 | export interface ObservableProperties {} 22 | 23 | export interface RawBrowserItem { 24 | readonly id: string; 25 | readonly children: RawBrowserItem[]; 26 | readonly name: string; 27 | readonly is_loadable: boolean; 28 | readonly is_selected: boolean; 29 | readonly is_device: boolean; 30 | readonly is_folder: boolean; 31 | readonly source: string; 32 | readonly uri: string; 33 | } 34 | 35 | export class BrowserItem extends Namespace< 36 | GettableProperties, 37 | TransformedProperties, 38 | SettableProperties, 39 | ObservableProperties 40 | > { 41 | constructor( 42 | ableton: Ableton, 43 | public raw: RawBrowserItem, 44 | ) { 45 | super(ableton, "browser-item", raw.id); 46 | this.transformers = { 47 | children: (children) => children.map((c) => new BrowserItem(ableton, c)), 48 | }; 49 | 50 | this.cachedProps = { 51 | children: true, 52 | is_device: true, 53 | is_folder: true, 54 | is_loadable: false, 55 | is_selected: false, 56 | name: true, 57 | source: true, 58 | uri: true, 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ns/browser.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { GettableProperties } from "./browser"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | "audio_effects", 7 | "clips", 8 | "colors", 9 | "current_project", 10 | "drums", 11 | "instruments", 12 | "max_for_live", 13 | "midi_effects", 14 | "packs", 15 | "plugins", 16 | "samples", 17 | "sounds", 18 | "user_folders", 19 | "user_library", 20 | "hotswap_target", 21 | ]; 22 | 23 | describe("Application", () => { 24 | it("should be able to read all properties without erroring", async () => { 25 | await withAbleton(async (ab) => { 26 | await Promise.all( 27 | gettableProps.map((p) => ab.application.browser.get(p)), 28 | ); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/ns/browser.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { BrowserItem, RawBrowserItem } from "./browser-item"; 4 | 5 | export interface GettableProperties { 6 | audio_effects: RawBrowserItem[]; 7 | clips: RawBrowserItem[]; 8 | colors: RawBrowserItem[]; 9 | current_project: RawBrowserItem[]; 10 | drums: RawBrowserItem[]; 11 | instruments: RawBrowserItem[]; 12 | max_for_live: RawBrowserItem[]; 13 | midi_effects: RawBrowserItem[]; 14 | packs: RawBrowserItem[]; 15 | plugins: RawBrowserItem[]; 16 | samples: RawBrowserItem[]; 17 | sounds: RawBrowserItem[]; 18 | user_library: RawBrowserItem[]; 19 | user_folders: RawBrowserItem[]; 20 | hotswap_target: RawBrowserItem; 21 | } 22 | 23 | export interface TransformedProperties { 24 | audio_effects: BrowserItem[]; 25 | clips: BrowserItem[]; 26 | colors: BrowserItem[]; 27 | current_project: BrowserItem[]; 28 | drums: BrowserItem[]; 29 | instruments: BrowserItem[]; 30 | max_for_live: BrowserItem[]; 31 | midi_effects: BrowserItem[]; 32 | packs: BrowserItem[]; 33 | plugins: BrowserItem[]; 34 | samples: BrowserItem[]; 35 | sounds: BrowserItem[]; 36 | user_library: BrowserItem[]; 37 | user_folders: BrowserItem[]; 38 | hotswap_target: BrowserItem; 39 | } 40 | 41 | export interface SettableProperties {} 42 | 43 | export interface ObservableProperties { 44 | filter_type: never; 45 | // remote script stalls when hotswap is activated, so we only get a bang when deactivated 46 | hotswap_target: BrowserItem; 47 | } 48 | 49 | export interface RawBrowser { 50 | readonly id: string; 51 | } 52 | 53 | export class Browser extends Namespace< 54 | GettableProperties, 55 | TransformedProperties, 56 | SettableProperties, 57 | ObservableProperties 58 | > { 59 | constructor(ableton: Ableton) { 60 | super(ableton, "browser"); 61 | 62 | const makeBrowserItems = (items: RawBrowserItem[]) => 63 | items.map((item) => new BrowserItem(ableton, item)); 64 | 65 | this.transformers = { 66 | audio_effects: makeBrowserItems, 67 | clips: makeBrowserItems, 68 | colors: makeBrowserItems, 69 | current_project: makeBrowserItems, 70 | drums: makeBrowserItems, 71 | instruments: makeBrowserItems, 72 | max_for_live: makeBrowserItems, 73 | midi_effects: makeBrowserItems, 74 | packs: makeBrowserItems, 75 | plugins: makeBrowserItems, 76 | samples: makeBrowserItems, 77 | sounds: makeBrowserItems, 78 | user_library: makeBrowserItems, 79 | user_folders: makeBrowserItems, 80 | hotswap_target: (t) => new BrowserItem(ableton, t), 81 | }; 82 | 83 | this.cachedProps = { 84 | audio_effects: true, 85 | clips: true, 86 | colors: true, 87 | current_project: true, 88 | drums: true, 89 | instruments: true, 90 | max_for_live: true, 91 | midi_effects: true, 92 | packs: true, 93 | plugins: true, 94 | samples: true, 95 | sounds: true, 96 | user_library: true, 97 | user_folders: true, 98 | hotswap_target: true, 99 | }; 100 | } 101 | 102 | /** Loads the provided browser item. */ 103 | public async loadItem(item: BrowserItem) { 104 | return this.sendCommand("load_item", { id: item.raw.id }); 105 | } 106 | 107 | /** Previews the provided browser item. */ 108 | public async previewItem(item: BrowserItem) { 109 | return this.sendCommand("preview_item", { id: item.raw.id }); 110 | } 111 | 112 | /** Stops the current preview. */ 113 | public async stopPreview() { 114 | return this.sendCommand("stop_preview"); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/ns/clip-slot.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { Color } from "../util/color"; 4 | import { Clip, RawClip } from "./clip"; 5 | 6 | export enum PlayingStatus { 7 | Stopped = "stopped", 8 | Playing = "playing", 9 | Recording = "recording", 10 | } 11 | 12 | export interface GettableProperties { 13 | clip: RawClip | null; 14 | color: number; 15 | color_index: number; 16 | controls_other_clips: boolean; 17 | has_clip: boolean; 18 | has_stop_button: boolean; 19 | is_group_slot: boolean; 20 | is_playing: boolean; 21 | is_recording: boolean; 22 | is_triggered: boolean; 23 | playing_status: PlayingStatus; 24 | will_record_on_start: boolean; 25 | } 26 | 27 | export interface TransformedProperties { 28 | clip: Clip | null; 29 | color: Color; 30 | } 31 | 32 | export interface SettableProperties { 33 | name: string; 34 | color: number; 35 | } 36 | 37 | export interface ObservableProperties { 38 | color_index: number; 39 | color: number; 40 | controls_other_clips: boolean; 41 | has_clip: boolean; 42 | has_stop_button: boolean; 43 | is_triggered: boolean; 44 | playing_status: PlayingStatus; 45 | } 46 | 47 | export interface RawClipSlot { 48 | readonly id: string; 49 | readonly color: number; 50 | readonly has_clip: boolean; 51 | readonly is_playing: boolean; 52 | readonly is_recording: boolean; 53 | readonly is_triggered: boolean; 54 | } 55 | 56 | /** 57 | * This class represents an entry in Live's Session view matrix. 58 | */ 59 | export class ClipSlot extends Namespace< 60 | GettableProperties, 61 | TransformedProperties, 62 | SettableProperties, 63 | ObservableProperties 64 | > { 65 | constructor( 66 | ableton: Ableton, 67 | public raw: RawClipSlot, 68 | ) { 69 | super(ableton, "clip_slot", raw.id); 70 | 71 | this.transformers = { 72 | clip: (c) => (c ? new Clip(ableton, c) : null), 73 | color: (c) => new Color(c), 74 | }; 75 | 76 | this.cachedProps = { 77 | clip: true, 78 | }; 79 | } 80 | 81 | /** 82 | * Creates an empty clip with the given length in the slot. 83 | * Throws an error when called on non-empty slots or slots in non-MIDI tracks. 84 | */ 85 | createClip(length: number) { 86 | return this.sendCommand("create_clip", [length]); 87 | } 88 | 89 | /** 90 | * Removes the clip contained in the slot. 91 | * Raises an exception if the slot was empty. 92 | */ 93 | deleteClip() { 94 | return this.sendCommand("delete_clip"); 95 | } 96 | 97 | duplicateClipTo(slot: ClipSlot) { 98 | return this.sendCommand("duplicate_clip_to", { slot_id: slot.raw.id }); 99 | } 100 | 101 | /** 102 | * Fire a Clip if this Clipslot owns one, 103 | * else trigger the stop button, if we have one. 104 | */ 105 | fire() { 106 | return this.sendCommand("fire"); 107 | } 108 | 109 | /** 110 | * Set the ClipSlot's fire button state directly. 111 | * Supports all launch modes. 112 | */ 113 | setFireButtonState(state: boolean) { 114 | return this.sendCommand("set_fire_button_state", [state]); 115 | } 116 | 117 | /** 118 | * Stop playing the contained Clip, 119 | * if there is a Clip and its currently playing. 120 | */ 121 | stop() { 122 | return this.sendCommand("stop"); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/ns/clip.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { Color } from "../util/color"; 4 | import { DeviceParameter } from "./device-parameter"; 5 | import { Note, noteToTuple, NoteTuple, tupleToNote } from "../util/note"; 6 | 7 | export enum WarpMode { 8 | Beats = 0, 9 | Tones = 1, 10 | Texture = 2, 11 | Repitch = 3, 12 | Complex = 4, 13 | ComplexPro = 6, 14 | } 15 | 16 | export enum LaunchMode { 17 | Trigger = 0, 18 | Gate = 1, 19 | Toggle = 2, 20 | Repeat = 3, 21 | } 22 | 23 | export enum LaunchQuantization { 24 | QGlobal = 0, 25 | QNone = 1, 26 | Q8Bars = 2, 27 | Q4Bars = 3, 28 | Q2Bars = 4, 29 | QBar = 5, 30 | QHalf = 6, 31 | QHalfTriplet = 7, 32 | QQuarter = 8, 33 | QQuarterTriplet = 9, 34 | QEighth = 10, 35 | QEighthTriplet = 11, 36 | QSixteenth = 12, 37 | QSixteenthTriplet = 13, 38 | QThirtySecond = 14, 39 | } 40 | 41 | interface WarpMarker { 42 | beat_time: number; 43 | sample_time: number; 44 | } 45 | 46 | export interface GettableProperties { 47 | available_warp_modes: WarpMode[]; 48 | color: number; 49 | color_index: number; 50 | end_marker: number; 51 | end_time: number; 52 | file_path: string; 53 | gain: number; 54 | gain_display_string: string; 55 | has_envelopes: boolean; 56 | is_arrangement_clip: boolean; 57 | is_audio_clip: boolean; 58 | is_midi_clip: boolean; 59 | is_overdubbing: boolean; 60 | is_playing: boolean; 61 | is_recording: boolean; 62 | is_triggered: boolean; 63 | launch_mode: LaunchMode; 64 | launch_quantization: LaunchQuantization; 65 | length: number; 66 | loop_end: number; 67 | loop_start: number; 68 | looping: boolean; 69 | muted: boolean; 70 | name: string; 71 | pitch_coarse: number; 72 | pitch_fine: number; 73 | playing_position: number; 74 | position: number; 75 | ram_mode: boolean; 76 | sample_length: number; 77 | selected_notes: NoteTuple[]; 78 | signature_denominator: number; 79 | signature_numerator: number; 80 | start_marker: number; 81 | start_time: number; 82 | velocity_amount: number; 83 | //view: unknown; 84 | warp_mode: WarpMode; 85 | warp_markers: WarpMarker[]; // Only supported in ableton 11 86 | warping: boolean; 87 | will_record_on_start: boolean; 88 | } 89 | 90 | export interface TransformedProperties { 91 | color: Color; 92 | notes: Note[]; 93 | selected_notes: Note[]; 94 | } 95 | 96 | export interface SettableProperties { 97 | name: string; 98 | color: Color | number; 99 | color_index: number; 100 | end_marker: number; 101 | gain: number; 102 | is_playing: boolean; 103 | launch_mode: LaunchMode; 104 | launch_quantization: LaunchQuantization; 105 | loop_end: number; 106 | loop_start: number; 107 | looping: boolean; 108 | muted: boolean; 109 | pitch_coarse: number; 110 | pitch_fine: number; 111 | position: number; 112 | ram_mode: boolean; 113 | signature_denominator: number; 114 | signature_numerator: number; 115 | start_marker: number; 116 | velocity_amount: number; 117 | warp_mode: WarpMode; 118 | warping: boolean; 119 | } 120 | 121 | export interface ObservableProperties { 122 | color_index: number; 123 | color: number; 124 | end_marker: number; 125 | end_time: number; 126 | file_path: string; 127 | gain: number; 128 | has_envelopes: boolean; 129 | is_overdubbing: boolean; 130 | is_recording: boolean; 131 | loop_end: number; 132 | loop_start: number; 133 | muted: boolean; 134 | name: string; 135 | notes: NoteTuple[]; 136 | pitch_coarse: number; 137 | pitch_fine: number; 138 | playing_position: number; 139 | position: number; 140 | signature_denominator: number; 141 | signature_numerator: number; 142 | start_marker: number; 143 | warp_markers: unknown; 144 | warping: boolean; 145 | } 146 | 147 | export interface RawClip { 148 | readonly id: string; 149 | readonly name: string; 150 | readonly color: number; 151 | readonly color_index: number; 152 | readonly is_audio_clip: boolean; 153 | readonly is_midi_clip: boolean; 154 | readonly start_time: number; 155 | readonly end_time: number; 156 | readonly muted: boolean; 157 | } 158 | 159 | /** 160 | * This class represents an entry in Live's Session view matrix. 161 | */ 162 | export class Clip extends Namespace< 163 | GettableProperties, 164 | TransformedProperties, 165 | SettableProperties, 166 | ObservableProperties 167 | > { 168 | constructor( 169 | ableton: Ableton, 170 | public raw: RawClip, 171 | ) { 172 | super(ableton, "clip", raw.id); 173 | 174 | this.transformers = { 175 | color: (c) => new Color(c), 176 | notes: (n) => (n as NoteTuple[]).map(tupleToNote), 177 | selected_notes: (n) => n.map(tupleToNote), 178 | }; 179 | } 180 | 181 | /** 182 | * Available for audio clips only. 183 | * Converts the given beat time to sample time. 184 | * Raises an error if the sample is not warped. 185 | */ 186 | beatToSampleTime(beats: number): Promise { 187 | return this.sendCommand("beat_to_sample_time", [beats]); 188 | } 189 | 190 | /** 191 | * Clears all envelopes for this clip. 192 | */ 193 | clearAllEnvelopes(): Promise { 194 | return this.sendCommand("clear_all_envelopes"); 195 | } 196 | 197 | /** 198 | * Clears the envelope of this clip's given parameter. 199 | */ 200 | clearEnvelope(parameter: DeviceParameter): Promise { 201 | return this.sendCommand("clear_envelope", { parameter }); 202 | } 203 | 204 | /** 205 | * Creates an envelope for a given parameter and returns it. 206 | * This should only be used if the envelope doesn't exist. 207 | * Raises an error if the the envelope can't be created. 208 | */ 209 | private createAutomationEnvelope() {} 210 | 211 | /** 212 | * Crops the clip. The region that is cropped depends on whether 213 | * the clip is looped or not. If looped, the region outside of 214 | * the loop is removed. If not looped, the region outside 215 | * the start and end markers is removed. 216 | */ 217 | crop(): Promise { 218 | return this.sendCommand("crop"); 219 | } 220 | 221 | /** 222 | * Deselects all notes present in the clip. 223 | */ 224 | deselectAllNotes(): Promise { 225 | return this.sendCommand("deselect_all_notes"); 226 | } 227 | 228 | /** 229 | * Makes the loop twice as long and duplicates notes and envelopes. 230 | * Duplicates the clip start/end range if the clip is not looped. 231 | */ 232 | duplicateLoop(): Promise { 233 | return this.sendCommand("duplicate_loop"); 234 | } 235 | 236 | /** 237 | * Duplicates the notes in the specified region to the destination_time. 238 | * Only notes of the specified pitch are duplicated if pitch is not -1. 239 | * If the transposition_amount is not 0, the notes in the region will be 240 | * transposed by the transposition_amount of semitones. 241 | * Raises an error on audio clips. 242 | */ 243 | duplicateRegion( 244 | start: number, 245 | length: number, 246 | destinationTime: number, 247 | pitch = -1, 248 | transpositionAmount = 0, 249 | ): Promise { 250 | return this.sendCommand("duplicate_region", [ 251 | start, 252 | length, 253 | destinationTime, 254 | pitch, 255 | transpositionAmount, 256 | ]); 257 | } 258 | 259 | /** 260 | * Starts playing this clip. 261 | */ 262 | fire(): Promise { 263 | return this.sendCommand("fire"); 264 | } 265 | 266 | /** 267 | * Returns all notes that match the given range. 268 | */ 269 | async getNotes( 270 | fromTime: number, 271 | fromPitch: number, 272 | timeSpan: number, 273 | pitchSpan: number, 274 | ): Promise { 275 | const notes: NoteTuple[] = await this.sendCommand("get_notes", { 276 | from_time: fromTime, 277 | from_pitch: fromPitch, 278 | time_span: timeSpan, 279 | pitch_span: pitchSpan, 280 | }); 281 | 282 | return notes.map(tupleToNote); 283 | } 284 | 285 | /** 286 | * Jump forward or backward by the specified relative amount in beats. 287 | * Will do nothing if the clip is not playing. 288 | */ 289 | movePlayingPos(amount: number): Promise { 290 | return this.sendCommand("move_playing_pos", [amount]); 291 | } 292 | 293 | /** 294 | * Quantizes all notes in a clip or aligns warp markers. 295 | */ 296 | quantize(grid: number, amount: number): Promise { 297 | return this.sendCommand("quantize", [grid, amount]); 298 | } 299 | 300 | /** 301 | * Quantizes all the notes of a given pitch. 302 | */ 303 | quantizePitch(pitch: number, grid: number, amount: number): Promise { 304 | return this.sendCommand("quantize_pitch", [pitch, grid, amount]); 305 | } 306 | 307 | /** 308 | * Deletes all notes that start in the given area. 309 | * 310 | * @deprecated starting with Live 11, use `removeNotesExtended` instead 311 | */ 312 | removeNotes( 313 | fromTime: number, 314 | fromPitch: number, 315 | timeSpan: number, 316 | pitchSpan: number, 317 | ) { 318 | return this.sendCommand("remove_notes", [ 319 | fromTime, 320 | fromPitch, 321 | timeSpan, 322 | pitchSpan, 323 | ]); 324 | } 325 | 326 | /** 327 | * Deletes all notes that start in the given area. 328 | */ 329 | removeNotesExtended( 330 | fromTime: number, 331 | fromPitch: number, 332 | timeSpan: number, 333 | pitchSpan: number, 334 | ) { 335 | return this.sendCommand("remove_notes_extended", [ 336 | fromTime, 337 | fromPitch, 338 | timeSpan, 339 | pitchSpan, 340 | ]); 341 | } 342 | 343 | /** 344 | * Replaces selected notes with an array of new notes. 345 | */ 346 | replaceSelectedNotes(notes: Note[]) { 347 | return this.sendCommand("replace_selected_notes", { 348 | notes: notes.map(noteToTuple), 349 | }); 350 | } 351 | 352 | /** 353 | * Available for audio clips only. 354 | * Converts the given sample time to beat time. 355 | * Raises an error if the sample is not warped. 356 | */ 357 | sampleToBeatTime(sampleTime: number): Promise { 358 | return this.sendCommand("sample_to_beat_time", [sampleTime]); 359 | } 360 | 361 | /** 362 | * Scrubs inside a clip. 363 | * `position` defines the position in beats that the scrub will start from. 364 | * The scrub will continue until `stop_scrub` is called. 365 | * Global quantization applies to the scrub's position and length. 366 | */ 367 | scrub(position: number): Promise { 368 | return this.sendCommand("scrub", [position]); 369 | } 370 | 371 | /** 372 | * Available for audio clips only. 373 | * Converts the given seconds to sample time. 374 | * Raises an error if the sample is warped. 375 | */ 376 | secondsToSampleTime(seconds: number): Promise { 377 | return this.sendCommand("seconds_to_sample_time", [seconds]); 378 | } 379 | 380 | /** 381 | * Selects all notes present in the clip. 382 | */ 383 | selectAllNotes(): Promise { 384 | return this.sendCommand("select_all_notes"); 385 | } 386 | 387 | /** 388 | * Set the clip's fire button state directly. 389 | * Supports all launch modes. 390 | */ 391 | setFireButtonState(state: boolean): Promise { 392 | return this.sendCommand("set_fire_button_state", [state]); 393 | } 394 | 395 | /** 396 | * Adds the given notes to the clip. 397 | */ 398 | setNotes(notes: Note[]): Promise { 399 | return this.sendCommand("set_notes", { notes: notes.map(noteToTuple) }); 400 | } 401 | 402 | /** 403 | * Stop playig this clip. 404 | */ 405 | stop(): Promise { 406 | return this.sendCommand("stop"); 407 | } 408 | 409 | /** 410 | * Stops the current scrub. 411 | */ 412 | stopScrub(): Promise { 413 | return this.sendCommand("stop_scrub"); 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/ns/cue-point.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export interface GettableProperties { 5 | name: string; 6 | time: number; 7 | } 8 | 9 | export interface TransformedProperties {} 10 | 11 | export interface SettableProperties {} 12 | 13 | export interface ObservableProperties { 14 | name: string; 15 | time: number; 16 | } 17 | 18 | export interface RawCuePoint { 19 | readonly id: string; 20 | readonly name: string; 21 | readonly time: number; 22 | } 23 | 24 | export class CuePoint extends Namespace< 25 | GettableProperties, 26 | TransformedProperties, 27 | SettableProperties, 28 | ObservableProperties 29 | > { 30 | constructor( 31 | ableton: Ableton, 32 | public raw: RawCuePoint, 33 | ) { 34 | super(ableton, "cue-point", raw.id); 35 | } 36 | 37 | async jump() { 38 | return this.sendCommand("jump"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ns/device-parameter.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export interface GettableProperties { 5 | automation_state: AutomationState; 6 | default_value: string; 7 | is_enabled: boolean; 8 | is_quantized: boolean; 9 | max: number; 10 | min: number; 11 | name: string; 12 | original_name: string; 13 | state: ParameterState; 14 | value: number; 15 | value_items: string[]; 16 | } 17 | 18 | export interface TransformedProperties {} 19 | 20 | export interface SettableProperties { 21 | value: number; 22 | } 23 | 24 | export interface ObservableProperties { 25 | automation_state: AutomationState; 26 | name: string; 27 | state: ParameterState; 28 | value: number; 29 | } 30 | 31 | export interface RawDeviceParameter { 32 | readonly id: string; 33 | readonly name: string; 34 | readonly value: number; 35 | readonly is_quantized: boolean; 36 | } 37 | 38 | export enum AutomationState { 39 | None = 0, 40 | Playing = 1, 41 | Overridden = 2, 42 | } 43 | 44 | export enum ParameterState { 45 | Enabled = 0, 46 | Disabled = 1, 47 | Irrelevant = 2, 48 | } 49 | 50 | export class DeviceParameter extends Namespace< 51 | GettableProperties, 52 | TransformedProperties, 53 | SettableProperties, 54 | ObservableProperties 55 | > { 56 | constructor( 57 | ableton: Ableton, 58 | public raw: RawDeviceParameter, 59 | ) { 60 | super(ableton, "device-parameter", raw.id); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ns/device.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { RawDeviceParameter, DeviceParameter } from "./device-parameter"; 4 | 5 | export interface GettableProperties { 6 | can_have_chains: boolean; 7 | can_have_drum_pads: boolean; 8 | class_display_name: string; 9 | class_name: string; 10 | is_active: boolean; 11 | name: string; 12 | parameters: RawDeviceParameter[]; 13 | type: DeviceType; 14 | } 15 | 16 | export interface TransformedProperties { 17 | parameters: DeviceParameter[]; 18 | } 19 | 20 | export interface SettableProperties { 21 | name: string; 22 | } 23 | 24 | export interface ObservableProperties { 25 | is_active: boolean; 26 | name: string; 27 | parameters: string; 28 | } 29 | 30 | export interface RawDevice { 31 | readonly id: string; 32 | readonly name: string; 33 | readonly type: DeviceType; 34 | readonly class_name: string; 35 | } 36 | 37 | export enum DeviceType { 38 | AudioEffect = "audio_effect", 39 | Instrument = "instrument", 40 | MidiEffect = "midi_effect", 41 | Undefined = "undefined", 42 | } 43 | 44 | export class Device extends Namespace< 45 | GettableProperties, 46 | TransformedProperties, 47 | SettableProperties, 48 | ObservableProperties 49 | > { 50 | constructor( 51 | ableton: Ableton, 52 | public raw: RawDevice, 53 | ) { 54 | super(ableton, "device", raw.id); 55 | 56 | this.transformers = { 57 | parameters: (ps) => ps.map((p) => new DeviceParameter(ableton, p)), 58 | }; 59 | 60 | this.cachedProps = { 61 | parameters: true, 62 | }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ns/index.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | 3 | export class Namespace { 4 | protected transformers: { 5 | [T in keyof TP]: (val: T extends keyof GP ? GP[T] : unknown) => TP[T]; 6 | } = {} as any; 7 | 8 | protected cachedProps: Partial<{ 9 | [T in keyof GP]: boolean; 10 | }> = {}; 11 | 12 | constructor( 13 | protected ableton: Ableton, 14 | protected ns: string, 15 | protected nsid?: string, 16 | ) {} 17 | 18 | async get( 19 | prop: T, 20 | useCache?: boolean, 21 | ): Promise { 22 | const cache = useCache ?? !!this.cachedProps[prop]; 23 | const res = await this.ableton.getProp( 24 | this.ns, 25 | this.nsid, 26 | String(prop), 27 | cache, 28 | ); 29 | 30 | const transformer = 31 | this.transformers[prop as any as Extract]; 32 | 33 | if (res !== null && transformer) { 34 | return transformer(res) as any; 35 | } else { 36 | return res; 37 | } 38 | } 39 | 40 | async set(prop: T, value: SP[T]): Promise { 41 | return this.ableton.setProp(this.ns, this.nsid, String(prop), value); 42 | } 43 | 44 | async addListener( 45 | prop: T, 46 | listener: (data: T extends keyof TP ? TP[T] : OP[T]) => any, 47 | ) { 48 | const transformer = 49 | this.transformers[prop as any as Extract]; 50 | return this.ableton.addPropListener( 51 | this.ns, 52 | this.nsid, 53 | String(prop), 54 | (data) => { 55 | if (data !== null && transformer) { 56 | listener(transformer(data) as any); 57 | } else { 58 | listener(data); 59 | } 60 | }, 61 | ); 62 | } 63 | 64 | /** 65 | * Sends a raw function invocation to Ableton. 66 | * This should be used with caution. 67 | */ 68 | async sendCommand(name: string, args?: { [k: string]: any }, etag?: string) { 69 | return this.ableton.sendCommand({ 70 | ns: this.ns, 71 | nsid: this.nsid, 72 | name, 73 | args, 74 | etag, 75 | }); 76 | } 77 | 78 | /** 79 | * Sends a raw function invocation to Ableton and expects the 80 | * result to be a CacheResponse with `data` and an `etag`. 81 | */ 82 | protected async sendCachedCommand(name: string, args?: { [k: string]: any }) { 83 | return this.ableton.sendCachedCommand({ 84 | ns: this.ns, 85 | nsid: this.nsid, 86 | name, 87 | args, 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/ns/internal.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { getPackageVersion } from "../util/package-version"; 4 | import semver from "semver"; 5 | 6 | export interface GettableProperties { 7 | version: string; 8 | ping: boolean; 9 | } 10 | 11 | export interface TransformedProperties {} 12 | 13 | export interface SettableProperties {} 14 | 15 | export interface ObservableProperties {} 16 | 17 | export class Internal extends Namespace< 18 | GettableProperties, 19 | TransformedProperties, 20 | SettableProperties, 21 | ObservableProperties 22 | > { 23 | constructor(ableton: Ableton) { 24 | super(ableton, "internal"); 25 | } 26 | 27 | async isPluginUpToDate() { 28 | const pluginVersion = await this.get("version"); 29 | return !semver.lt(pluginVersion, getPackageVersion()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ns/midi.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export enum MidiCommand { 5 | NoteOn = 128, 6 | NoteOff = 144, 7 | AfterTouch = 160, 8 | ControlChange = 176, 9 | PatchChange = 192, 10 | ChannelPressure = 208, 11 | PitchBend = 224, 12 | SysExStart = 240, 13 | MidiTimeCodeQuarterFrame = 241, 14 | SongPositionPointer = 242, 15 | SongSelect = 243, 16 | TuneRequest = 246, 17 | SysExEnd = 247, 18 | TimingClock = 248, 19 | Start = 250, 20 | Continue = 251, 21 | Stop = 252, 22 | ActiveSensing = 254, 23 | SystemReset = 255, 24 | } 25 | 26 | export interface MidiMapping { 27 | type: "cc" | "note"; 28 | channel: number; 29 | target: number; 30 | } 31 | 32 | export interface MidiNote { 33 | command: MidiCommand.NoteOn | MidiCommand.NoteOff; 34 | key: number; 35 | velocity: number; 36 | } 37 | 38 | export interface MidiCC { 39 | command: MidiCommand.ControlChange; 40 | controller: number; 41 | value: number; 42 | } 43 | 44 | export class MidiMessage { 45 | command: MidiCommand; 46 | parameter1: number | null = null; 47 | parameter2: number | null = null; 48 | 49 | constructor(raw: RawMidiMessage) { 50 | switch (raw.bytes.length) { 51 | case 0: 52 | throw "bytes missing from midi message"; 53 | case 3: 54 | this.parameter1 = raw.bytes[1]; 55 | this.parameter2 = raw.bytes[2]; 56 | break; 57 | case 2: 58 | this.parameter1 = raw.bytes[1]; 59 | break; 60 | case 1: 61 | break; 62 | default: 63 | throw "invalid midi message length: " + raw.bytes.length; 64 | } 65 | if (!(raw.bytes[0] in MidiCommand)) { 66 | throw "invalid midi command: " + raw.bytes[0]; 67 | } 68 | this.command = raw.bytes[0]; 69 | } 70 | 71 | toCC(): MidiCC { 72 | if (this.command !== MidiCommand.ControlChange) { 73 | throw "not a midi CC message"; 74 | } 75 | return { 76 | command: this.command, 77 | controller: this.parameter1 as number, 78 | value: this.parameter2 as number, 79 | }; 80 | } 81 | 82 | toNote(): MidiNote { 83 | if ( 84 | this.command !== MidiCommand.NoteOn && 85 | this.command !== MidiCommand.NoteOff 86 | ) { 87 | throw "not a midi note message"; 88 | } 89 | return { 90 | command: this.command, 91 | key: this.parameter1 as number, 92 | velocity: this.parameter2 as number, 93 | }; 94 | } 95 | } 96 | 97 | export interface RawMidiMessage { 98 | bytes: number[]; 99 | } 100 | 101 | export interface GettableProperties {} 102 | 103 | export interface TransformedProperties { 104 | midi: MidiMessage; 105 | } 106 | 107 | export interface SettableProperties { 108 | midi_outputs: MidiMapping[]; 109 | } 110 | 111 | export interface ObservableProperties { 112 | midi: RawMidiMessage; 113 | } 114 | 115 | export class Midi extends Namespace< 116 | GettableProperties, 117 | TransformedProperties, 118 | SettableProperties, 119 | ObservableProperties 120 | > { 121 | constructor(ableton: Ableton) { 122 | super(ableton, "midi"); 123 | 124 | this.transformers = { 125 | midi: (msg) => new MidiMessage(msg as RawMidiMessage), 126 | }; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ns/mixer-device.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { GettableProperties } from "./mixer-device"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | //"crossfade_assign", (not applicable to the master track) 7 | "crossfader", 8 | "cue_volume", 9 | "left_split_stereo", 10 | "panning", 11 | "panning_mode", 12 | "right_split_stereo", 13 | "sends", 14 | "song_tempo", 15 | "track_activator", 16 | "volume", 17 | ]; 18 | 19 | describe("Mixer Device", () => { 20 | it("should be able to read all properties without erroring", async () => { 21 | await withAbleton(async (ab) => { 22 | const masterTrack = await ab.song.get("master_track"); 23 | const mixerDevice = await masterTrack.get("mixer_device"); 24 | await Promise.all(gettableProps.map((p) => mixerDevice.get(p))); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/ns/mixer-device.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { DeviceParameter, RawDeviceParameter } from "./device-parameter"; 4 | 5 | export enum PanningMode { 6 | Stereo, 7 | StereoSplit, 8 | } 9 | 10 | export enum CrossfadeAssignment { 11 | A, 12 | None, 13 | B, 14 | } 15 | 16 | export interface GettableProperties { 17 | crossfade_assign: CrossfadeAssignment; 18 | crossfader: RawDeviceParameter; 19 | cue_volume: RawDeviceParameter; 20 | left_split_stereo: RawDeviceParameter; 21 | panning: RawDeviceParameter; 22 | panning_mode: PanningMode; 23 | right_split_stereo: RawDeviceParameter; 24 | sends: RawDeviceParameter[]; 25 | song_tempo: RawDeviceParameter; 26 | track_activator: RawDeviceParameter; 27 | volume: RawDeviceParameter; 28 | } 29 | 30 | export interface TransformedProperties { 31 | crossfader: DeviceParameter; 32 | cue_volume: DeviceParameter; 33 | left_split_stereo: DeviceParameter; 34 | panning: DeviceParameter; 35 | right_split_stereo: DeviceParameter; 36 | sends: DeviceParameter[]; 37 | song_tempo: DeviceParameter; 38 | track_activator: DeviceParameter; 39 | volume: DeviceParameter; 40 | } 41 | 42 | export interface SettableProperties { 43 | crossfade_assign: CrossfadeAssignment; 44 | panning_mode: string; 45 | } 46 | 47 | export interface ObservableProperties { 48 | crossfade_assign: CrossfadeAssignment; 49 | panning_mode: string; 50 | sends: RawDeviceParameter[]; 51 | } 52 | 53 | export interface RawMixerDevice { 54 | id: string; 55 | volume: string; 56 | } 57 | 58 | export class MixerDevice extends Namespace< 59 | GettableProperties, 60 | TransformedProperties, 61 | SettableProperties, 62 | ObservableProperties 63 | > { 64 | constructor( 65 | ableton: Ableton, 66 | public raw: RawMixerDevice, 67 | ) { 68 | super(ableton, "mixer-device", raw.id); 69 | 70 | this.transformers = { 71 | crossfader: (v) => new DeviceParameter(ableton, v), 72 | cue_volume: (v) => new DeviceParameter(ableton, v), 73 | left_split_stereo: (v) => new DeviceParameter(ableton, v), 74 | panning: (v) => new DeviceParameter(ableton, v), 75 | right_split_stereo: (v) => new DeviceParameter(ableton, v), 76 | sends: (v) => v.map((s) => new DeviceParameter(ableton, s)), 77 | song_tempo: (v) => new DeviceParameter(ableton, v), 78 | track_activator: (v) => new DeviceParameter(ableton, v), 79 | volume: (v) => new DeviceParameter(ableton, v), 80 | }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ns/scene.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { ClipSlot, RawClipSlot } from "./clip-slot"; 4 | import { Color } from "../util/color"; 5 | 6 | export interface GettableProperties { 7 | clip_slots: RawClipSlot[]; 8 | color: number; 9 | color_index: number; 10 | is_empty: boolean; 11 | is_triggered: boolean; 12 | name: string; 13 | tempo: number; 14 | } 15 | 16 | export interface TransformedProperties { 17 | color: Color; 18 | clip_slots: ClipSlot[]; 19 | } 20 | 21 | export interface SettableProperties { 22 | clip_slots: RawClipSlot[]; 23 | color: number; 24 | color_index: number; 25 | name: string; 26 | tempo: number; 27 | } 28 | 29 | export interface ObservableProperties { 30 | clip_slots: RawClipSlot[]; 31 | color: number; 32 | color_index: number; 33 | is_triggered: boolean; 34 | name: string; 35 | } 36 | 37 | export interface RawScene { 38 | readonly color: number; 39 | readonly id: string; 40 | readonly name: string; 41 | } 42 | 43 | export class Scene extends Namespace< 44 | GettableProperties, 45 | TransformedProperties, 46 | SettableProperties, 47 | ObservableProperties 48 | > { 49 | constructor( 50 | ableton: Ableton, 51 | public raw: RawScene, 52 | ) { 53 | super(ableton, "scene", raw.id); 54 | 55 | this.transformers = { 56 | color: (c) => new Color(c), 57 | clip_slots: (clip_slots) => 58 | clip_slots.map((c) => new ClipSlot(this.ableton, c)), 59 | }; 60 | 61 | this.cachedProps = { 62 | clip_slots: true, 63 | }; 64 | } 65 | 66 | /** 67 | * Fire the scene directly. Will fire all clip slots 68 | * that this scene owns and select the scene itself. 69 | */ 70 | async fire() { 71 | return this.sendCommand("fire"); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ns/session.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | 4 | describe("Session", () => { 5 | it("should work and silently fail when no session is created.", async () => { 6 | await withAbleton(async (ab) => { 7 | await ab.session.setSessionOffset(0, 1); 8 | }); 9 | }); 10 | 11 | it("2x2 session ring is created and moved", async () => { 12 | await withAbleton(async (ab) => { 13 | await ab.session.setupSessionBox(2, 2); 14 | await ab.session.setSessionOffset(0, 1); 15 | }); 16 | }); 17 | 18 | it("4x4 session ring is created and moved", async () => { 19 | await withAbleton(async (ab) => { 20 | await ab.session.setupSessionBox(2, 2); 21 | await ab.session.setupSessionBox(4, 2); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/ns/session.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | 4 | export interface GettableProperties {} 5 | 6 | export interface TransformedProperties {} 7 | 8 | export interface SettableProperties {} 9 | 10 | export interface ObservableProperties {} 11 | 12 | export class Session extends Namespace< 13 | GettableProperties, 14 | TransformedProperties, 15 | SettableProperties, 16 | ObservableProperties 17 | > { 18 | constructor(ableton: Ableton) { 19 | super(ableton, "session", undefined); 20 | } 21 | 22 | public async setupSessionBox(num_tracks: number, num_scenes: number) { 23 | return this.sendCommand("setup_session_box", { num_tracks, num_scenes }); 24 | } 25 | 26 | public async setSessionOffset(track_offset: number, scene_offset: number) { 27 | return this.sendCommand("set_session_offset", { 28 | track_offset, 29 | scene_offset, 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ns/song-view.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { GettableProperties } from "./song-view"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | "detail_clip", 7 | "draw_mode", 8 | "follow_song", 9 | "highlighted_clip_slot", 10 | "selected_chain", 11 | "selected_parameter", 12 | "selected_scene", 13 | "selected_track", 14 | ]; 15 | 16 | describe("Song View", () => { 17 | it("should be able to read all properties without erroring", async () => { 18 | await withAbleton(async (ab) => { 19 | await Promise.all(gettableProps.map((p) => ab.song.view.get(p))); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/ns/song-view.ts: -------------------------------------------------------------------------------- 1 | import { Namespace } from "."; 2 | import { Ableton } from ".."; 3 | import { Clip, RawClip } from "./clip"; 4 | import { ClipSlot, RawClipSlot } from "./clip-slot"; 5 | import { Device } from "./device"; 6 | import { DeviceParameter, RawDeviceParameter } from "./device-parameter"; 7 | import { RawScene, Scene } from "./scene"; 8 | import { RawTrack, Track } from "./track"; 9 | 10 | export interface GettableProperties { 11 | detail_clip: RawClip; 12 | draw_mode: boolean; 13 | follow_song: boolean; 14 | highlighted_clip_slot: RawClipSlot; 15 | selected_chain: any /* Todo: Implement Chain class */; 16 | selected_parameter: RawDeviceParameter; 17 | selected_scene: RawScene; 18 | selected_track: RawTrack; 19 | } 20 | 21 | export interface TransformedProperties { 22 | detail_clip: Clip; 23 | selected_parameter: DeviceParameter; 24 | selected_scene: Scene; 25 | selected_track: Track; 26 | highlighted_clip_slot: ClipSlot; 27 | } 28 | 29 | export interface SettableProperties { 30 | detail_clip: RawClip["id"]; 31 | draw_mode: boolean; 32 | follow_song: boolean; 33 | highlighted_clip_slot: number; 34 | selected_scene: RawScene["id"]; 35 | selected_track: RawTrack["id"]; 36 | } 37 | 38 | export interface ObservableProperties { 39 | detail_clip: RawClip | null; 40 | draw_mode: any; 41 | follow_song: any; 42 | highlighted_clip_slot: any; 43 | selected_chain: any; 44 | selected_parameter: any; 45 | selected_scene: RawScene | null; 46 | selected_track: RawTrack | null; 47 | } 48 | 49 | export class SongView extends Namespace< 50 | GettableProperties, 51 | TransformedProperties, 52 | SettableProperties, 53 | ObservableProperties 54 | > { 55 | constructor(ableton: Ableton) { 56 | super(ableton, "song-view"); 57 | 58 | this.transformers = { 59 | selected_parameter: (param) => new DeviceParameter(ableton, param), 60 | selected_track: (track) => new Track(ableton, track), 61 | selected_scene: (scene) => new Scene(ableton, scene), 62 | highlighted_clip_slot: (slot) => new ClipSlot(ableton, slot), 63 | detail_clip: (clip) => new Clip(ableton, clip), 64 | }; 65 | 66 | this.cachedProps = { 67 | detail_clip: true, 68 | selected_parameter: true, 69 | selected_track: true, 70 | selected_scene: true, 71 | highlighted_clip_slot: true, 72 | }; 73 | } 74 | 75 | async selectDevice(device: Device) { 76 | return this.ableton.sendCommand({ 77 | ns: this.ns, 78 | name: "select_device", 79 | args: { device_id: device.raw.id }, 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ns/song.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { 4 | GettableProperties, 5 | Quantization, 6 | RecordingQuantization, 7 | } from "./song"; 8 | 9 | const gettableProps: (keyof GettableProperties)[] = [ 10 | "arrangement_overdub", 11 | "back_to_arranger", 12 | "can_capture_midi", 13 | "can_jump_to_next_cue", 14 | "can_jump_to_prev_cue", 15 | "can_redo", 16 | "can_undo", 17 | "clip_trigger_quantization", 18 | "count_in_duration", 19 | "cue_points", 20 | "current_song_time", 21 | "exclusive_arm", 22 | "exclusive_solo", 23 | "groove_amount", 24 | "is_counting_in", 25 | "is_playing", 26 | "last_event_time", 27 | "loop", 28 | "loop_length", 29 | "loop_start", 30 | "master_track", 31 | "metronome", 32 | "midi_recording_quantization", 33 | "nudge_down", 34 | "nudge_up", 35 | "overdub", 36 | "punch_in", 37 | "punch_out", 38 | "re_enable_automation_enabled", 39 | "record_mode", 40 | "return_tracks", 41 | "root_note", 42 | "scale_name", 43 | "scenes", 44 | "select_on_launch", 45 | "session_automation_record", 46 | "session_record", 47 | "session_record_status", 48 | "signature_denominator", 49 | "signature_numerator", 50 | "song_length", 51 | "swing_amount", 52 | "tempo", 53 | "tempo_follower_enabled", 54 | "tracks", 55 | "visible_tracks", 56 | ]; 57 | 58 | describe("Song", () => { 59 | it("should be able to read all properties without erroring", async () => { 60 | await withAbleton(async (ab) => { 61 | await Promise.all(gettableProps.map((p) => ab.song.get(p))); 62 | }); 63 | }); 64 | 65 | it("should return the proper types for properties", async () => { 66 | await withAbleton(async (ab) => { 67 | const songTime = await ab.song.get("current_song_time"); 68 | expect(songTime).toBeTypeOf("number"); 69 | 70 | const clipTriggerQuantization = await ab.song.get( 71 | "clip_trigger_quantization", 72 | ); 73 | expect(clipTriggerQuantization).toBeTypeOf("string"); 74 | 75 | const isPlaying = await ab.song.get("is_playing"); 76 | expect(isPlaying).toBeTypeOf("boolean"); 77 | }); 78 | }); 79 | 80 | it("should be able to change the playback quantization", async () => { 81 | await withAbleton(async (ab) => { 82 | const currentQuantization = await ab.song.get( 83 | "clip_trigger_quantization", 84 | ); 85 | for (const quantization of Object.keys(Quantization)) { 86 | await ab.song.set( 87 | "clip_trigger_quantization", 88 | quantization as Quantization, 89 | ); 90 | } 91 | await ab.song.set("clip_trigger_quantization", currentQuantization); 92 | }); 93 | }); 94 | 95 | it("should be able to change the recording quantization", async () => { 96 | await withAbleton(async (ab) => { 97 | const currentQuantization = await ab.song.get( 98 | "midi_recording_quantization", 99 | ); 100 | for (const quantization of Object.keys(RecordingQuantization)) { 101 | await ab.song.set( 102 | "midi_recording_quantization", 103 | quantization as RecordingQuantization, 104 | ); 105 | } 106 | await ab.song.set("midi_recording_quantization", currentQuantization); 107 | }); 108 | }); 109 | 110 | it("should be able to write and read large objects from the project", async () => { 111 | await withAbleton(async (ab) => { 112 | const largeArray: number[] = []; 113 | 114 | for (let i = 0; i < 100000; i++) { 115 | largeArray.push(i); 116 | } 117 | 118 | await ab.song.setData("abletonjs_test", largeArray); 119 | const received = await ab.song.getData("abletonjs_test"); 120 | expect(received).toEqual(largeArray); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/ns/song.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { Track, RawTrack } from "./track"; 4 | import { CuePoint, RawCuePoint } from "./cue-point"; 5 | import { SongView } from "./song-view"; 6 | import { Scene, RawScene } from "./scene"; 7 | import { RawDevice } from "./device"; 8 | 9 | export interface GettableProperties { 10 | appointed_device: RawDevice; 11 | arrangement_overdub: boolean; 12 | back_to_arranger: number; 13 | can_capture_midi: boolean; 14 | can_jump_to_next_cue: boolean; 15 | can_jump_to_prev_cue: boolean; 16 | can_redo: boolean; 17 | can_undo: boolean; 18 | clip_trigger_quantization: Quantization; 19 | count_in_duration: number; 20 | cue_points: RawCuePoint[]; 21 | current_song_time: number; 22 | exclusive_arm: boolean; 23 | exclusive_solo: boolean; 24 | groove_amount: number; 25 | is_counting_in: boolean; 26 | is_playing: boolean; 27 | last_event_time: number; 28 | loop: boolean; 29 | loop_length: number; 30 | loop_start: number; 31 | master_track: RawTrack; 32 | metronome: number; 33 | midi_recording_quantization: RecordingQuantization; 34 | nudge_down: boolean; 35 | nudge_up: boolean; 36 | overdub: boolean; 37 | punch_in: boolean; 38 | punch_out: boolean; 39 | re_enable_automation_enabled: number; 40 | record_mode: number; 41 | return_tracks: RawTrack[]; 42 | root_note: number; 43 | scale_name: number; 44 | scenes: RawScene[]; 45 | select_on_launch: number; 46 | session_automation_record: number; 47 | session_record: number; 48 | session_record_status: number; 49 | signature_denominator: number; 50 | signature_numerator: number; 51 | song_length: number; 52 | swing_amount: number; 53 | tempo: number; 54 | tempo_follower_enabled: boolean; 55 | tracks: RawTrack[]; 56 | // view: never; - Not needed here 57 | visible_tracks: RawTrack[]; 58 | } 59 | 60 | export interface TransformedProperties { 61 | cue_points: CuePoint[]; 62 | master_track: Track; 63 | return_tracks: Track[]; 64 | tracks: Track[]; 65 | visible_tracks: Track[]; 66 | //view: SongView; - Not needed here 67 | scenes: Scene[]; 68 | } 69 | 70 | export interface SettableProperties { 71 | appointed_device: string; 72 | arrangement_overdub: boolean; 73 | back_to_arranger: number; 74 | clip_trigger_quantization: Quantization; 75 | count_in_duration: number; 76 | current_song_time: number; 77 | exclusive_arm: number; 78 | exclusive_solo: number; 79 | groove_amount: number; 80 | is_counting_in: boolean; 81 | is_playing: boolean; 82 | last_event_time: number; 83 | loop: boolean; 84 | loop_length: number; 85 | loop_start: number; 86 | master_track: number; 87 | metronome: number; 88 | midi_recording_quantization: RecordingQuantization; 89 | nudge_down: boolean; 90 | nudge_up: boolean; 91 | overdub: boolean; 92 | punch_in: boolean; 93 | punch_out: boolean; 94 | re_enable_automation_enabled: number; 95 | record_mode: number; 96 | return_tracks: number; 97 | root_note: number; 98 | scale_name: number; 99 | select_on_launch: number; 100 | session_automation_record: number; 101 | session_record: number; 102 | session_record_status: number; 103 | signature_denominator: number; 104 | signature_numerator: number; 105 | song_length: number; 106 | swing_amount: number; 107 | tempo: number; 108 | tempo_follower_enabled: boolean; 109 | visible_tracks: number; 110 | } 111 | 112 | export interface ObservableProperties { 113 | appointed_device: RawDevice; 114 | arrangement_overdub: boolean; 115 | back_to_arranger: number; 116 | can_capture_midi: boolean; 117 | can_jump_to_next_cue: boolean; 118 | can_jump_to_prev_cue: boolean; 119 | clip_trigger_quantization: Quantization; 120 | count_in_duration: number; 121 | cue_points: number; 122 | current_song_time: number; 123 | data: number; 124 | exclusive_arm: number; 125 | groove_amount: number; 126 | is_counting_in: boolean; 127 | is_playing: boolean; 128 | loop_length: number; 129 | loop: boolean; 130 | loop_start: number; 131 | metronome: number; 132 | midi_recording_quantization: RecordingQuantization; 133 | nudge_down: boolean; 134 | nudge_up: boolean; 135 | overdub: boolean; 136 | punch_in: boolean; 137 | punch_out: boolean; 138 | re_enable_automation_enabled: number; 139 | record_mode: number; 140 | return_tracks: RawTrack[]; 141 | scenes: RawScene[]; 142 | session_automation_record: number; 143 | session_record: number; 144 | session_record_status: number; 145 | signature_denominator: number; 146 | signature_numerator: number; 147 | song_length: number; 148 | swing_amount: number; 149 | tempo: number; 150 | tempo_follower_enabled: boolean; 151 | tracks: RawTrack[]; 152 | } 153 | 154 | export interface SmpteTime { 155 | hours: number; 156 | minutes: number; 157 | seconds: number; 158 | frames: number; 159 | } 160 | 161 | export enum TimeFormat { 162 | MsTime = 0, 163 | Smpte24 = 1, 164 | Smpte25 = 2, 165 | Smpte29 = 3, 166 | Smpte30 = 4, 167 | Smpte30Drop = 5, 168 | } 169 | 170 | export enum Quantization { 171 | q_8_bars = "q_8_bars", 172 | q_4_bars = "q_4_bars", 173 | q_2_bars = "q_2_bars", 174 | q_bar = "q_bar", 175 | q_half = "q_half", 176 | q_half_triplet = "q_half_triplet", 177 | q_quarter = "q_quarter", 178 | q_quarter_triplet = "q_quarter_triplet", 179 | q_eight = "q_eight", 180 | q_eight_triplet = "q_eight_triplet", 181 | q_sixtenth = "q_sixtenth", 182 | q_sixtenth_triplet = "q_sixtenth_triplet", 183 | q_thirtytwoth = "q_thirtytwoth", 184 | q_no_q = "q_no_q", 185 | } 186 | 187 | export enum RecordingQuantization { 188 | rec_q_eight = "rec_q_eight", 189 | rec_q_eight_eight_triplet = "rec_q_eight_eight_triplet", 190 | rec_q_eight_triplet = "rec_q_eight_triplet", 191 | rec_q_no_q = "rec_q_no_q", 192 | rec_q_quarter = "rec_q_quarter", 193 | rec_q_sixtenth = "rec_q_sixtenth", 194 | rec_q_sixtenth_sixtenth_triplet = "rec_q_sixtenth_sixtenth_triplet", 195 | rec_q_sixtenth_triplet = "rec_q_sixtenth_triplet", 196 | rec_q_thirtysecond = "rec_q_thirtysecond", 197 | } 198 | 199 | export class Song extends Namespace< 200 | GettableProperties, 201 | TransformedProperties, 202 | SettableProperties, 203 | ObservableProperties 204 | > { 205 | constructor(ableton: Ableton) { 206 | super(ableton, "song"); 207 | 208 | this.transformers = { 209 | cue_points: (points) => points.map((c) => new CuePoint(ableton, c)), 210 | master_track: (track) => new Track(ableton, track), 211 | return_tracks: (tracks) => tracks.map((t) => new Track(ableton, t)), 212 | tracks: (tracks) => tracks.map((t) => new Track(ableton, t)), 213 | visible_tracks: (tracks) => tracks.map((t) => new Track(ableton, t)), 214 | scenes: (scenes) => scenes.map((s) => new Scene(ableton, s)), 215 | }; 216 | 217 | this.cachedProps = { 218 | cue_points: true, 219 | master_track: true, 220 | return_tracks: true, 221 | tracks: true, 222 | visible_tracks: true, 223 | scenes: true, 224 | }; 225 | } 226 | 227 | public view = new SongView(this.ableton); 228 | public async beginUndoStep() { 229 | return this.sendCommand("begin_undo_step"); 230 | } 231 | 232 | public async continuePlaying() { 233 | return this.sendCommand("continue_playing"); 234 | } 235 | 236 | public async createAudioTrack(index = -1) { 237 | const result = await this.sendCommand("create_audio_track", { index }); 238 | return new Track(this.ableton, result); 239 | } 240 | 241 | public async createMidiTrack(index = -1) { 242 | const result = await this.sendCommand("create_midi_track", { index }); 243 | return new Track(this.ableton, result); 244 | } 245 | 246 | public async createReturnTrack() { 247 | const result = await this.sendCommand("create_return_track"); 248 | return new Track(this.ableton, result); 249 | } 250 | 251 | public async createScene(index = -1) { 252 | const result = await this.sendCommand("create_scene", { index }); 253 | return new Scene(this.ableton, result); 254 | } 255 | 256 | public async deleteReturnTrack(index: number) { 257 | return this.sendCommand("delete_return_track", [index]); 258 | } 259 | 260 | public async deleteScene(index: number) { 261 | return this.sendCommand("delete_scene", [index]); 262 | } 263 | 264 | public async deleteTrack(index: number) { 265 | return this.sendCommand("delete_track", [index]); 266 | } 267 | 268 | public async duplicateScene(index: number) { 269 | return this.sendCommand("duplicate_scene", [index]); 270 | } 271 | 272 | public async duplicateTrack(index: number) { 273 | return this.sendCommand("duplicate_track", [index]); 274 | } 275 | 276 | public async endUndoStep() { 277 | return this.sendCommand("end_undo_step"); 278 | } 279 | 280 | public async getData(key: string) { 281 | return this.sendCachedCommand("get_data", { key }); 282 | } 283 | 284 | public async getCurrentSmpteSongTime( 285 | timeFormat: TimeFormat, 286 | ): Promise { 287 | return this.sendCommand("get_current_smpte_song_time", { timeFormat }); 288 | } 289 | 290 | public async isCuePointSelected() { 291 | return this.sendCommand("is_cue_point_selected"); 292 | } 293 | 294 | public async jumpBy(amount: number) { 295 | return this.sendCommand("jump_by", [amount]); 296 | } 297 | 298 | public async jumpToNextCue() { 299 | return this.sendCommand("jump_to_next_cue"); 300 | } 301 | 302 | public async jumpToPrevCue() { 303 | return this.sendCommand("jump_to_prev_cue"); 304 | } 305 | 306 | public async playSelection() { 307 | return this.sendCommand("play_selection"); 308 | } 309 | 310 | public async redo() { 311 | return this.sendCommand("redo"); 312 | } 313 | 314 | public async scrubBy(amount: number) { 315 | return this.sendCommand("scrub_by", [amount]); 316 | } 317 | 318 | public async setData(key: string, value: any) { 319 | return this.sendCommand("set_data", [key, value]); 320 | } 321 | 322 | public async setOrDeleteCue() { 323 | return this.sendCommand("set_or_delete_cue"); 324 | } 325 | 326 | public async startPlaying() { 327 | return this.sendCommand("start_playing"); 328 | } 329 | 330 | public async stopAllClips() { 331 | return this.sendCommand("stop_all_clips"); 332 | } 333 | 334 | public async stopPlaying() { 335 | return this.sendCommand("stop_playing"); 336 | } 337 | 338 | /** 339 | * Only starts playing when Live is currently not playing 340 | * to prevent Live from jumping back to the start when it's 341 | * already playing. 342 | * 343 | * @returns a boolean indicating whether the command was executed 344 | */ 345 | public async safeStartPlaying(): Promise { 346 | return this.sendCommand("safe_start_playing"); 347 | } 348 | 349 | /** 350 | * Only stops playback when Live is currently playing to prevent 351 | * Live jumping back to the beginning of the arrangement when it's 352 | * already stopped. 353 | * 354 | * @returns a boolean indicating whether the command was executed 355 | */ 356 | public async safeStopPlaying(): Promise { 357 | return this.sendCommand("safe_stop_playing"); 358 | } 359 | 360 | public async tapTempo() { 361 | return this.sendCommand("tap_tempo"); 362 | } 363 | 364 | public async undo() { 365 | return this.sendCommand("undo"); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/ns/track-view.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { withAbleton } from "../util/tests"; 3 | import { DeviceInsertMode, GettableProperties } from "./track-view"; 4 | 5 | const gettableProps: (keyof GettableProperties)[] = [ 6 | "is_collapsed", 7 | "selected_device", 8 | ]; 9 | 10 | describe("Track View", () => { 11 | it("should be able to read all properties without erroring", async () => { 12 | await withAbleton(async (ab) => { 13 | const tracks = await ab.song.get("tracks"); 14 | await Promise.all(gettableProps.map((p) => tracks[0].view.get(p))); 15 | }); 16 | }); 17 | 18 | it("should be able to set the device insert mode", async () => { 19 | await withAbleton(async (ab) => { 20 | const tracks = await ab.song.get("tracks"); 21 | await tracks[0].view.set("device_insert_mode", DeviceInsertMode.Left); 22 | }); 23 | }); 24 | 25 | it("should select the instrument device", async () => { 26 | await withAbleton(async (ab) => { 27 | const tracks = await ab.song.get("tracks"); 28 | await tracks[0].view.selectInstrument(); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/ns/track-view.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { Device, RawDevice } from "./device"; 4 | import { Track, RawTrack } from "./track"; 5 | import { Scene, RawScene } from "./scene"; 6 | import { RawDeviceParameter, DeviceParameter } from "./device-parameter"; 7 | import { ClipSlot, RawClipSlot } from "./clip-slot"; 8 | 9 | export enum DeviceInsertMode { 10 | Default = "default", 11 | Left = "left", 12 | Right = "right", 13 | } 14 | 15 | export interface GettableProperties { 16 | // device_insert_mode: DeviceInsertMode; – for some reason, Live returns a boolean here 17 | is_collapsed: boolean; 18 | selected_device: RawDevice; 19 | } 20 | 21 | export interface TransformedProperties { 22 | selected_device: Device; 23 | } 24 | 25 | export interface SettableProperties { 26 | device_insert_mode: DeviceInsertMode; 27 | is_collapsed: boolean; 28 | } 29 | 30 | export interface ObservableProperties { 31 | // device_insert_mode: DeviceInsertMode; 32 | is_collapsed: boolean; 33 | selected_device: RawDevice; 34 | } 35 | 36 | export class TrackView extends Namespace< 37 | GettableProperties, 38 | TransformedProperties, 39 | SettableProperties, 40 | ObservableProperties 41 | > { 42 | constructor(ableton: Ableton, nsid: string) { 43 | super(ableton, "track-view", nsid); 44 | 45 | this.transformers = { 46 | selected_device: (device) => new Device(ableton, device), 47 | }; 48 | 49 | this.cachedProps = { 50 | selected_device: true, 51 | }; 52 | } 53 | 54 | /** 55 | * Selects the track's instrument if it has one. 56 | */ 57 | async selectInstrument() { 58 | return this.sendCommand("select_instrument"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ns/track.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | import { Namespace } from "."; 3 | import { Device, RawDevice } from "./device"; 4 | import { ClipSlot, RawClipSlot } from "./clip-slot"; 5 | import { MixerDevice, RawMixerDevice } from "./mixer-device"; 6 | import { Clip, RawClip } from "./clip"; 7 | import { Color } from "../util/color"; 8 | import { TrackView } from "./track-view"; 9 | 10 | export enum RoutingLayout { 11 | Mono = 1, 12 | Stereo = 2, 13 | } 14 | 15 | export interface RoutingChannel { 16 | display_name: string; 17 | layout: RoutingLayout; 18 | } 19 | 20 | export enum RoutingCategory { 21 | External, 22 | Rewire, 23 | Resampling, 24 | Master, 25 | Track, 26 | ParentGroupTrack, 27 | None, 28 | Invalid, 29 | } 30 | 31 | export interface RoutingType { 32 | display_name: string; 33 | category: RoutingCategory; 34 | } 35 | 36 | // TODO: Implement commented-out properties properly 37 | export interface GettableProperties { 38 | arm: boolean; 39 | arrangement_clips: RawClip[]; 40 | available_input_routing_channels: RoutingChannel[]; 41 | available_input_routing_types: RoutingType[]; 42 | available_output_routing_channels: RoutingChannel[]; 43 | available_output_routing_types: RoutingType[]; 44 | can_be_armed: boolean; 45 | can_be_frozen: boolean; 46 | can_show_chains: boolean; 47 | canonical_parent: number; 48 | clip_slots: RawClipSlot[]; 49 | color: number; 50 | color_index: number; 51 | current_input_routing: string; 52 | current_input_sub_routing: string; 53 | current_monitoring_state: number; 54 | current_output_routing: string; 55 | current_output_sub_routing: string; 56 | devices: RawDevice[]; 57 | fired_slot_index: number; 58 | fold_state: boolean; 59 | group_track: RawTrack | null; 60 | has_audio_input: boolean; 61 | has_audio_output: boolean; 62 | has_midi_input: boolean; 63 | has_midi_output: boolean; 64 | implicit_arm: number; 65 | input_meter_left: number; 66 | input_meter_level: number; 67 | input_meter_right: number; 68 | // input_routing_channel: unknown; 69 | // input_routing_type: unknown; 70 | // input_routings: unknown; 71 | // input_sub_routings: unknown; 72 | is_foldable: boolean; 73 | is_frozen: boolean; 74 | is_grouped: boolean; 75 | is_part_of_selection: boolean; 76 | is_showing_chains: boolean; 77 | is_visible: boolean; 78 | mixer_device: RawMixerDevice; 79 | mute: boolean; 80 | muted_via_solo: boolean; 81 | name: string; 82 | output_meter_left: number; 83 | output_meter_level: number; 84 | output_meter_right: number; 85 | // output_routing_channel: unknown; 86 | // output_routing_type: unknown; 87 | // output_routings: unknown; 88 | // output_sub_routings: unknown; 89 | playing_slot_index: number; 90 | solo: number; 91 | //view: unknown; 92 | } 93 | 94 | export interface TransformedProperties { 95 | color: Color; 96 | devices: Device[]; 97 | clip_slots: ClipSlot[]; 98 | arrangement_clips: Clip[]; 99 | mixer_device: MixerDevice; 100 | } 101 | 102 | export interface SettableProperties { 103 | arm: boolean; 104 | color: number; 105 | color_index: number; 106 | current_input_routing: string; 107 | current_input_sub_routing: string; 108 | current_monitoring_state: number; 109 | current_output_routing: string; 110 | current_output_sub_routing: string; 111 | fired_slot_index: number; 112 | fold_state: number; 113 | implicit_arm: boolean; 114 | input_routing_channel: number; 115 | input_routing_type: number; 116 | input_routings: number; 117 | input_sub_routings: number; 118 | is_showing_chains: number; 119 | mute: boolean; 120 | name: string; 121 | output_routing_channel: number; 122 | output_routing_type: number; 123 | output_routings: number; 124 | output_sub_routings: number; 125 | playing_slot_index: number; 126 | solo: boolean; 127 | } 128 | 129 | export interface ObservableProperties { 130 | arm: number; 131 | arrangement_clips: RawClip[]; 132 | // available_input_routing_channels: number; 133 | // available_input_routing_types: number; 134 | // available_output_routing_channels: number; 135 | // available_output_routing_types: number; 136 | clip_slots: RawClipSlot[]; 137 | color_index: number; 138 | color: number; 139 | current_input_routing: string; 140 | current_input_sub_routing: string; 141 | current_monitoring_state: number; 142 | current_output_routing: string; 143 | current_output_sub_routing: string; 144 | devices: RawDevice[]; 145 | fired_slot_index: number; 146 | has_audio_input: boolean; 147 | has_audio_output: boolean; 148 | has_midi_input: boolean; 149 | has_midi_output: boolean; 150 | implicit_arm: boolean; 151 | input_meter_left: number; 152 | input_meter_level: number; 153 | input_meter_right: number; 154 | // input_routing_channel: string; 155 | // input_routing_type: string; 156 | // input_routings: string; 157 | // input_sub_routings: string; 158 | is_frozen: number; 159 | is_showing_chains: number; 160 | mute: boolean; 161 | muted_via_solo: number; 162 | name: string; 163 | output_meter_left: number; 164 | output_meter_level: number; 165 | output_meter_right: number; 166 | // output_routing_channel: number; 167 | // output_routing_type: number; 168 | // output_routings: number; 169 | // output_sub_routings: number; 170 | playing_slot_index: number; 171 | solo: boolean; 172 | } 173 | 174 | export interface RawTrack { 175 | readonly id: string; 176 | readonly name: string; 177 | readonly color: number; 178 | readonly color_index: number; 179 | readonly is_foldable: boolean; 180 | readonly is_grouped: boolean; 181 | readonly mute: boolean; 182 | readonly solo: boolean; 183 | } 184 | 185 | export class Track extends Namespace< 186 | GettableProperties, 187 | TransformedProperties, 188 | SettableProperties, 189 | ObservableProperties 190 | > { 191 | view: TrackView; 192 | 193 | constructor( 194 | ableton: Ableton, 195 | public raw: RawTrack, 196 | ) { 197 | super(ableton, "track", raw.id); 198 | this.view = new TrackView(this.ableton, raw.id); 199 | 200 | this.transformers = { 201 | arrangement_clips: (clips: RawClip[]) => 202 | clips.map((clip) => new Clip(ableton, clip)), 203 | color: (c) => new Color(c), 204 | devices: (devices) => devices.map((d) => new Device(ableton, d)), 205 | clip_slots: (clip_slots) => 206 | clip_slots.map((c) => new ClipSlot(ableton, c)), 207 | mixer_device: (mixer_device) => new MixerDevice(ableton, mixer_device), 208 | }; 209 | 210 | this.cachedProps = { 211 | arrangement_clips: true, 212 | devices: true, 213 | clip_slots: true, 214 | }; 215 | } 216 | 217 | /** 218 | * Duplicates the given clip into the arrangement of this track at the provided destination time and returns it. 219 | * When the type of the clip and the type of the track are incompatible, a runtime error is raised. 220 | */ 221 | duplicateClipToArrangement(clipOrId: Clip | string, time: number) { 222 | return this.sendCommand("duplicate_clip_to_arrangement", { 223 | clip_id: typeof clipOrId === "string" ? clipOrId : clipOrId.raw.id, 224 | time: time, 225 | }); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/util/cache.ts: -------------------------------------------------------------------------------- 1 | import type LruCache from "lru-cache"; 2 | 3 | export type CachedResponse = { __cached: true }; 4 | export type CacheResponse = CachedResponse | { data: any; etag: string }; 5 | 6 | export const isCached = (obj: CacheResponse): obj is CachedResponse => 7 | obj && "__cached" in obj; 8 | 9 | export interface CacheObject { 10 | etag: string; 11 | data: any; 12 | } 13 | 14 | export type Cache = LruCache; 15 | -------------------------------------------------------------------------------- /src/util/color.ts: -------------------------------------------------------------------------------- 1 | /** Represents a color in Ableton */ 2 | export class Color { 3 | private readonly color: string; 4 | 5 | constructor(color: number | string) { 6 | if (typeof color === "number") { 7 | this.color = color.toString(16).padStart(6, "0"); 8 | } else if (color.length === 6 || color.length === 7) { 9 | this.color = color.replace("#", ""); 10 | } else { 11 | throw new Error("Color " + color + " is not in a valid format"); 12 | } 13 | } 14 | 15 | get hex() { 16 | return `#${this.color}`; 17 | } 18 | 19 | get rgb() { 20 | return { 21 | r: parseInt(this.color.substr(0, 2), 16), 22 | g: parseInt(this.color.substr(2, 2), 16), 23 | b: parseInt(this.color.substr(4, 2), 16), 24 | }; 25 | } 26 | 27 | get numberRepresentation() { 28 | return parseInt(this.color, 16); 29 | } 30 | 31 | toString() { 32 | return this.hex; 33 | } 34 | 35 | toJSON() { 36 | return this.numberRepresentation; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | debug: (msg: string, ...args: any[]) => unknown; 3 | info: (msg: string, ...args: any[]) => unknown; 4 | warn: (msg: string, ...args: any[]) => unknown; 5 | error: (msg: string, ...args: any[]) => unknown; 6 | } 7 | -------------------------------------------------------------------------------- /src/util/note.ts: -------------------------------------------------------------------------------- 1 | export type NoteTuple = [ 2 | pitch: number, 3 | time: number, 4 | duration: number, 5 | velocity: number, 6 | muted: boolean, 7 | ]; 8 | 9 | export interface Note { 10 | pitch: number; 11 | time: number; 12 | duration: number; 13 | velocity: number; 14 | muted: boolean; 15 | } 16 | 17 | export const tupleToNote = (tuple: NoteTuple): Note => ({ 18 | pitch: tuple[0], 19 | time: tuple[1], 20 | duration: tuple[2], 21 | velocity: tuple[3], 22 | muted: tuple[4], 23 | }); 24 | 25 | export const noteToTuple = (note: Note): NoteTuple => [ 26 | note.pitch, 27 | note.time, 28 | note.duration, 29 | note.velocity, 30 | note.muted, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/util/package-version.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { getPackageVersion } from "./package-version"; 3 | import { valid } from "semver"; 4 | 5 | describe("Package Version", () => { 6 | it("should get a valid package version without erroring", () => { 7 | const version = getPackageVersion(); 8 | expect(valid(version)).toBeTruthy(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/util/package-version.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export const getPackageVersion = () => { 5 | const parentPath = path.join(__dirname, "..", "package.json"); 6 | const parent2Path = path.join(__dirname, "..", "..", "package.json"); 7 | 8 | if (fs.existsSync(parentPath)) { 9 | return require(parentPath).version; 10 | } 11 | 12 | if (fs.existsSync(parent2Path)) { 13 | return require(parent2Path).version; 14 | } 15 | 16 | throw new Error("Could not find package.json"); 17 | }; 18 | -------------------------------------------------------------------------------- /src/util/tests.ts: -------------------------------------------------------------------------------- 1 | import { Ableton } from ".."; 2 | 3 | export const withAbleton = async (callback: (ab: Ableton) => Promise) => { 4 | const ab = new Ableton(); 5 | ab.on("error", console.error); 6 | 7 | await ab.start(2000); 8 | 9 | try { 10 | await callback(ab); 11 | } finally { 12 | await ab.close(); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*", "./scripts/**/*"], 3 | "exclude": ["**/*.spec.ts"], 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "ES2022", 7 | "strict": true, 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "downlevelIteration": true, 11 | "allowJs": false, 12 | "outDir": "./", 13 | "rootDir": "./src/" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@esbuild/android-arm64@0.18.20": 6 | version "0.18.20" 7 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" 8 | integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== 9 | 10 | "@esbuild/android-arm@0.18.20": 11 | version "0.18.20" 12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" 13 | integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== 14 | 15 | "@esbuild/android-x64@0.18.20": 16 | version "0.18.20" 17 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" 18 | integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== 19 | 20 | "@esbuild/darwin-arm64@0.18.20": 21 | version "0.18.20" 22 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" 23 | integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== 24 | 25 | "@esbuild/darwin-x64@0.18.20": 26 | version "0.18.20" 27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" 28 | integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== 29 | 30 | "@esbuild/freebsd-arm64@0.18.20": 31 | version "0.18.20" 32 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" 33 | integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== 34 | 35 | "@esbuild/freebsd-x64@0.18.20": 36 | version "0.18.20" 37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" 38 | integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== 39 | 40 | "@esbuild/linux-arm64@0.18.20": 41 | version "0.18.20" 42 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" 43 | integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== 44 | 45 | "@esbuild/linux-arm@0.18.20": 46 | version "0.18.20" 47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" 48 | integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== 49 | 50 | "@esbuild/linux-ia32@0.18.20": 51 | version "0.18.20" 52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" 53 | integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== 54 | 55 | "@esbuild/linux-loong64@0.18.20": 56 | version "0.18.20" 57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" 58 | integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== 59 | 60 | "@esbuild/linux-mips64el@0.18.20": 61 | version "0.18.20" 62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" 63 | integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== 64 | 65 | "@esbuild/linux-ppc64@0.18.20": 66 | version "0.18.20" 67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" 68 | integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== 69 | 70 | "@esbuild/linux-riscv64@0.18.20": 71 | version "0.18.20" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" 73 | integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== 74 | 75 | "@esbuild/linux-s390x@0.18.20": 76 | version "0.18.20" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" 78 | integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== 79 | 80 | "@esbuild/linux-x64@0.18.20": 81 | version "0.18.20" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" 83 | integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== 84 | 85 | "@esbuild/netbsd-x64@0.18.20": 86 | version "0.18.20" 87 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" 88 | integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== 89 | 90 | "@esbuild/openbsd-x64@0.18.20": 91 | version "0.18.20" 92 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" 93 | integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== 94 | 95 | "@esbuild/sunos-x64@0.18.20": 96 | version "0.18.20" 97 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" 98 | integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== 99 | 100 | "@esbuild/win32-arm64@0.18.20": 101 | version "0.18.20" 102 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" 103 | integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== 104 | 105 | "@esbuild/win32-ia32@0.18.20": 106 | version "0.18.20" 107 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" 108 | integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== 109 | 110 | "@esbuild/win32-x64@0.18.20": 111 | version "0.18.20" 112 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" 113 | integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== 114 | 115 | "@jest/schemas@^29.6.3": 116 | version "29.6.3" 117 | resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" 118 | integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== 119 | dependencies: 120 | "@sinclair/typebox" "^0.27.8" 121 | 122 | "@jridgewell/sourcemap-codec@^1.5.0": 123 | version "1.5.0" 124 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" 125 | integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== 126 | 127 | "@sinclair/typebox@^0.27.8": 128 | version "0.27.8" 129 | resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" 130 | integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== 131 | 132 | "@types/chai-subset@^1.3.3": 133 | version "1.3.5" 134 | resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.5.tgz#3fc044451f26985f45625230a7f22284808b0a9a" 135 | integrity sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A== 136 | dependencies: 137 | "@types/chai" "*" 138 | 139 | "@types/chai@*": 140 | version "5.0.1" 141 | resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.0.1.tgz#2c3705555cf11f5f59c836a84c44afcfe4e5689d" 142 | integrity sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA== 143 | dependencies: 144 | "@types/deep-eql" "*" 145 | 146 | "@types/chai@^4.3.5": 147 | version "4.3.20" 148 | resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.20.tgz#cb291577ed342ca92600430841a00329ba05cecc" 149 | integrity sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ== 150 | 151 | "@types/deep-eql@*": 152 | version "4.0.2" 153 | resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" 154 | integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== 155 | 156 | "@types/lodash@^4.14.194": 157 | version "4.17.13" 158 | resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" 159 | integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== 160 | 161 | "@types/node-uuid@^0.0.28": 162 | version "0.0.28" 163 | resolved "https://registry.yarnpkg.com/@types/node-uuid/-/node-uuid-0.0.28.tgz#41655b5ce63b2f3374c4e826b4dd21e729058e3d" 164 | integrity sha512-FOZsQldDy39ox+grtoZfGC43zLz88fBZo+YbH+ROXqrHw2stPSnOL5nMTrq4I2q+Kd8rBU2PEXMN/HO9nIrvQQ== 165 | dependencies: 166 | "@types/node" "*" 167 | 168 | "@types/node@*": 169 | version "22.9.0" 170 | resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.0.tgz#b7f16e5c3384788542c72dc3d561a7ceae2c0365" 171 | integrity sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ== 172 | dependencies: 173 | undici-types "~6.19.8" 174 | 175 | "@types/node@^20.3.0": 176 | version "20.17.6" 177 | resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.6.tgz#6e4073230c180d3579e8c60141f99efdf5df0081" 178 | integrity sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ== 179 | dependencies: 180 | undici-types "~6.19.2" 181 | 182 | "@types/semver@^7.3.6": 183 | version "7.5.8" 184 | resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" 185 | integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== 186 | 187 | "@types/uuid@^8.3.0": 188 | version "8.3.4" 189 | resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" 190 | integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== 191 | 192 | "@vitest/expect@0.32.4": 193 | version "0.32.4" 194 | resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.32.4.tgz#4aa4eec78112cdbe299834b965420d4fb3afa91d" 195 | integrity sha512-m7EPUqmGIwIeoU763N+ivkFjTzbaBn0n9evsTOcde03ugy2avPs3kZbYmw3DkcH1j5mxhMhdamJkLQ6dM1bk/A== 196 | dependencies: 197 | "@vitest/spy" "0.32.4" 198 | "@vitest/utils" "0.32.4" 199 | chai "^4.3.7" 200 | 201 | "@vitest/runner@0.32.4": 202 | version "0.32.4" 203 | resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.32.4.tgz#2872c697994745f1b70e2bd6568236ad2d9eade6" 204 | integrity sha512-cHOVCkiRazobgdKLnczmz2oaKK9GJOw6ZyRcaPdssO1ej+wzHVIkWiCiNacb3TTYPdzMddYkCgMjZ4r8C0JFCw== 205 | dependencies: 206 | "@vitest/utils" "0.32.4" 207 | p-limit "^4.0.0" 208 | pathe "^1.1.1" 209 | 210 | "@vitest/snapshot@0.32.4": 211 | version "0.32.4" 212 | resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.32.4.tgz#75166b1c772d018278a7f0e79f43f3eae813f5ae" 213 | integrity sha512-IRpyqn9t14uqsFlVI2d7DFMImGMs1Q9218of40bdQQgMePwVdmix33yMNnebXcTzDU5eiV3eUsoxxH5v0x/IQA== 214 | dependencies: 215 | magic-string "^0.30.0" 216 | pathe "^1.1.1" 217 | pretty-format "^29.5.0" 218 | 219 | "@vitest/spy@0.32.4": 220 | version "0.32.4" 221 | resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.32.4.tgz#c3212bc60c1430c3b5c39d6a384a75458b8f1e80" 222 | integrity sha512-oA7rCOqVOOpE6rEoXuCOADX7Lla1LIa4hljI2MSccbpec54q+oifhziZIJXxlE/CvI2E+ElhBHzVu0VEvJGQKQ== 223 | dependencies: 224 | tinyspy "^2.1.1" 225 | 226 | "@vitest/utils@0.32.4": 227 | version "0.32.4" 228 | resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.32.4.tgz#36283e3aa3f3b1a378e19493c7b3b9107dc4ea71" 229 | integrity sha512-Gwnl8dhd1uJ+HXrYyV0eRqfmk9ek1ASE/LWfTCuWMw+d07ogHqp4hEAV28NiecimK6UY9DpSEPh+pXBA5gtTBg== 230 | dependencies: 231 | diff-sequences "^29.4.3" 232 | loupe "^2.3.6" 233 | pretty-format "^29.5.0" 234 | 235 | acorn-walk@^8.2.0: 236 | version "8.3.4" 237 | resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" 238 | integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== 239 | dependencies: 240 | acorn "^8.11.0" 241 | 242 | acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.9.0: 243 | version "8.14.0" 244 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" 245 | integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== 246 | 247 | aggregate-error@^3.0.0: 248 | version "3.1.0" 249 | resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" 250 | integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== 251 | dependencies: 252 | clean-stack "^2.0.0" 253 | indent-string "^4.0.0" 254 | 255 | ansi-styles@^5.0.0: 256 | version "5.2.0" 257 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" 258 | integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== 259 | 260 | assertion-error@^1.1.0: 261 | version "1.1.0" 262 | resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" 263 | integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== 264 | 265 | auto-changelog@^2.3.0: 266 | version "2.5.0" 267 | resolved "https://registry.yarnpkg.com/auto-changelog/-/auto-changelog-2.5.0.tgz#c7a3a203a99b54c3c7286b247911966581103c10" 268 | integrity sha512-UTnLjT7I9U2U/xkCUH5buDlp8C7g0SGChfib+iDrJkamcj5kaMqNKHNfbKJw1kthJUq8sUo3i3q2S6FzO/l/wA== 269 | dependencies: 270 | commander "^7.2.0" 271 | handlebars "^4.7.7" 272 | import-cwd "^3.0.0" 273 | node-fetch "^2.6.1" 274 | parse-github-url "^1.0.3" 275 | semver "^7.3.5" 276 | 277 | buffer-from@^1.0.0: 278 | version "1.1.2" 279 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" 280 | integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 281 | 282 | cac@^6.7.14: 283 | version "6.7.14" 284 | resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" 285 | integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== 286 | 287 | chai@^4.3.7: 288 | version "4.5.0" 289 | resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" 290 | integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== 291 | dependencies: 292 | assertion-error "^1.1.0" 293 | check-error "^1.0.3" 294 | deep-eql "^4.1.3" 295 | get-func-name "^2.0.2" 296 | loupe "^2.3.6" 297 | pathval "^1.1.1" 298 | type-detect "^4.1.0" 299 | 300 | check-error@^1.0.3: 301 | version "1.0.3" 302 | resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" 303 | integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== 304 | dependencies: 305 | get-func-name "^2.0.2" 306 | 307 | clean-stack@^2.0.0: 308 | version "2.2.0" 309 | resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" 310 | integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== 311 | 312 | commander@^7.2.0: 313 | version "7.2.0" 314 | resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" 315 | integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== 316 | 317 | confbox@^0.1.8: 318 | version "0.1.8" 319 | resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" 320 | integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== 321 | 322 | debug@^4.3.4: 323 | version "4.3.7" 324 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" 325 | integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== 326 | dependencies: 327 | ms "^2.1.3" 328 | 329 | deep-eql@^4.1.3: 330 | version "4.1.4" 331 | resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" 332 | integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== 333 | dependencies: 334 | type-detect "^4.0.0" 335 | 336 | diff-sequences@^29.4.3: 337 | version "29.6.3" 338 | resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" 339 | integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== 340 | 341 | esbuild@^0.18.10, esbuild@~0.18.20: 342 | version "0.18.20" 343 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" 344 | integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== 345 | optionalDependencies: 346 | "@esbuild/android-arm" "0.18.20" 347 | "@esbuild/android-arm64" "0.18.20" 348 | "@esbuild/android-x64" "0.18.20" 349 | "@esbuild/darwin-arm64" "0.18.20" 350 | "@esbuild/darwin-x64" "0.18.20" 351 | "@esbuild/freebsd-arm64" "0.18.20" 352 | "@esbuild/freebsd-x64" "0.18.20" 353 | "@esbuild/linux-arm" "0.18.20" 354 | "@esbuild/linux-arm64" "0.18.20" 355 | "@esbuild/linux-ia32" "0.18.20" 356 | "@esbuild/linux-loong64" "0.18.20" 357 | "@esbuild/linux-mips64el" "0.18.20" 358 | "@esbuild/linux-ppc64" "0.18.20" 359 | "@esbuild/linux-riscv64" "0.18.20" 360 | "@esbuild/linux-s390x" "0.18.20" 361 | "@esbuild/linux-x64" "0.18.20" 362 | "@esbuild/netbsd-x64" "0.18.20" 363 | "@esbuild/openbsd-x64" "0.18.20" 364 | "@esbuild/sunos-x64" "0.18.20" 365 | "@esbuild/win32-arm64" "0.18.20" 366 | "@esbuild/win32-ia32" "0.18.20" 367 | "@esbuild/win32-x64" "0.18.20" 368 | 369 | fsevents@~2.3.2, fsevents@~2.3.3: 370 | version "2.3.3" 371 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 372 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 373 | 374 | get-func-name@^2.0.1, get-func-name@^2.0.2: 375 | version "2.0.2" 376 | resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" 377 | integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== 378 | 379 | get-tsconfig@^4.7.2: 380 | version "4.8.1" 381 | resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.1.tgz#8995eb391ae6e1638d251118c7b56de7eb425471" 382 | integrity sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg== 383 | dependencies: 384 | resolve-pkg-maps "^1.0.0" 385 | 386 | handlebars@^4.7.7: 387 | version "4.7.8" 388 | resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" 389 | integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== 390 | dependencies: 391 | minimist "^1.2.5" 392 | neo-async "^2.6.2" 393 | source-map "^0.6.1" 394 | wordwrap "^1.0.0" 395 | optionalDependencies: 396 | uglify-js "^3.1.4" 397 | 398 | import-cwd@^3.0.0: 399 | version "3.0.0" 400 | resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92" 401 | integrity sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg== 402 | dependencies: 403 | import-from "^3.0.0" 404 | 405 | import-from@^3.0.0: 406 | version "3.0.0" 407 | resolved "https://registry.yarnpkg.com/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966" 408 | integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ== 409 | dependencies: 410 | resolve-from "^5.0.0" 411 | 412 | indent-string@^4.0.0: 413 | version "4.0.0" 414 | resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" 415 | integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== 416 | 417 | local-pkg@^0.4.3: 418 | version "0.4.3" 419 | resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" 420 | integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g== 421 | 422 | lodash@^4.17.21: 423 | version "4.17.21" 424 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 425 | integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 426 | 427 | loupe@^2.3.6: 428 | version "2.3.7" 429 | resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" 430 | integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== 431 | dependencies: 432 | get-func-name "^2.0.1" 433 | 434 | lru-cache@^7.14.0: 435 | version "7.18.3" 436 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" 437 | integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== 438 | 439 | magic-string@^0.30.0: 440 | version "0.30.12" 441 | resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.12.tgz#9eb11c9d072b9bcb4940a5b2c2e1a217e4ee1a60" 442 | integrity sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw== 443 | dependencies: 444 | "@jridgewell/sourcemap-codec" "^1.5.0" 445 | 446 | minimist@^1.2.5: 447 | version "1.2.8" 448 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" 449 | integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== 450 | 451 | mlly@^1.4.0, mlly@^1.7.2: 452 | version "1.7.2" 453 | resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.2.tgz#21c0d04543207495b8d867eff0ac29fac9a023c0" 454 | integrity sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA== 455 | dependencies: 456 | acorn "^8.12.1" 457 | pathe "^1.1.2" 458 | pkg-types "^1.2.0" 459 | ufo "^1.5.4" 460 | 461 | ms@^2.1.3: 462 | version "2.1.3" 463 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 464 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 465 | 466 | nanoid@^3.3.7: 467 | version "3.3.7" 468 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" 469 | integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== 470 | 471 | neo-async@^2.6.2: 472 | version "2.6.2" 473 | resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" 474 | integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== 475 | 476 | node-fetch@^2.6.1: 477 | version "2.7.0" 478 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" 479 | integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== 480 | dependencies: 481 | whatwg-url "^5.0.0" 482 | 483 | p-all@^3: 484 | version "3.0.0" 485 | resolved "https://registry.yarnpkg.com/p-all/-/p-all-3.0.0.tgz#077c023c37e75e760193badab2bad3ccd5782bfb" 486 | integrity sha512-qUZbvbBFVXm6uJ7U/WDiO0fv6waBMbjlCm4E66oZdRR+egswICarIdHyVSZZHudH8T5SF8x/JG0q0duFzPnlBw== 487 | dependencies: 488 | p-map "^4.0.0" 489 | 490 | p-limit@^4.0.0: 491 | version "4.0.0" 492 | resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" 493 | integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== 494 | dependencies: 495 | yocto-queue "^1.0.0" 496 | 497 | p-map@^4.0.0: 498 | version "4.0.0" 499 | resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" 500 | integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== 501 | dependencies: 502 | aggregate-error "^3.0.0" 503 | 504 | parse-github-url@^1.0.3: 505 | version "1.0.3" 506 | resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.3.tgz#2ab55642c8685b63fbe2a196f5abe4ae9bd68abc" 507 | integrity sha512-tfalY5/4SqGaV/GIGzWyHnFjlpTPTNpENR9Ea2lLldSJ8EWXMsvacWucqY3m3I4YPtas15IxTLQVQ5NSYXPrww== 508 | 509 | pathe@^1.1.1, pathe@^1.1.2: 510 | version "1.1.2" 511 | resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" 512 | integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== 513 | 514 | pathval@^1.1.1: 515 | version "1.1.1" 516 | resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" 517 | integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== 518 | 519 | picocolors@^1.0.0, picocolors@^1.1.0: 520 | version "1.1.1" 521 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" 522 | integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== 523 | 524 | pkg-types@^1.2.0: 525 | version "1.2.1" 526 | resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.2.1.tgz#6ac4e455a5bb4b9a6185c1c79abd544c901db2e5" 527 | integrity sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw== 528 | dependencies: 529 | confbox "^0.1.8" 530 | mlly "^1.7.2" 531 | pathe "^1.1.2" 532 | 533 | postcss@^8.4.27: 534 | version "8.4.47" 535 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365" 536 | integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ== 537 | dependencies: 538 | nanoid "^3.3.7" 539 | picocolors "^1.1.0" 540 | source-map-js "^1.2.1" 541 | 542 | prettier@^3.3.3: 543 | version "3.3.3" 544 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" 545 | integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== 546 | 547 | pretty-format@^29.5.0: 548 | version "29.7.0" 549 | resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" 550 | integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== 551 | dependencies: 552 | "@jest/schemas" "^29.6.3" 553 | ansi-styles "^5.0.0" 554 | react-is "^18.0.0" 555 | 556 | react-is@^18.0.0: 557 | version "18.3.1" 558 | resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" 559 | integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== 560 | 561 | resolve-from@^5.0.0: 562 | version "5.0.0" 563 | resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" 564 | integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== 565 | 566 | resolve-pkg-maps@^1.0.0: 567 | version "1.0.0" 568 | resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" 569 | integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== 570 | 571 | rollup@^3.27.1: 572 | version "3.29.5" 573 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" 574 | integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== 575 | optionalDependencies: 576 | fsevents "~2.3.2" 577 | 578 | semver@^7.3.5: 579 | version "7.6.3" 580 | resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" 581 | integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== 582 | 583 | siginfo@^2.0.0: 584 | version "2.0.0" 585 | resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" 586 | integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== 587 | 588 | source-map-js@^1.2.1: 589 | version "1.2.1" 590 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" 591 | integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== 592 | 593 | source-map-support@^0.5.21: 594 | version "0.5.21" 595 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" 596 | integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== 597 | dependencies: 598 | buffer-from "^1.0.0" 599 | source-map "^0.6.0" 600 | 601 | source-map@^0.6.0, source-map@^0.6.1: 602 | version "0.6.1" 603 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 604 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 605 | 606 | stackback@0.0.2: 607 | version "0.0.2" 608 | resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" 609 | integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== 610 | 611 | std-env@^3.3.3: 612 | version "3.8.0" 613 | resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" 614 | integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== 615 | 616 | strip-literal@^1.0.1: 617 | version "1.3.0" 618 | resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07" 619 | integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== 620 | dependencies: 621 | acorn "^8.10.0" 622 | 623 | tinybench@^2.5.0: 624 | version "2.9.0" 625 | resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" 626 | integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== 627 | 628 | tinypool@^0.5.0: 629 | version "0.5.0" 630 | resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.5.0.tgz#3861c3069bf71e4f1f5aa2d2e6b3aaacc278961e" 631 | integrity sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ== 632 | 633 | tinyspy@^2.1.1: 634 | version "2.2.1" 635 | resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" 636 | integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== 637 | 638 | tr46@~0.0.3: 639 | version "0.0.3" 640 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 641 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 642 | 643 | tsx@^3.12.7: 644 | version "3.14.0" 645 | resolved "https://registry.yarnpkg.com/tsx/-/tsx-3.14.0.tgz#be6e2176b6f210fe8f48124fb6e22e0f075e927b" 646 | integrity sha512-xHtFaKtHxM9LOklMmJdI3BEnQq/D5F73Of2E1GDrITi9sgoVkvIsrQUTY1G8FlmGtA+awCI4EBlTRRYxkL2sRg== 647 | dependencies: 648 | esbuild "~0.18.20" 649 | get-tsconfig "^4.7.2" 650 | source-map-support "^0.5.21" 651 | optionalDependencies: 652 | fsevents "~2.3.3" 653 | 654 | type-detect@^4.0.0, type-detect@^4.1.0: 655 | version "4.1.0" 656 | resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" 657 | integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== 658 | 659 | typescript@^5.1.3: 660 | version "5.6.3" 661 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" 662 | integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== 663 | 664 | ufo@^1.5.4: 665 | version "1.5.4" 666 | resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" 667 | integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== 668 | 669 | uglify-js@^3.1.4: 670 | version "3.19.3" 671 | resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" 672 | integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== 673 | 674 | undici-types@~6.19.2, undici-types@~6.19.8: 675 | version "6.19.8" 676 | resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" 677 | integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== 678 | 679 | uuid@^8.3.2: 680 | version "8.3.2" 681 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" 682 | integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== 683 | 684 | vite-node@0.32.4: 685 | version "0.32.4" 686 | resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.4.tgz#7b3f94af5a87c631fbc380ba662914bafbd04d80" 687 | integrity sha512-L2gIw+dCxO0LK14QnUMoqSYpa9XRGnTTTDjW2h19Mr+GR0EFj4vx52W41gFXfMLqpA00eK4ZjOVYo1Xk//LFEw== 688 | dependencies: 689 | cac "^6.7.14" 690 | debug "^4.3.4" 691 | mlly "^1.4.0" 692 | pathe "^1.1.1" 693 | picocolors "^1.0.0" 694 | vite "^3.0.0 || ^4.0.0" 695 | 696 | "vite@^3.0.0 || ^4.0.0": 697 | version "4.5.5" 698 | resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.5.tgz#639b9feca5c0a3bfe3c60cb630ef28bf219d742e" 699 | integrity sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ== 700 | dependencies: 701 | esbuild "^0.18.10" 702 | postcss "^8.4.27" 703 | rollup "^3.27.1" 704 | optionalDependencies: 705 | fsevents "~2.3.2" 706 | 707 | vitest@^0.32.4: 708 | version "0.32.4" 709 | resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.32.4.tgz#a0558ae44c2ccdc254eece0365f16c4ffc5231bb" 710 | integrity sha512-3czFm8RnrsWwIzVDu/Ca48Y/M+qh3vOnF16czJm98Q/AN1y3B6PBsyV8Re91Ty5s7txKNjEhpgtGPcfdbh2MZg== 711 | dependencies: 712 | "@types/chai" "^4.3.5" 713 | "@types/chai-subset" "^1.3.3" 714 | "@types/node" "*" 715 | "@vitest/expect" "0.32.4" 716 | "@vitest/runner" "0.32.4" 717 | "@vitest/snapshot" "0.32.4" 718 | "@vitest/spy" "0.32.4" 719 | "@vitest/utils" "0.32.4" 720 | acorn "^8.9.0" 721 | acorn-walk "^8.2.0" 722 | cac "^6.7.14" 723 | chai "^4.3.7" 724 | debug "^4.3.4" 725 | local-pkg "^0.4.3" 726 | magic-string "^0.30.0" 727 | pathe "^1.1.1" 728 | picocolors "^1.0.0" 729 | std-env "^3.3.3" 730 | strip-literal "^1.0.1" 731 | tinybench "^2.5.0" 732 | tinypool "^0.5.0" 733 | vite "^3.0.0 || ^4.0.0" 734 | vite-node "0.32.4" 735 | why-is-node-running "^2.2.2" 736 | 737 | webidl-conversions@^3.0.0: 738 | version "3.0.1" 739 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 740 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 741 | 742 | whatwg-url@^5.0.0: 743 | version "5.0.0" 744 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 745 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 746 | dependencies: 747 | tr46 "~0.0.3" 748 | webidl-conversions "^3.0.0" 749 | 750 | why-is-node-running@^2.2.2: 751 | version "2.3.0" 752 | resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" 753 | integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== 754 | dependencies: 755 | siginfo "^2.0.0" 756 | stackback "0.0.2" 757 | 758 | wordwrap@^1.0.0: 759 | version "1.0.0" 760 | resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 761 | integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== 762 | 763 | yocto-queue@^1.0.0: 764 | version "1.1.1" 765 | resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" 766 | integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== 767 | --------------------------------------------------------------------------------