├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── taktil.js ├── tasks │ ├── build.js │ └── init.js └── template │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── components.js │ ├── components.ts │ ├── controls.js │ ├── daw.js │ ├── daw.ts │ ├── index.js │ └── views.js │ ├── tsconfig.json │ └── webpack.config.js ├── gulpfile.js ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── src ├── component │ ├── Button.spec.ts │ ├── Button.ts │ ├── Component.spec.ts │ ├── Component.ts │ ├── Range.spec.ts │ ├── Range.ts │ └── index.ts ├── control │ ├── ChannelPressure.ts │ ├── Control.spec.ts │ ├── Control.ts │ ├── ControlChange.ts │ ├── KeyPressure.ts │ ├── Note.ts │ └── index.ts ├── env │ ├── DelayedTask.ts │ ├── Logger.ts │ └── index.ts ├── helpers │ ├── Color.ts │ └── index.ts ├── index.ts ├── midi │ ├── MessagePattern.spec.ts │ ├── MessagePattern.ts │ ├── MidiMessage.ts │ ├── MidiOutProxy.ts │ ├── SysexMessage.ts │ └── index.ts ├── session │ ├── EventEmitter.ts │ ├── Session.spec.ts │ ├── Session.ts │ └── index.ts └── view │ ├── View.spec.ts │ ├── View.ts │ ├── ViewStack.ts │ └── index.ts ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /notes 4 | /.vscode 5 | 6 | /lib 7 | /coverage 8 | /docs/typedoc 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /notes 4 | /.vscode 5 | /gulpfile.js 6 | /.travis.yml 7 | /tsconfig.* 8 | /tslint.json 9 | /src 10 | /coverage 11 | /docs 12 | 13 | # ignore the *.ts files but not *.d.ts 14 | *.ts 15 | !*.d.ts 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Joseph Larson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ~~Taktil~~ DEPRECATED 2 | 3 | > The work I did here matured into a new project that allows you to build controller scripts with the abstraction provided by React: [ReactBitwig](https://github.com/joslarson/react-bitwig) 4 | 5 | --- 6 | 7 | [![npm version](https://badge.fury.io/js/taktil.svg)](https://badge.fury.io/js/taktil) 8 | 9 | Taktil is a lightweight control surface scripting framework for Bitwig Studio that encourages rapid development and community code reuse. At its core, it is designed around the idea of building reusable, extendable components and provides a simple but powerful view abstraction that makes contextually mapping hardware controls to different components a breeze. 10 | 11 | Taktil's integrated build tool transpiles and bundles your code to enable ES2015+ language features and npm dependency support in Bitwig's ES5 world (no messy build configurations necessary). 12 | 13 | > **Note:** While Taktil is written in and provide first class support for TypeScript, this documentation currently provides only pure JavaScript examples. TypeScript specific docs are in the works. 14 | 15 | > **Warning!** While fully usable, Taktil is still pre-1.0 and has no backwards compatibility guarantees until the v1 release occurs! Your input is encouraged. Also, while the CLI is designed to work on Windows, Mac, and Linux, I have only tested it on macOS so far. So please create ticket and/or submit pull request as you find bugs. 16 | 17 | ## Installation 18 | 19 | To get started started, you'll need to first make sure you have the following prerequisites installed and functioning on your system. 20 | 21 | * Node.js v6.0 or newer 22 | * Bitwig Studio v2.0 or newer 23 | 24 | Once you've got that out of the way, you're ready to install Taktil. If you want to use the integrated CLI, you'll need to install it globally. Doing so will put the `taktil` CLI command in your path which will handle initializing projects and building + bundling them for you. 25 | 26 | ```bash 27 | npm install -g taktil 28 | ``` 29 | 30 | > **Note:** Don't miss the `-g` flag here as this installs the package globally, adding the the `taktil` command to your path. 31 | 32 | If you'd rather use Taktil without the CLI, you can manually install it locally into an existing project like so: 33 | 34 | ```bash 35 | npm install taktil 36 | ``` 37 | 38 | ## Project Setup 39 | 40 | With that out of the way, let's start a new project using the CLI tool's `init` command. We'll call our project `getting-started`. 41 | 42 | ```bash 43 | taktil init getting-started 44 | # run taktil --help for detailed usage 45 | ``` 46 | 47 | This command will ask you some questions then generate a working project skeleton based on your responses. If, after running the command above, we answer the prompts as follows... 48 | 49 | ```bash 50 | # answers are optional where a default is provided in parentheses 51 | [taktil] begin project initialization... 52 | Display Name: Getting Started 53 | Vendor/Category: Custom 54 | Author: Joseph Larson 55 | Version (1.0.0): 56 | API Version (4): 57 | [taktil] project initialization complete. 58 | ``` 59 | 60 | ...your new project will be created within a new `getting-started` directory, relative to your current working directory, and will have the following structure: 61 | 62 | ```bash 63 | getting-started/ 64 | ├── dist/ -> ... # symlinked into default Bitwig control surface scripts directory 65 | ├── src 66 | │   ├── components.js 67 | │   ├── controls.js 68 | │   ├── daw.js 69 | │   ├── index.js 70 | │   └── views.js 71 | ├── README.md 72 | ├── package.json 73 | ├── tsconfig.json 74 | └── webpack.config.js 75 | ``` 76 | 77 | `cd` into your new project directory and install the initial dependencies using npm. 78 | 79 | ```bash 80 | cd getting-started 81 | npm install 82 | ``` 83 | 84 | Now, we'll run the CLI's `build` command, to generate your project's initial build. 85 | 86 | ```bash 87 | taktil build # run from the project root 88 | ``` 89 | 90 | At this point your newly built project files should have been picked up by Bitwig and your script should be listed under `Custom > Getting Started` in Bitwig's controller selection menu. 91 | 92 | Activate your new controller script by selecting it from the script selection menu and assigning your Midi controller's corresponding MIDI I/O. 93 | 94 | > **Note**: The default project template is setup assuming a single midi input/output pair, but if you need more than that, more can be defined in the your project's entry file (`src/index.js` in this case). 95 | 96 | Now, before we start editing files, let's put the CLI's build command in watch mode so that our project will rebuild whenever we modify and save a source file. 97 | 98 | ```bash 99 | taktil watch # run from the project root 100 | # exit the build command with ctrl/cmd+c 101 | ``` 102 | 103 | With that, everything should be in place to start coding your control surface script. Let's get into it :) 104 | 105 | 106 | ## Defining Controls 107 | 108 | The first real task for any new Taktil project is to define an initial set of Midi controls for the script to react to and/or send updates to. This set of controls acts as the template for your control surface and all its knobs, buttons, pads, etc. Taktil's `Control` abstraction exists to help you model your unique hardware-specific controls in way that provides **a common interface on top of which to build reusable components.** 109 | 110 | In this tutorial, our hypothetical control surface has three relevant controls: a `PLAY` button, a `SHIFT` button, and a general purpose `KNOB`. Let's take a quick look at Taktil's `Control` MIDI abstraction. 111 | 112 | ### MIDI Patterns 113 | 114 | Each `Control` is composed of a list of MIDI input/output patterns that: 115 | 116 | 1. Define which MIDI input messages will be routed to the `Control` to be handled. 117 | 2. Let Taktil know how to cache midi output messages such that redundant messages—those that would not change your Midi controller's state—are not sent. 118 | 119 | This list of patterns must not overlap with any other registered `Control` definition, such that a given Midi input message will always be routed to only one `Control`. This requirement is enforced to enable the above mentioned caching mechanism which provides significant automatic Midi output optimizations. 120 | 121 | Patterns can be defined in string or object literal form. 122 | 123 | * **String form** patterns are the most expressive and concise, representing the MIDI port and each MIDI byte as consecutive two character hexadecimal values, with question marks used for wildcard matching (e.g. `'00B018??'` where `00`, `B0`, `18`, and `??` represent the port, status, data1, and data2 values). If the port byte is left off (`'B018??'`) the pattern's port defaults to 0. 124 | 125 | * **Object literal form** allows you to define MIDI patterns by assuming undefined MIDI byte values to be wild cards (e.g. `{ port: 0, status: 0xb0, data1: 0x18 }` where any MIDI message matching the provided values will be handled). If the port value is left off (`{ status: 0xb0, data1: 0x18 }`) the pattern's port defaults to 0. Though slightly less powerful and more verbose, you may find object literal form to be more readable while still covering the vast majority of use cases. 126 | 127 | > **Note**: The values for the status and data1 arguments above (e.g. `0xb0`) are just plain numbers written in JavaScript's hexadecimal (base 16) form. This makes it easier to to see the type of message and which channel the `Control` maps to, but you can use regular base 10 integers 0-127 here if you want to. 128 | 129 | ### Control Options 130 | 131 | Beyond defining the MIDI patterns your `Control` will handle, you may also need to override some of the `Control`'s default options to reflect the type of `Control` you are defining. The most common of those options are as follows: 132 | 133 | * **`enableMidiOut`** (default: `true`): Set to `false` to disable Midi output for this control. 134 | * **`enableCache`** (default: `true`): Set to `false` if you'd like to disable the magic that is the cache. 135 | * **`cacheOnMidiIn`** (default: `true`): Set this to `false` for controls whose visual state (e.g. LED) is only updated on MIDI output. 136 | 137 | > **Note:** Whenever this documentation mentions MIDI input or output it is speaking from the perspective of the script, not the controller. So "input" is referring to MIDI messages travelling from the controller to the script, and "output" is referencing messages moving from the script to the controller. 138 | 139 | 140 | ### Control State 141 | 142 | All `Control` instances have a state object that contains a required value property, optional color, brightness, and flashing properties, and whatever other properties custom `Control` subclasses define. The state is updated by calling the `Controls`'s `setState` method, and is most often called from within a corresponding component (which we'll explore later on). 143 | 144 | ### The `PLAY` Button 145 | 146 | Let's begin by defining the `PLAY` button as MIDI CC 24 (or `0x18` in hex) on port 0, channel 0. That translates, in hex values, to a status of `0xb0`, a data1 of `0x18`, and a wild card match on the data2 value. In **string form** our definition will look as follows: 147 | 148 | ```js 149 | // src/controls.js 150 | 151 | import taktil from 'taktil'; 152 | 153 | export const controls = { 154 | PLAY: new taktil.Control({ patterns: ['B018??'] }), 155 | }; 156 | ``` 157 | 158 | The same definition in **object literal form** would look like this: 159 | 160 | ```js 161 | // src/controls.js 162 | 163 | import taktil from 'taktil'; 164 | 165 | export const controls = { 166 | PLAY: new taktil.Control({ patterns: [{ status: 0xb0, data1: 0x18 }] }), 167 | }; 168 | ``` 169 | 170 | ### Further Control Abstraction 171 | 172 | Taktil provides further abstracted Control subclasses for CC (Control Change), Note, Channel Pressure, and Key Pressure messages. Assuming all three of our controls are MIDI CC controls, we can redefine our `PLAY` button and add to it definitions for our `SHIFT` button and `KNOB` as follows. 173 | 174 | ```js 175 | // src/controls.js 176 | 177 | import taktil from 'taktil'; 178 | 179 | export const controls = { 180 | PLAY: new taktil.ControlChange({ channel: 0, control: 24 }), 181 | SHIFT: new taktil.ControlChange({ channel: 0, control: 25 }), 182 | KNOB: new taktil.ControlChange({ channel: 0, control: 26 }), 183 | }; 184 | ``` 185 | 186 | With that, we've defined our controls. 187 | 188 | > **Note:** In the same way that ControlChange extends Control, you can create your own Control subclasses. You might, for example define a Control type that not only keeps track of your hardware control's value, but also handles its color, its brightness, and whether it's pulsing or not. The sky's the limit. All controls must define the MIDI messages they will handle, but what they do with the input and what they render on output and when is up to you. 189 | 190 | 191 | ## Creating Components 192 | 193 | Now that we've defined our controls, we'll move on to building some components. Components, in Taktil, are the state and business logic containers for non-hardware-specific functionality. They receive and react to standardized `ControlInput` messages (through the `onControlInput` method) as well as Bitwig API events. They also decide when a connected `Control` should be updated by calling its `setState` method, usually in reaction to one of the above mentioned messages or events. 194 | 195 | Components are defined as ES6 classes that must eventually extend the `Component` base class. At instantiation time, a component will be passed an associated control and a params object. The params object is your component configuration object and is where all of the Bitwig API derived objects should be passed in for use in the components different life-cycle methods. 196 | 197 | There are only three total component life-cycle methods and only two that are required for a component definition. They are as follows. 198 | 199 | * **`onInit` (optional):** This where you define all of your component's "init" phase logic, which mostly consists of hooking up Bitwig API event callbacks. 200 | * **`onControlInput` (required):** This is where you define what happens when your component's connected control receives input. 201 | * **`getControlOutput` (required):** This returns the full or partial control state object that will be sent to the controls `setState` method whenever our component re-renders. 202 | 203 | To get our feet wet, we'll start off by creating a simple play/pause toggle. 204 | 205 | ```js 206 | // src/components.js 207 | 208 | import taktil from 'taktil'; 209 | 210 | export class PlayToggle extends taktil.Component { 211 | state = { on: false }; 212 | 213 | // onInit is where you should hookup your Bitwig API event callbacks, 214 | // as all of that work must be done during the "init" phase 215 | onInit() { 216 | // we're expecting a Bitwig API derived transport object to be passed in to the params 217 | // object at instantiation time, then we're hooking up a callback to sync the component 218 | // state with the transport's isPlaying state. 219 | this.params.transport 220 | .isPlaying() 221 | .addValueObserver(isPlaying => this.setState({ on: isPlaying })); 222 | } 223 | 224 | // onControlInput is where we define what happens when our component's connected control 225 | // receives input 226 | onControlInput({ value }) { 227 | // if the input value is greater than the controls min value, toggle play state 228 | if (value > this.control.minValue) this.params.transport.togglePlay(); 229 | } 230 | 231 | // getControlOutput is where we define the full or partial control state object 232 | // that will be sent to the controls `setState` method whenever our component re-renders 233 | getControlOutput() { 234 | // in this case if our button is "on" we send the control's max value, otherwise 235 | // we send it's minimum value 236 | const { state: { on }, control: { minValue, maxValue } } = this; 237 | return { value: on ? maxValue : minValue }; 238 | } 239 | } 240 | ``` 241 | 242 | Because buttons are such a big and predictable part of every controller script, Taktil provides a general purpose Button component. The button component extends the base Component class, adding five self described optionally implemented life-cycle methods: 243 | 244 | * **`onPress`** 245 | * **`onLongPress`** 246 | * **`onDoublePress`** 247 | * **`onRelease`** 248 | * **`onDoubleRelease`** 249 | 250 | Let's re-implement our PlayToggle by extending the Button component. 251 | 252 | ```js 253 | // src/components.js 254 | 255 | import taktil from 'taktil'; 256 | 257 | export class PlayToggle extends taktil.Button { 258 | onInit() { 259 | this.params.transport 260 | .isPlaying() 261 | .addValueObserver(isPlaying => this.setState({ on: isPlaying })); 262 | } 263 | 264 | onPress() { 265 | this.params.transport.togglePlay() 266 | } 267 | } 268 | ``` 269 | 270 | Now let's implement the rest of the components for our getting started project. First we want to implement a MetronomeToggle and a ModeGate button which will turn shift mode on/off globally such that when the shift ModeGate button is pressed the PLAY control will toggle the metronome on/off, and when the shift ModeGate button is released the PLAY control will go back to being a PlayToggle. 271 | 272 | > **Note:** We'll hook up the components to controls and modes in the view section, then you'll have a better understanding of what I'm talking about here. 273 | 274 | ```js 275 | // src/components.js (continued...) 276 | 277 | export class ModeGate extends taktil.Button { 278 | onPress() { 279 | this.setState({ on: true }); 280 | taktil.activateMode(this.params.target); 281 | } 282 | 283 | onRelease() { 284 | this.setState({ on: false }); 285 | taktil.deactivateMode(this.params.target); 286 | } 287 | } 288 | 289 | export class MetronomeToggle extends taktil.Button { 290 | onInit() { 291 | this.params.transport 292 | .isMetronomeEnabled() 293 | .addValueObserver(isEnabled => this.setState({ on: isEnabled })); 294 | } 295 | 296 | onPress() { 297 | this.params.transport.isMetronomeEnabled().toggle(); 298 | } 299 | } 300 | ``` 301 | 302 | Finally, to get away from buttons, we'll implement a VolumeRange component which takes a Bitwig Track instance as a param and controls it's volume. When the volume changes in Bitwig the component will update its associated control, and when its associated control sends input, the tracks level will be adjust accordingly. In this way the component's job is to keep the control state's value and and the Track object's volume value in sync with one another. 303 | 304 | ```js 305 | // src/components.js (continued...) 306 | 307 | export class VolumeRange extends taktil.Component { 308 | state = { value: 0 }; 309 | memory = {}; 310 | 311 | onInit() { 312 | this.params.track.getVolume().addValueObserver(value => { 313 | this.setState({ value: Math.round(value * 127) }); 314 | }); 315 | } 316 | 317 | onControlInput({ value }) { 318 | if (this.memory.input) clearTimeout(this.memory.input); 319 | this.memory.input = setTimeout(() => delete this.memory.input, 350); 320 | 321 | this.params.track.getVolume().set(value / 127); 322 | } 323 | 324 | getControlOutput() { 325 | return { value: this.state.value }; 326 | } 327 | 328 | render() { 329 | if (!this.memory.input) super.render(); 330 | } 331 | } 332 | ``` 333 | 334 | ## Integrating with the Bitwig API 335 | 336 | Bitwig's API requires that all of our API derived object and event subscription needs be defined and setup during the controller scripts 'init' phase. This can be done by following the pattern below. 337 | 338 | ```js 339 | // src/daw.js 340 | 341 | import taktil from 'taktil'; 342 | 343 | // export our initialized empty daw object 344 | export const daw = {}; 345 | // after the init phase our daw object will be populated with all of our needed API objects. 346 | // these object will be ready and can be accessed from within in a component's `onInit` 347 | // method to setup event callbacks related to that component. It's best practice to pass 348 | // these objects into components as params to make them more reusable. 349 | taktil.on('init', () => { 350 | daw.transport = host.createTransport(); 351 | daw.masterTrack = host.createMasterTrack(0); 352 | // ...setup all of your "init time only" bitwig api objects here 353 | }); 354 | ``` 355 | 356 | > **Note:** Bitwig's API only allows a single function to be defined as your init event callback. Taktil defines this function for us in a way that allows us to register multiple callbacks to the 'init' event via the `taktil.on('init', [callback])` method. 357 | 358 | 359 | ## Assembling the Views 360 | 361 | Now that we've defined our controls and and components, it's time to assemble them into views. I their simplest form, views are just a mapping of components to controls. When a view is active, the view's components will receive control input and generate control output. An inactive view's components, on the other hand, will not be sent control input messages, and will not generate any control output, but they will continue to maintain their internal state in preparation for being activated. 362 | 363 | View components are also registered to a specific view "mode" and will only be considered active if both the view and the mode are active. Taktil maintains the array of active mode strings globally. These modes are kept in the order they were activated such that a component registered to the same control but a different mode can override the another, with the most recently activated mode taking precedence. This allows a view to configure itself differently based on the global mode list. This is useful, for instance, when implementing a shift button, where having all views know our script is in "shift mode" will allow us to define secondary actions across multiple disconnected views. 364 | 365 | Views are defined by extending Taktil's View class or by stacking previously defined views using the ViewStack function. The ViewStack function accepts a list of view classes and returns a new View class definition where, in order, each of the provided views' component/control mappings override any subsequent view's mapping involving the same control (it's just simple inheritance where the the control portion of each mapping is the thing being overridden). This pattern makes it possible to define reusable chunks of view logic which can be combined together in different ways to create more complex views. 366 | 367 | In a simple project, a single view making use of view modes may be enough handle your needs. The value of view stacks will become apparent when developing more complex projects. 368 | 369 | As shown below, the component/control mappings are defined as instance properties. Valid instance property types consist of a component instance, an array of component instances, or a function that returns either of the previous. Component constructors take a control instance and a params object. The params object consists of an optional mode property—for defining which mode the component should be registered to—as well as whatever else the individual component needs to operate. This is generally where we will pass in objects retrieved or created through Bitwig's API as the view instance code will not be run until after the init phase. 370 | 371 | ```js 372 | // src/views.js 373 | 374 | import { View, ViewStack } from 'taktil'; 375 | 376 | import { PlayToggle, ModeGate, MetronomeToggle, VolumeRange } from './components'; 377 | import { controls } from './controls'; 378 | import { daw } from './daw'; 379 | 380 | // in this view when you press the SHIFT button the PLAY button will toggle the metronome on/off 381 | // but when the SHIFT button is released, the PLAY button will toggle the transport's play state 382 | class BaseView extends View { 383 | // map PlayToggle component to the PLAY control, registering it to the default base mode 384 | playToggle = new PlayToggle(controls.PLAY, { transport: daw.transport }); 385 | // map ModeGate component to the SHIFT control, registering it to the default base mode 386 | shiftModeGate = new ModeGate(controls.SHIFT, { target: 'SHIFT' }); 387 | // map MetronomeToggle component to the PLAY control, registering it to our custom 'SHIFT' mode 388 | metroToggle = new MetronomeToggle(controls.PLAY, { mode: 'SHIFT', transport: daw.transport }); 389 | } 390 | 391 | class MixerView extends View { 392 | // map VolumeRange component to the KNOB control, registering it the default base mode 393 | masterVolume = new VolumeRange(controls.KNOB, { track: daw.masterTrack }); 394 | } 395 | 396 | // export the view name to view class mapping so that we can register these views to 397 | // the session to be activated by name 398 | export const views = { 399 | BASE: BaseView, 400 | // by stacking these views, all components in MixerView that are connected to an active mode 401 | // will be active and any components in BaseView that are connected to an active mode will 402 | // be active as long as their connected controls do not conflict with any active component 403 | // in the MixerView. 404 | MIXER: ViewStack(MixerView, BaseView), 405 | }; 406 | ``` 407 | 408 | 409 | ## Initializing the Session 410 | 411 | At this point we've defined our control layer, created some components, and assembled those controls and components into views. Now all that's left is to create our controller script's entry point where we will setup and initialize our session. 412 | 413 | ```js 414 | // src/index.js 415 | 416 | import taktil from 'taktil'; 417 | 418 | import { controls } from './controls'; 419 | import { views } from './views'; 420 | 421 | // 1. set bitwig api version 422 | host.loadAPI(4); 423 | 424 | // 2. define controller script 425 | host.defineController( 426 | 'Custom', // vendor 427 | 'Getting Started', // name 428 | '1.0.0', // version 429 | 'f2b0f9b0-87be-11e7-855f-094b43050ba2', // uuid 430 | 'Joseph Larson' // author 431 | ); 432 | 433 | // 3. setup and discover midi controllers 434 | host.defineMidiPorts(1, 1); // number of midi inputs, outputs 435 | // host.addDeviceNameBasedDiscoveryPair( 436 | // ['Input Name'], 437 | // ['Output Name'], 438 | // ); 439 | 440 | // 4. register controls to the session 441 | taktil.registerControls(controls); 442 | 443 | // 5. register views to the session 444 | taktil.registerViews(views); 445 | 446 | // 6. on init, activate view to trigger initial render 447 | taktil.on('init', () => taktil.activateView('MIXER')); 448 | ``` 449 | -------------------------------------------------------------------------------- /bin/taktil.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const program = require('commander'); 3 | const uuid = require('uuid/v1'); 4 | const colors = require('colors/safe'); 5 | 6 | const init = require('./tasks/init'); 7 | const build = require('./tasks/build'); 8 | 9 | // taktil init command 10 | program 11 | .command('init [dirname]') 12 | .description('Initialize a new project with dirname (defaults to current directory).') 13 | .option('-t, --typescript', 'setup project to use TypeScript') 14 | .action((dirname, options) => init(dirname, options.typescript)); 15 | 16 | // taktil build command 17 | program 18 | .command('build [project_root]') 19 | .description('Build Taktil project located at project_root (defaults to current directory).') 20 | .option('-p, --production', "build using Webpack's production mode") 21 | .action((project_root, { production }) => build(project_root, { production })); 22 | 23 | // taktil watch command 24 | program 25 | .command('watch [project_root]') 26 | .description('Watches Taktil project located at project_root (defaults to current directory).') 27 | .option('-p, --production', "build using Webpack's production mode") 28 | .action((project_root, { production }) => build(project_root, { production, watch: true })); 29 | 30 | // taktil uuid generator 31 | program 32 | .command('uuid') 33 | .description('Generate a UUID') 34 | .action(() => console.log(colors.bold(colors.magenta(`[taktil]`)), colors.green(uuid()))); 35 | 36 | // parse args and run commands 37 | program.parse(process.argv); 38 | 39 | // no command? print help 40 | if (!process.argv.slice(2).length) program.outputHelp(); 41 | -------------------------------------------------------------------------------- /bin/tasks/build.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const colors = require('colors/safe'); 3 | 4 | const taskName = colors.bold(colors.magenta(`[taktil]`)); 5 | 6 | module.exports = (project_root = '.', { production = false, watch = false }) => { 7 | process.chdir(project_root); 8 | 9 | const webpackPath = path.join(process.cwd(), 'node_modules', 'webpack'); 10 | let webpack; 11 | try { 12 | webpack = require(webpackPath); 13 | } catch (e) { 14 | console.error( 15 | `\n${taskName} ${colors.bold( 16 | colors.red('ERROR: No local Webpack installation found.') 17 | )}\n${webpackPath}` 18 | ); 19 | console.error(colors.cyan('\n try "npm install webpack"\n')); 20 | return; 21 | } 22 | 23 | let config = require(path.join(process.cwd(), 'webpack.config.js')); 24 | 25 | const mode = production ? 'production' : 'development'; 26 | if (typeof config === 'function') config = config(undefined, { mode }); 27 | else config = { mode, ...config }; 28 | 29 | const compiler = webpack(config); 30 | 31 | if (compiler.hooks) { 32 | compiler.hooks.compile.tap('TaktilCLI', () => { 33 | console.log(taskName, 'building...'); 34 | console.time(`${taskName} complete`); 35 | }); 36 | } else { 37 | compiler.plugin('compile', () => { 38 | console.log(taskName, 'building...'); 39 | console.time(`${taskName} complete`); 40 | }); 41 | } 42 | 43 | function onBuild(err, stats) { 44 | if (err) console.error(err); 45 | console.log( 46 | stats.toString( 47 | config.stats || { 48 | colors: true, 49 | chunks: false, 50 | version: false, 51 | hash: false, 52 | timings: false, 53 | modules: false, 54 | } 55 | ) 56 | ); 57 | console.timeEnd(`${taskName} complete`); 58 | console.log(); // blank line between builds 59 | } 60 | 61 | if (!watch) { 62 | compiler.run(onBuild); 63 | } else { 64 | compiler.watch({}, onBuild); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /bin/tasks/init.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const change = require('gulp-change'); 3 | const nunjucks = require('nunjucks'); 4 | const path = require('path'); 5 | const rename = require('gulp-rename'); 6 | const uuid = require('uuid/v1'); 7 | const prompt = require('prompt-sync')({ sigint: true }); 8 | const os = require('os'); 9 | const fs = require('fs'); 10 | const colors = require('colors/safe'); 11 | const glob = require('glob').sync; 12 | const pkgjson = require('../../package.json'); 13 | 14 | const isWindows = os.platform() === 'win32'; 15 | const isMacOS = os.platform() === 'darwin'; 16 | const isLinux = !isWindows && !isMacOS; 17 | 18 | const scriptsDirs = { 19 | linux: path.join(os.homedir(), 'Bitwig Studio', 'Controller Scripts'), 20 | other: path.join(os.homedir(), 'Documents', 'Bitwig Studio', 'Controller Scripts'), 21 | }; 22 | 23 | const taskName = colors.bold(colors.magenta(`[taktil]`)); 24 | const scriptsDir = isLinux ? scriptsDirs.linux : scriptsDirs.other; 25 | 26 | const typescriptVersion = pkgjson.devDependencies['typescript']; 27 | const apiTypesVersion = pkgjson.devDependencies['typed-bitwig-api']; 28 | const defaultApiVersion = apiTypesVersion.split('.')[0].slice(1); 29 | 30 | function rprompt(question, defaultValue, test, badTestResponse) { 31 | test = test === undefined ? val => val.trim().length > 0 : test; 32 | badTestResponse = 33 | badTestResponse !== undefined ? badTestResponse : 'Invalid input, try again...'; 34 | 35 | let result; 36 | while (true) { 37 | result = prompt(question, defaultValue); 38 | if (test(result)) break; 39 | console.log(badTestResponse); 40 | } 41 | 42 | return result; 43 | } 44 | 45 | module.exports = (dirname = '.', typescript = false) => { 46 | console.log(`${taskName} begin project initialization...`); 47 | const templatesGlob = path.posix.join( 48 | path.dirname(require.main.filename), 49 | 'template', 50 | '**', 51 | '*' 52 | ); 53 | 54 | const scriptName = path.basename(path.resolve(dirname)).trim(); 55 | 56 | const name = rprompt(colors.bold(colors.blue('Display Name: ')), '').trim(); 57 | const vendor = rprompt(colors.bold(colors.blue('Vendor/Category: ')), '').trim(); 58 | const author = prompt(colors.bold(colors.blue('Author: ')), '').trim(); 59 | const version = prompt(colors.bold(colors.blue('Version (1.0.0): ')), '1.0.0').trim(); 60 | const apiVersion = rprompt( 61 | colors.bold(colors.blue(`API Version (${defaultApiVersion}): `)), 62 | `${defaultApiVersion}` 63 | ).trim(); 64 | 65 | const context = { 66 | typescript, 67 | taktilVersion: pkgjson.version, 68 | scriptName, 69 | name, 70 | vendor, 71 | version, 72 | uuid: uuid(), 73 | author, 74 | apiVersion, 75 | apiTypesVersion, 76 | typescriptVersion, 77 | }; 78 | 79 | const ignore = glob(templatesGlob) 80 | .filter(p => { 81 | return typescript 82 | ? p.endsWith('.js') && templatesGlob.indexOf(`${p.slice(0, -3)}${'.ts'}`) > -1 83 | : p.endsWith('.ts'); 84 | }) 85 | .map(p => `!${p.slice(0, -3)}${typescript ? '.js' : '.ts'}`); 86 | 87 | gulp 88 | .src([templatesGlob, `${templatesGlob.slice(0, -1)}.*`, ...ignore]) 89 | .pipe( 90 | change(function processTemplates(content) { 91 | return nunjucks.configure(this.file.base).renderString(content, context); 92 | }) 93 | ) 94 | .pipe( 95 | rename(filepath => { 96 | if (filepath.basename !== 'webpack.config') { 97 | filepath.extname = 98 | typescript && filepath.extname === '.js' ? '.ts' : filepath.extname; 99 | } 100 | // run filenames through template renderer 101 | filepath.basename = nunjucks.renderString(filepath.basename, context); 102 | }) 103 | ) 104 | .pipe(gulp.dest(dirname)) 105 | .on('end', () => { 106 | if (!isWindows) { 107 | // symlink on mac and linux 108 | // create build directory 109 | try { 110 | fs.mkdirSync(path.join(scriptsDir, scriptName)); 111 | } catch (e) { 112 | // already exists... pass 113 | } 114 | // symlink build dir to project root 115 | fs.symlinkSync(path.join(scriptsDir, scriptName), path.join(dirname, 'dist')); 116 | } 117 | 118 | console.log(`${taskName} project initialization complete.`); 119 | }); 120 | }; 121 | -------------------------------------------------------------------------------- /bin/template/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | ./dist 4 | -------------------------------------------------------------------------------- /bin/template/README.md: -------------------------------------------------------------------------------- 1 | # {{ name }} 2 | -------------------------------------------------------------------------------- /bin/template/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ scriptName }}", 3 | "description": "{{ description }}", 4 | "author": "{{ author }}", 5 | "dependencies": { 6 | "taktil": "^{{ taktilVersion }}", 7 | "tslib": "^1.9.0" 8 | }, 9 | "devDependencies": { 10 | "bitwig-webpack-plugin": "^1.1.1", 11 | "clean-webpack-plugin": "^0.1.19", 12 | "copy-webpack-plugin": "^4.5.1", 13 | "glob": "^7.1.2", 14 | "ts-loader": "^4.1.0", 15 | "typed-bitwig-api": "{{ apiTypesVersion }}", 16 | "typescript": "{{ typescriptVersion }}", 17 | "webpack": "^4.4.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bin/template/src/components.js: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | export class PlayToggle extends taktil.Button { 4 | onInit() { 5 | this.params.transport 6 | .isPlaying() 7 | .addValueObserver(isPlaying => this.setState({ on: isPlaying })); 8 | } 9 | 10 | onPress() { 11 | this.state.on ? this.params.transport.stop() : this.params.transport.play(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bin/template/src/components.ts: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | interface PlayToggleParams { 4 | transport: API.Transport; 5 | } 6 | 7 | type PlayToggleState = taktil.ButtonState; 8 | 9 | export class PlayToggle extends taktil.Button { 10 | onInit() { 11 | this.params.transport 12 | .isPlaying() 13 | .addValueObserver(isPlaying => this.setState({ on: isPlaying })); 14 | } 15 | 16 | onPress() { 17 | this.state.on ? this.params.transport.stop() : this.params.transport.play(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bin/template/src/controls.js: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | export const controls = { 4 | PLAY: new taktil.ControlChange({ channel: 0, control: 24 }), 5 | }; 6 | -------------------------------------------------------------------------------- /bin/template/src/daw.js: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | export const daw = {}; 4 | 5 | taktil.on('init', () => { 6 | daw.transport = host.createTransport(); 7 | // ...setup all of your "init time only" bitwig api stuff here 8 | }); 9 | -------------------------------------------------------------------------------- /bin/template/src/daw.ts: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | export interface Daw { 4 | transport: API.Transport; 5 | // ...define what you are going to keep track of 6 | } 7 | 8 | export const daw = {} as Daw; 9 | 10 | taktil.on('init', () => { 11 | daw.transport = host.createTransport(); 12 | // ...setup all of your "init time only" bitwig api stuff here 13 | }); 14 | -------------------------------------------------------------------------------- /bin/template/src/index.js: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | import { controls } from './controls'; 4 | import { views } from './views'; 5 | 6 | // 1. set bitwig api version 7 | host.loadAPI({{ apiVersion }}); 8 | 9 | // 2. define controller script 10 | host.defineController( 11 | '{{ vendor }}', // vendor 12 | '{{ name }}', // name 13 | '{{ version }}', // version 14 | '{{ uuid }}', // uuid 15 | '{{ author }}' // author 16 | ); 17 | 18 | // 3. setup and discover midi controllers 19 | host.defineMidiPorts(1, 1); // number of midi inputs, outputs 20 | // host.addDeviceNameBasedDiscoveryPair(['Input Name'], ['Output Name']); 21 | 22 | // 4. register controls to the session 23 | taktil.registerControls(controls); 24 | 25 | // 5. register views to the session 26 | taktil.registerViews(views); 27 | 28 | // 6. on init, activate view to trigger initial render 29 | taktil.on('init', () => taktil.activateView('BASE')); 30 | -------------------------------------------------------------------------------- /bin/template/src/views.js: -------------------------------------------------------------------------------- 1 | import taktil from 'taktil'; 2 | 3 | import { PlayToggle } from './components'; 4 | import { controls } from './controls'; 5 | import { daw } from './daw'; 6 | 7 | class BaseView extends taktil.View { 8 | playButton = new PlayToggle(controls.PLAY, { transport: daw.transport }); 9 | } 10 | 11 | export const views = { 12 | BASE: BaseView, 13 | }; 14 | -------------------------------------------------------------------------------- /bin/template/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "rootDir": "src", 6 | "baseUrl": "./src", 7 | "sourceMap": false, 8 | "moduleResolution": "node", 9 | "allowJs": true, 10 | "checkJs": false, 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es6", 15 | "dom.iterable", 16 | "scripthost" 17 | ], 18 | "typeRoots": [ 19 | "@types", 20 | "typed-bitwig-api" 21 | ] 22 | }, 23 | "compileOnSave": false, 24 | "include": [ 25 | "src/**/*.ts", 26 | "src/**/*.js" 27 | ], 28 | "exclude": [] 29 | } -------------------------------------------------------------------------------- /bin/template/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const glob = require('glob'); 4 | const webpack = require('webpack'); 5 | const BitwigWebpackPlugin = require('bitwig-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 8 | 9 | const tsconfig = require('./tsconfig.json'); 10 | 11 | // generate per source file cache groups for easier debugging in dev mode 12 | const rootDirRegex = new RegExp(`^${path.join(tsconfig.compilerOptions.rootDir)}`); 13 | const perFileCacheGroups = Array.from( 14 | new Set([].concat(...tsconfig.include.map(p => glob.sync(p)))) 15 | ).reduce((result, p) => { 16 | // strip file type, replace root source dir with desired project file out dir 17 | const cacheGroupName = p.slice(0, -3).replace(rootDirRegex, 'project-files'); 18 | result[cacheGroupName] = { 19 | name: cacheGroupName, 20 | test: RegExp(path.join(__dirname, p)), 21 | chunks: 'initial', 22 | enforce: true, 23 | }; 24 | return result; 25 | }, {}); 26 | 27 | // declare webpack config based on mode 28 | module.exports = (_, { mode }) => ({ 29 | mode, 30 | entry: { 31 | '{{ scriptName }}.control': './src/index.{% if typescript %}ts{% else %}js{% endif %}', 32 | }, 33 | output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' }, 34 | resolve: { 35 | // allow both TypeScript and JavaScript files 36 | extensions: ['.ts', '.js'], 37 | // allow non relative imports from project root 38 | modules: [tsconfig.compilerOptions.baseUrl, 'node_modules'], 39 | }, 40 | // setup ts-loader to handle ts and js files 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.[tj]s$/, 45 | loader: 'ts-loader', 46 | options: { compilerOptions: { checkJs: false } }, // don't have build process type check js files 47 | exclude: /node_modules/, 48 | }, 49 | ], 50 | }, 51 | plugins: [ 52 | new BitwigWebpackPlugin(), // enables synchronous code splitting 53 | new CleanWebpackPlugin(['dist/*'], { verbose: false }), // clean dist pre build 54 | new CopyWebpackPlugin([{ from: 'README.md' }]), // non JS things to copy 55 | ], 56 | optimization: { 57 | // separate webpack manifest and vendor libs from project code 58 | splitChunks: { 59 | cacheGroups: { 60 | // in dev mode output per source file cache groups 61 | ...(mode === 'development' ? perFileCacheGroups : {}), 62 | // separate node_modules code from webpack boilerplate and project code 63 | vendor: { 64 | name: 'vendor.bundle', 65 | test: /node_modules/, 66 | chunks: 'initial', 67 | enforce: true, 68 | }, 69 | }, 70 | }, 71 | // makes output easy to read for debugging 72 | concatenateModules: true, 73 | }, 74 | devtool: false, // sourcemaps not supported in Java's JS engine 75 | stats: { 76 | colors: true, 77 | chunks: false, 78 | version: false, 79 | hash: false, 80 | timings: false, 81 | modules: false, 82 | builtAt: false, 83 | entrypoints: false, 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const typescript = require('typescript'); 3 | const gts = require('gulp-typescript'); 4 | const merge = require('merge2'); 5 | const spawn = require('child_process').spawnSync; 6 | const del = require('del'); 7 | const deleteEmpty = require('delete-empty'); 8 | const glob = require('glob'); 9 | 10 | const test = process.argv.indexOf('--test') > -1; 11 | const tsProject = gts.createProject('tsconfig.build.json', { typescript }); 12 | 13 | // typescript 14 | gulp.task('ts', () => { 15 | const tsResult = tsProject.src().pipe(tsProject()); 16 | return merge([ 17 | tsResult.dts.pipe(gulp.dest('./lib')), 18 | tsResult.js.pipe(gulp.dest('./lib')), 19 | ]).on('finish', () => { 20 | if (test) gulp.start('test'); 21 | }); 22 | }); 23 | 24 | // test 25 | gulp.task('test', () => { 26 | spawn('npm', ['test'], { stdio: 'inherit' }); 27 | }); 28 | 29 | // gulp watch 30 | gulp.task('watch', ['clean', 'ts'], () => { 31 | gulp.watch(['src/**/*.ts'], ['ts']); 32 | }); 33 | 34 | // clean 35 | gulp.task('clean', function() { 36 | del.sync('./lib'); 37 | }); 38 | 39 | // default task 40 | gulp.task('default', ['clean', 'ts']); 41 | gulp.task('build', ['clean', 'ts']); 42 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as taktil from './lib'; 2 | export default taktil; 3 | export * from './lib'; 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import * as taktil from './lib'; 2 | export default taktil; 3 | export * from './lib'; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taktil", 3 | "description": "Taktil is a lightweight control surface scripting framework for Bitwig Studio that encourages rapid development and community code reuse.", 4 | "version": "0.13.0", 5 | "repository": "taktiljs/taktil", 6 | "author": "Joseph Larson", 7 | "tags": [ 8 | "bitwig", 9 | "control surface script", 10 | "framework", 11 | "typescript" 12 | ], 13 | "main": "index.js", 14 | "types": "index.d.ts", 15 | "bin": { 16 | "taktil": "./bin/taktil.js" 17 | }, 18 | "dependencies": { 19 | "colors": "^1.2.1", 20 | "commander": "^2.15.1", 21 | "glob": "^7.1.2", 22 | "gulp": "^3.9.1", 23 | "gulp-change": "^1.0.0", 24 | "gulp-rename": "^1.2.2", 25 | "nunjucks": "^3.1.2", 26 | "prompt-sync": "^4.1.5", 27 | "tslib": "^1.9.0", 28 | "uuid": "^3.2.1" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^22.2.2", 32 | "@types/node": "^9.6.1", 33 | "del": "^3.0.0", 34 | "delete-empty": "^2.0.0", 35 | "gulp-typescript": "^4.0.2", 36 | "jest": "^22.4.3", 37 | "merge2": "^1.2.1", 38 | "prettier": "^1.11.1", 39 | "ts-jest": "^22.4.2", 40 | "tslint": "^5.9.1", 41 | "tslint-config-airbnb": "^5.8.0", 42 | "tslint-config-prettier": "^1.10.0", 43 | "tsutils": "^2.25.0", 44 | "typed-bitwig-api": "^6.0.0", 45 | "typedoc": "^0.11.1", 46 | "typescript": "^2.8.1" 47 | }, 48 | "scripts": { 49 | "start": "gulp watch", 50 | "build": "gulp build", 51 | "clean": "gulp clean", 52 | "test": "jest", 53 | "check": "tsc --noEmit", 54 | "lint": "tslint -c tslint.json $(find . ! -path './.git*' ! -path './node_modules*' -path '*.ts') || true", 55 | "format": "prettier --parser typescript --print-width 100 --tab-width 4 --single-quote --trailing-comma=es5 --write $(find . ! -path './.git*' ! -path './node_modules*' -path '*.ts')", 56 | "typedoc": "typedoc --module commonjs --exclude '**/*.spec.ts' --out ./docs/typedoc/ src/taktil.ts", 57 | "preversion": "npm test && npm run build", 58 | "postversion": "npm run clean", 59 | "prepack": "npm test && npm run build", 60 | "postpack": "npm run clean" 61 | }, 62 | "engines": { 63 | "node": ">= 6.9.0" 64 | }, 65 | "license": "BSD-3-Clause", 66 | "bugs": { 67 | "url": "https://github.com/taktiljs/taktil/issues" 68 | }, 69 | "homepage": "https://github.com/taktiljs/taktil#readme", 70 | "jest": { 71 | "testEnvironment": "node", 72 | "transform": { 73 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 74 | }, 75 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(tsx?|jsx?)$", 76 | "moduleFileExtensions": [ 77 | "ts", 78 | "tsx", 79 | "js", 80 | "json", 81 | "jsx" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/component/Button.spec.ts: -------------------------------------------------------------------------------- 1 | import { Button } from './Button'; 2 | import { Control } from '../control'; 3 | 4 | class TestButton extends Button { 5 | onPress() {} 6 | onLongPress() {} 7 | onDoublePress() {} 8 | onRelease() {} 9 | onDoubleRelease() {} 10 | } 11 | 12 | describe('Button', () => { 13 | jest.useFakeTimers(); 14 | 15 | const control = new Control({ patterns: [{ status: 0xb0, data1: 21 }] }); 16 | const button = new TestButton(control, {}); 17 | 18 | const onPress = jest.spyOn(button, 'onPress'); 19 | const onDoublePress = jest.spyOn(button, 'onDoublePress'); 20 | const onRelease = jest.spyOn(button, 'onRelease'); 21 | const onDoubleRelease = jest.spyOn(button, 'onDoubleRelease'); 22 | const onLongPress = jest.spyOn(button, 'onLongPress'); 23 | 24 | it('correctly identifies press and release input', () => { 25 | expect(button.isPress(control.minValue)).toBe(false); 26 | expect(button.isPress(control.minValue + 1)).toBe(true); 27 | }); 28 | 29 | it('should handle single and double press/release events', () => { 30 | jest.runTimersToTime(350); // memory reset 31 | 32 | onPress.mockReset(); 33 | onDoublePress.mockReset(); 34 | onRelease.mockReset(); 35 | onDoubleRelease.mockReset(); 36 | 37 | // double press/release 38 | button.onControlInput({ value: control.maxValue }); 39 | jest.runTimersToTime(50); 40 | button.onControlInput({ value: control.minValue }); 41 | jest.runTimersToTime(100); 42 | button.onControlInput({ value: control.maxValue }); 43 | jest.runTimersToTime(50); 44 | button.onControlInput({ value: control.minValue }); 45 | jest.runTimersToTime(50); 46 | 47 | expect(onPress).toHaveBeenCalledTimes(1); 48 | expect(onDoublePress).toHaveBeenCalledTimes(1); 49 | expect(onRelease).toHaveBeenCalledTimes(1); 50 | expect(onDoubleRelease).toHaveBeenCalledTimes(1); 51 | }); 52 | 53 | it('should handle long press event', () => { 54 | jest.runTimersToTime(350); // memory reset 55 | 56 | onPress.mockReset(); 57 | onLongPress.mockReset(); 58 | 59 | // long press 60 | button.onControlInput({ value: control.maxValue }); 61 | jest.runTimersToTime(350); 62 | button.onControlInput({ value: control.minValue }); 63 | 64 | expect(onPress).toHaveBeenCalledTimes(1); 65 | expect(onLongPress).toHaveBeenCalledTimes(1); 66 | }); 67 | 68 | afterAll(() => { 69 | onPress.mockRestore(); 70 | onDoublePress.mockRestore(); 71 | onRelease.mockRestore(); 72 | onDoubleRelease.mockRestore(); 73 | onLongPress.mockRestore(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/component/Button.ts: -------------------------------------------------------------------------------- 1 | import { Component, ComponentState, ComponentParams } from './Component'; 2 | import { Control } from '../control'; 3 | import { ControlState } from '../control/Control'; 4 | 5 | export type ButtonParams = ComponentParams; 6 | export interface ButtonState extends ComponentState { 7 | on: boolean; 8 | color?: { r: number; g: number; b: number }; 9 | } 10 | 11 | /** 12 | * A button component providing method hooks for press, long press, 13 | * double press, release, and double release events. 14 | */ 15 | export abstract class Button< 16 | Params extends ButtonParams = ButtonParams, 17 | State extends ButtonState = ButtonState 18 | > extends Component { 19 | state: State = { on: false } as State; 20 | memory: { [key: string]: any } = {}; 21 | 22 | LONG_PRESS_DELAY = 350; 23 | DOUBLE_PRESS_DELAY = 350; 24 | 25 | onPress?(): void; 26 | onLongPress?(): void; 27 | onDoublePress?(): void; 28 | onRelease?(): void; 29 | onDoubleRelease?(): void; 30 | 31 | onControlInput(input: ControlState) { 32 | if (this.onPress) this.handlePress(input.value); 33 | if (this.onLongPress) this.handleLongPress(input.value); 34 | if (this.onDoublePress) this.handleDoublePress(input.value); 35 | if (this.onRelease) this.handleRelease(input.value); 36 | if (this.onDoubleRelease) this.handleDoubleRelease(input.value); 37 | } 38 | 39 | getControlOutput(): ControlState { 40 | const { on, color } = this.state; 41 | return { 42 | value: on ? this.control.maxValue : this.control.minValue, 43 | ...(color && { color }), 44 | }; 45 | } 46 | 47 | isPress(value: number) { 48 | return value > this.control.minValue; 49 | } 50 | 51 | isRelease(value: number) { 52 | return value === this.control.minValue; 53 | } 54 | 55 | isDoublePress(value: number) { 56 | return this.memory['doublePress'] && this.isPress(value); 57 | } 58 | 59 | isDoubleRelease(value: number) { 60 | return this.memory['doubleRelease'] && this.isRelease(value); 61 | } 62 | 63 | handlePress(value: number) { 64 | // if it's not a press or is a doublePress, ignore it 65 | if (!this.isPress(value) || this.memory['doublePress']) return; 66 | // handle single press 67 | if (this.onPress) this.onPress(); 68 | } 69 | 70 | handleDoublePress(value: number) { 71 | // if it's not a press or not implemented, ignore it 72 | if (!this.isPress(value)) return; 73 | 74 | // if is doublePress 75 | if (this.isDoublePress(value)) { 76 | if (this.onDoublePress) this.onDoublePress(); 77 | } else { 78 | // setup interval task to remove self after this.DOUBLE_PRESS_DELAY 79 | this.memory['doublePress'] = setTimeout(() => { 80 | delete this.memory['doublePress']; 81 | }, this.DOUBLE_PRESS_DELAY); 82 | } 83 | } 84 | 85 | handleLongPress(value: number) { 86 | // if it's a doublePress or is not implemented, ignore it 87 | if (this.isDoublePress(value)) return; 88 | 89 | // if it's a press schedule the callback 90 | if (this.isPress(value)) { 91 | // schedule long press callback 92 | this.memory['longPress'] = setTimeout(() => { 93 | if (this.onLongPress) this.onLongPress(); 94 | }, this.LONG_PRESS_DELAY); 95 | } else if (this.memory['longPress']) { 96 | // cancel scheduled long press callback if button released too early 97 | clearTimeout(this.memory['longPress']); 98 | delete this.memory['longPress']; 99 | } 100 | } 101 | 102 | handleRelease(value: number) { 103 | // if it's not a release, not implemented or is a doubleRelease, ignore it 104 | if (!this.isRelease(value) || this.memory['doubleRelease']) return; 105 | // handle single release 106 | if (this.onRelease) this.onRelease(); 107 | } 108 | 109 | handleDoubleRelease(value: number) { 110 | // if it's not a release or not implemented, ignore it 111 | if (!this.isRelease(value)) return; 112 | 113 | // if is doubleRelease 114 | if (this.isDoubleRelease(value) && this.onDoubleRelease) { 115 | this.onDoubleRelease(); 116 | } else { 117 | // setup timeout task to remove self after this.DOUBLE_PRESS_DELAY 118 | this.memory['doubleRelease'] = setTimeout(() => { 119 | delete this.memory['doubleRelease']; 120 | }, this.DOUBLE_PRESS_DELAY); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/component/Component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from '../control'; 2 | import { Component, ComponentState, ComponentParams } from './Component'; 3 | 4 | describe('Component', () => { 5 | const control = new Control({ patterns: [{ status: 0xb0, data1: 21 }], enableMidiOut: false }); 6 | 7 | type Params = ComponentParams; 8 | interface State extends ComponentState { 9 | value: number; 10 | foo: { bar: number }; 11 | } 12 | 13 | class TestComponent extends Component { 14 | state: State = { value: control.minValue, foo: { bar: 0 } }; 15 | 16 | getControlOutput(): ControlState { 17 | return { value: this.state.value }; 18 | } 19 | 20 | onControlInput({ value }: ControlState) { 21 | this.setState({ value }); 22 | } 23 | 24 | onActivate() {} 25 | 26 | onDeactivate() {} 27 | } 28 | 29 | const component = new TestComponent(control, { mode: 'MY_MODE' }); 30 | 31 | it('should initialize state correctly', () => { 32 | expect(component.state).toEqual({ value: control.minValue, foo: { bar: 0 } }); 33 | }); 34 | 35 | it('should modify state correctly', () => { 36 | component.setState({ value: control.maxValue }); // receives partial state 37 | expect(component.state).toEqual({ value: control.maxValue, foo: { bar: 0 } }); 38 | }); 39 | 40 | it('should set the control correctly', () => { 41 | expect(component.control).toBe(control); 42 | }); 43 | 44 | it('should set the mode correctly', () => { 45 | expect(component.params.mode).toBe('MY_MODE'); 46 | }); 47 | 48 | it('should call activate when component is activated', () => { 49 | const onActivate = jest.spyOn(component, 'onActivate'); 50 | const onDeactivate = jest.spyOn(component, 'onDeactivate'); 51 | expect(onActivate).toHaveBeenCalledTimes(0); 52 | expect(onDeactivate).toHaveBeenCalledTimes(0); 53 | control.activeComponent = component; 54 | control.activeComponent = null; 55 | expect(onActivate).toHaveBeenCalledTimes(1); 56 | expect(onDeactivate).toHaveBeenCalledTimes(1); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/component/Component.ts: -------------------------------------------------------------------------------- 1 | import { View } from '../view'; 2 | import { Control, ControlState } from '../control'; 3 | 4 | export interface ComponentState {} 5 | export interface ComponentParams {} 6 | 7 | /** 8 | * Abstract class defining the the base functionality from which all 9 | * other components must extend. 10 | */ 11 | export abstract class Component< 12 | Params extends ComponentParams = ComponentParams, 13 | State extends ComponentState = ComponentState 14 | > { 15 | label: string; 16 | control: Control; 17 | params: Params & { mode: string } = {} as Params & { mode: string }; 18 | state: State = {} as State; 19 | 20 | constructor(control: Control, params: Params & { mode?: string }) { 21 | this.control = control; 22 | this.params = { 23 | ...(this.params as object), 24 | ...(params as object), 25 | mode: params.mode || '__BASE__', 26 | } as Params & { mode: string }; 27 | } 28 | 29 | // called when component is registered to a view for the first time 30 | // allows running of code that is only allowed in the API's init function 31 | onInit?(): void; 32 | 33 | onActivate?(): void; 34 | 35 | onDeactivate?(): void; 36 | 37 | setState(partialState: Partial): void { 38 | // update object state 39 | this.state = { ...(this.state as object), ...(partialState as object) } as State; // TODO: should be able to remove type casting in future typescript release 40 | // re-render associated controls 41 | this.render(); 42 | } 43 | 44 | render(): void { 45 | // update hardware state if in view 46 | if (this.control.activeComponent === this) this.control.setState(this.getControlOutput()); 47 | } 48 | 49 | // defines conversion of component state to control state 50 | abstract getControlOutput(): Partial; 51 | 52 | // handles control input 53 | abstract onControlInput(input: ControlState): void; 54 | } 55 | -------------------------------------------------------------------------------- /src/component/Range.spec.ts: -------------------------------------------------------------------------------- 1 | import { Range } from './Range'; 2 | import { Control, ControlState } from '../control'; 3 | import { Session } from '../session'; 4 | 5 | class TestRange extends Range { 6 | onControlInput({ value, ...rest }: ControlState) { 7 | super.onControlInput({ value, ...rest }); 8 | this.setState({ value }); 9 | } 10 | } 11 | 12 | describe('Range', () => { 13 | const session = new Session(); 14 | const control = new Control({ patterns: [{ status: 0xb0, data1: 21 }] }); 15 | session.registerControls({ TEST: control }); 16 | const range = new TestRange(control, { mode: 'MY_MODE' }); 17 | 18 | // if active component is not set, getOutput will not be called 19 | control.activeComponent = range; 20 | 21 | jest.useFakeTimers(); 22 | const getOutput = jest.spyOn(range, 'getControlOutput'); 23 | 24 | it('should not render while receiving input', () => { 25 | range.onControlInput({ value: control.maxValue }); 26 | range.setState({ value: control.minValue }); 27 | jest.runTimersToTime(range.INPUT_DELAY); 28 | expect(getOutput).not.toHaveBeenCalled(); 29 | 30 | range.setState({ value: control.minValue }); 31 | expect(getOutput).toHaveBeenCalledTimes(1); 32 | }); 33 | 34 | afterAll(() => { 35 | getOutput.mockRestore(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/component/Range.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from '../control'; 2 | import { Component, ComponentState, ComponentParams } from './Component'; 3 | 4 | export type RangeParams = ComponentParams; 5 | export interface RangeState extends ComponentState { 6 | value: number; 7 | } 8 | 9 | export abstract class Range< 10 | Params extends RangeParams = RangeParams, 11 | State extends RangeState = RangeState 12 | > extends Component { 13 | INPUT_DELAY = 350; 14 | 15 | state: State = { value: 0 } as State; 16 | memory: { [key: string]: any } = {}; 17 | 18 | render() { 19 | // if there is active input, don't call render 20 | if (!this.memory.input) super.render(); 21 | } 22 | 23 | getControlOutput(): Partial { 24 | return { value: this.state.value }; 25 | } 26 | 27 | onControlInput(input: ControlState) { 28 | // on input, start/restart input countdown timer to create input buffer time 29 | clearTimeout(this.memory.input); 30 | this.memory.input = setTimeout(() => delete this.memory.input, this.INPUT_DELAY); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/component/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Component'; 2 | export * from './Range'; 3 | export * from './Button'; 4 | -------------------------------------------------------------------------------- /src/control/ChannelPressure.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from './Control'; 2 | import { MidiMessage } from '../midi'; 3 | 4 | export type ChannelPressureState = ControlState; 5 | export class ChannelPressure< 6 | State extends ChannelPressureState = ChannelPressureState 7 | > extends Control { 8 | enableMidiOut = false; 9 | status: number; 10 | 11 | constructor({ 12 | port = 0, 13 | channel, 14 | ...rest, 15 | }: { 16 | port?: number; 17 | channel: number; 18 | enableMidiOut?: boolean; 19 | enableCache?: boolean; 20 | cacheOnMidiIn?: boolean; 21 | }) { 22 | super({ patterns: [{ port, status: 0xd0 | channel }], ...rest }); 23 | } 24 | 25 | getControlInput({ data1 }: MidiMessage): State { 26 | return { ...this.state as ControlState, value: data1 } as State; // TODO: should be able to remove type casting in future typescript release 27 | } 28 | 29 | getMidiOutput({ value }: State): MidiMessage[] { 30 | const { port, status } = this; 31 | return [new MidiMessage({ port, status, data1: value, data2: 0 })]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/control/Control.spec.ts: -------------------------------------------------------------------------------- 1 | import { Control } from './Control'; 2 | import { MidiMessage, SysexMessage } from '../midi/'; 3 | import { Button } from '../component'; 4 | import { Session } from '../session'; 5 | 6 | type TestControlState = { value: number; nested: { value: number } }; 7 | 8 | class TestControl extends Control { 9 | state = { value: 127, nested: { value: 0 } }; 10 | } 11 | 12 | class TestComponent extends Button { 13 | state = { on: false }; 14 | } 15 | 16 | describe('Control', () => { 17 | const session = new Session(); 18 | const control = new TestControl({ patterns: ['00B01F??'] }); 19 | 20 | session.registerControls({ TEST: control }); 21 | 22 | const component = new TestComponent(control, {}); 23 | 24 | it('should initialize state correctly', () => { 25 | expect(control.state).toEqual({ value: 127, nested: { value: 0 } }); 26 | }); 27 | 28 | it('should modify state correctly', () => { 29 | control.setState({ nested: { value: 1 } }); // receives partial state 30 | expect(control.state).toEqual({ value: 127, nested: { value: 1 } }); 31 | }); 32 | 33 | it('should maintain its initial state', () => { 34 | expect(control.defaultState).toEqual({ 35 | value: 127, 36 | nested: { value: 0 }, 37 | }); 38 | }); 39 | 40 | it('should set active component correctly', () => { 41 | // should be initialized as null 42 | expect(control.activeComponent).toBe(null); 43 | control.activeComponent = component; 44 | expect(control.activeComponent).toBe(component); 45 | // state.value should have been changed to 0 because of initial 46 | // component state 47 | expect(control.state.value).toBe(0); 48 | }); 49 | 50 | it('should throw on invalid setState of state.value', () => { 51 | expect(() => control.setState({ value: 128 })).toThrow(); 52 | }); 53 | 54 | it('should cache controller hardware state', () => { 55 | control.setState({ value: 0 }); 56 | const spy = jest.spyOn(session.midiOut, 'sendMidi'); 57 | control.setState({ value: 127 }); 58 | control.setState({ value: 127 }); 59 | expect(spy).toHaveBeenCalledTimes(1); 60 | }); 61 | 62 | it('should skip render in certain situations', () => { 63 | control.enableMidiOut = false; 64 | expect(control.render()).toBe(false); 65 | 66 | control.enableMidiOut = true; 67 | expect(control.render()).toBe(true); 68 | }); 69 | 70 | it('should set shared port, status, data1, and data2 from patterns', () => { 71 | const { port, status, data1, data2 } = new TestControl({ 72 | patterns: ['00B41900', '00B41801'], 73 | }); 74 | expect({ port, status, data1, data2 }).toEqual({ port: 0, status: 0xb4 }); 75 | }); 76 | 77 | it('should generate correct input for simple control', () => { 78 | const control = new Control({ patterns: [{ status: 0xb0, data1: 21 }] }); 79 | const { status, data1 } = control as { status: number; data1: number }; 80 | expect( 81 | control.getControlInput(new MidiMessage({ status, data1, data2: control.maxValue })) 82 | ).toEqual({ 83 | value: control.maxValue, 84 | }); 85 | }); 86 | 87 | it('should generate correct output for simple control', () => { 88 | const control = new Control({ patterns: [{ status: 0xb0, data1: 21 }] }); 89 | const { status, data1, state: { value } } = control as { 90 | status: number; 91 | data1: number; 92 | state: { value: number }; 93 | }; 94 | expect(control.getMidiOutput(control.state)).toEqual([ 95 | new MidiMessage({ status, data1, data2: value }), 96 | ]); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/control/Control.ts: -------------------------------------------------------------------------------- 1 | import { MidiMessage, SimpleMidiMessage, SysexMessage, MessagePattern } from '../midi/'; 2 | import { Component } from '../component'; 3 | import { Color } from '../helpers'; 4 | import { Session } from '../session'; 5 | 6 | export interface ControlState { 7 | value: number; 8 | color?: Color; 9 | brightness?: number; 10 | flashing?: 11 | | boolean 12 | | { 13 | on: boolean; 14 | type?: 'sync' | 'free'; 15 | rate?: number; 16 | }; 17 | [key: string]: any; 18 | } 19 | 20 | type PatternInitializer = string | Partial | MessagePattern; 21 | 22 | /** 23 | * Abstract class defining the the base functionality from which all 24 | * other controls must extend. 25 | */ 26 | export class Control { 27 | label: string; 28 | session: Session; 29 | patterns: MessagePattern[]; 30 | 31 | port?: number; 32 | status?: number; 33 | data1?: number; 34 | data2?: number; 35 | 36 | minValue = 0; 37 | maxValue = 127; 38 | enableMidiOut = true; 39 | enableCache = true; 40 | cacheOnMidiIn = true; 41 | 42 | state: State = { value: 0 } as State; 43 | 44 | protected cache: string[] = []; 45 | private _defaultState: State; 46 | private _activeComponent: Component | null = null; 47 | 48 | constructor({ 49 | patterns, 50 | minValue, 51 | maxValue, 52 | enableMidiOut, 53 | enableCache, 54 | cacheOnMidiIn, 55 | }: { 56 | patterns: (string | Partial | MessagePattern)[]; 57 | minValue?: number; 58 | maxValue?: number; 59 | enableMidiOut?: boolean; 60 | enableCache?: boolean; 61 | cacheOnMidiIn?: boolean; 62 | }) { 63 | if (!patterns || patterns.length === 0) 64 | throw new Error(`Error, Control must specify at least one pattern.`); 65 | // set object properties 66 | this.patterns = patterns.map( 67 | pattern => (pattern instanceof MessagePattern ? pattern : new MessagePattern(pattern)) 68 | ); 69 | 70 | if (minValue !== undefined) this.minValue = minValue; 71 | if (maxValue !== undefined) this.maxValue = maxValue; 72 | if (enableMidiOut !== undefined) this.enableMidiOut = enableMidiOut; 73 | if (enableCache !== undefined) this.enableCache = enableCache; 74 | if (cacheOnMidiIn !== undefined) this.cacheOnMidiIn = cacheOnMidiIn; 75 | 76 | // pull out shared pattern info into port, status, data1, and data2 77 | const isShared = this.patterns.reduce( 78 | (result, p1, index) => { 79 | // if there's just one pattern, return all true 80 | if (index === 0) return result; 81 | const p2 = this.patterns[index - 1]; 82 | if (result.port && p1.port !== p2.port) result.port = false; 83 | if (result.status && p1.status !== p2.status) result.status = false; 84 | if (result.data1 && p1.data1 !== p2.data1) result.data1 = false; 85 | if (result.data2 && p1.data2 !== p2.data2) result.data2 = false; 86 | return result; 87 | }, 88 | { port: true, status: true, data1: true, data2: true } 89 | ); 90 | const [{ port, status, data1, data2 }] = this.patterns; 91 | if (isShared.port) this.port = port; 92 | if (isShared.status) this.status = status; 93 | if (isShared.data1) this.data1 = data1; 94 | if (isShared.data2) this.data2 = data2; 95 | } 96 | 97 | get valueRange() { 98 | return this.maxValue - this.minValue; 99 | } 100 | 101 | // state 102 | 103 | get defaultState(): State { 104 | // if not set by setState, store initialized state value 105 | if (!this._defaultState) this._defaultState = JSON.parse(JSON.stringify(this.state)); 106 | return this._defaultState; 107 | } 108 | 109 | setState(partialState: Partial, render = true): void { 110 | this.defaultState; // make sure defaultState has been initialized 111 | if (partialState.value) { 112 | // validate value input 113 | if (partialState.value > this.maxValue || partialState.value < this.minValue) { 114 | throw new Error( 115 | `Invalid value "${partialState.value}" for Control "${this 116 | .label}" with value range ${this.minValue} to ${this.maxValue}.` 117 | ); 118 | } 119 | } 120 | // update state 121 | this.state = { 122 | ...this.state as object, 123 | ...partialState as object, 124 | } as State; // TODO: should be able to remove type casting in future typescript release 125 | // re-render with new state 126 | if (render) this.render(); 127 | } 128 | 129 | // active component 130 | 131 | get activeComponent() { 132 | return this._activeComponent; 133 | } 134 | 135 | set activeComponent(component: Component | null) { 136 | // component not changing? do nothing 137 | if (component === this._activeComponent) return; 138 | // deactivate old component 139 | if (this._activeComponent && this._activeComponent.onDeactivate) { 140 | this._activeComponent.onDeactivate(); 141 | } 142 | 143 | // activate new component 144 | this._activeComponent = component; 145 | if (this._activeComponent && this._activeComponent.onActivate) { 146 | this._activeComponent.onActivate(); 147 | } 148 | 149 | // on component change, reset state to default 150 | this.setState(this.defaultState, false); 151 | 152 | // render new control state 153 | component ? component.render() : this.render(); 154 | } 155 | 156 | // midi i/o 157 | 158 | getControlInput(message: MidiMessage | SysexMessage): State { 159 | if ( 160 | message instanceof MidiMessage && 161 | message.status === this.status && 162 | message.data1 === this.data1 163 | ) { 164 | return { ...this.state as ControlState, value: message.data2 } as State; // TODO: should be able to remove type casting in future typescript release 165 | } else { 166 | return this.state; 167 | } 168 | } 169 | 170 | cacheMidiMessage(midiMessage: MidiMessage): boolean { 171 | if (this.cache.indexOf(midiMessage.hex) !== -1) return false; 172 | for (let i = 0; i < this.patterns.length; i += 1) { 173 | const pattern = this.patterns[i]; 174 | if (pattern.test(midiMessage)) { 175 | this.cache[i] = midiMessage.hex; 176 | return true; 177 | } 178 | } 179 | // no match 180 | throw new Error( 181 | `MidiMessage "${midiMessage.hex}" does not match existing pattern on Control "${this 182 | .label}".` 183 | ); 184 | } 185 | 186 | onMidiInput(message: MidiMessage | SysexMessage) { 187 | // update cache with input 188 | if (this.cacheOnMidiIn && message instanceof MidiMessage) this.cacheMidiMessage(message); 189 | 190 | if (this.activeComponent) { 191 | this.activeComponent.onControlInput(this.getControlInput(message)); 192 | this.render(); // make sure hardware reflects control state 193 | } else { 194 | // re-render based on current state (messages will only be sent if they are 195 | // different than what's in the cache) 196 | this.render(); 197 | console.info(`Control "${this.label}" is not mapped in the active view stack.`); 198 | } 199 | } 200 | 201 | getMidiOutput(state: State): (MidiMessage | SysexMessage)[] { 202 | const { port, status, data1, data2 } = this; 203 | if (port !== undefined && status !== undefined && data1 !== undefined) { 204 | // if it's a simple midi control (port,status, and data1 provided), handle it 205 | return !data2 ? [new MidiMessage({ port, status, data1, data2: state.value })] : []; 206 | } else { 207 | // otherwise leave it up to the implementation 208 | return []; 209 | } 210 | } 211 | 212 | // render 213 | 214 | controlWillRender?(messages: (MidiMessage | SysexMessage)[]): void; 215 | 216 | render(force = !this.enableCache): boolean { 217 | // no midi out? no render. 218 | if (!this.enableMidiOut) return false; 219 | 220 | // get list of messages that will be sent 221 | const messages = this.getMidiOutput(this.state).filter(message => { 222 | if (message instanceof MidiMessage) { 223 | // send midi message to cache, add to message list if new 224 | return this.cacheMidiMessage(message) || force; 225 | } else { 226 | // sysex messages are not cached, always send 227 | return true; 228 | } 229 | }); 230 | 231 | // call pre render hook only if something will be rendered 232 | if (messages.length && this.controlWillRender) this.controlWillRender(messages); 233 | 234 | for (const message of messages) { 235 | if (message instanceof MidiMessage) { 236 | // send midi message 237 | const { port, status, data1, data2, urgent } = message; 238 | this.session.midiOut.sendMidi({ 239 | port, 240 | status, 241 | data1, 242 | data2, 243 | urgent, 244 | label: this.label, 245 | }); 246 | } else { 247 | // send sysex message 248 | const { port, data } = message; 249 | this.session.midiOut.sendSysex({ port, data }); 250 | } 251 | } 252 | 253 | // call post render hook 254 | if (this.controlDidRender) this.controlDidRender(messages); 255 | return true; 256 | } 257 | 258 | controlDidRender?(messages: (MidiMessage | SysexMessage)[]): void; 259 | } 260 | -------------------------------------------------------------------------------- /src/control/ControlChange.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from './Control'; 2 | import { MidiMessage } from '../midi'; 3 | 4 | export type ControlChangeState = ControlState; 5 | export class ControlChange extends Control< 6 | State 7 | > { 8 | status: number; 9 | data1: number; 10 | 11 | constructor({ 12 | port = 0, 13 | channel, 14 | control, 15 | ...rest, 16 | }: { 17 | port?: number; 18 | channel: number; 19 | control: number; 20 | enableMidiOut?: boolean; 21 | enableCache?: boolean; 22 | cacheOnMidiIn?: boolean; 23 | }) { 24 | super({ patterns: [{ port, status: 0xb0 | channel, data1: control }], ...rest }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/control/KeyPressure.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from './Control'; 2 | import { MidiMessage } from '../midi'; 3 | 4 | export type KeyPressureState = ControlState; 5 | export class KeyPressure extends Control { 6 | status: number; 7 | data1: number; 8 | 9 | constructor({ 10 | port = 0, 11 | channel, 12 | key, 13 | enableMidiOut = false, 14 | ...rest, 15 | }: { 16 | port?: number; 17 | channel: number; 18 | key: number; 19 | enableMidiOut?: boolean; 20 | enableCache?: boolean; 21 | cacheOnMidiIn?: boolean; 22 | }) { 23 | const patterns = [{ port, status: 0xa0 | channel, data1: key }]; 24 | super({ patterns, enableMidiOut, ...rest }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/control/Note.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from './Control'; 2 | import { MidiMessage } from '../midi'; 3 | 4 | export type NoteState = ControlState; 5 | 6 | export class Note extends Control { 7 | channel: number; 8 | data1: number; 9 | useNoteOff: boolean = false; 10 | 11 | constructor({ 12 | port = 0, 13 | channel, 14 | key, 15 | ...rest, 16 | }: { 17 | port?: number; 18 | channel: number; 19 | key: number; 20 | enableMidiOut?: boolean; 21 | enableCache?: boolean; 22 | cacheOnMidiIn?: boolean; 23 | }) { 24 | super({ 25 | patterns: [ 26 | { port, status: 0x90 | channel, data1: key }, 27 | { port, status: 0x80 | channel, data1: key }, 28 | ], 29 | ...rest, 30 | }); 31 | this.channel = channel; 32 | } 33 | 34 | getControlInput(message: MidiMessage): State { 35 | if (message.isNoteOff && !this.useNoteOff) this.useNoteOff = true; 36 | return { 37 | ...this.state as ControlState, 38 | value: message.isNoteOn ? message.data2 : 0, 39 | } as State; // TODO: should be able to remove type casting in future typescript release 40 | } 41 | 42 | getMidiOutput({ value }: State): MidiMessage[] { 43 | const { port, channel, data1 } = this; 44 | if (this.useNoteOff && value === 0) { 45 | return [new MidiMessage({ port, data1, status: 0x80 | channel, data2: value })]; 46 | } else { 47 | return [new MidiMessage({ port, data1, status: 0x90 | channel, data2: value })]; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/control/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChannelPressure'; 2 | export * from './Control'; 3 | export * from './ControlChange'; 4 | export * from './KeyPressure'; 5 | export * from './Note'; 6 | -------------------------------------------------------------------------------- /src/env/DelayedTask.ts: -------------------------------------------------------------------------------- 1 | export class DelayedTask { 2 | callback: Function; 3 | delay: number; 4 | repeat: boolean; 5 | cancelled = false; 6 | 7 | constructor(callback: (...args: any[]) => any, delay = 0, repeat = false) { 8 | this.callback = callback; 9 | this.delay = delay; 10 | this.repeat = repeat; 11 | } 12 | 13 | start(...args: any[]) { 14 | host.scheduleTask(() => { 15 | if (!this.cancelled) { 16 | this.callback.call(args); 17 | if (this.repeat) this.start(...args); 18 | } 19 | }, this.delay); 20 | return this; 21 | } 22 | 23 | cancel() { 24 | this.cancelled = true; 25 | return this; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/env/Logger.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '../session'; 2 | 3 | export type Level = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'; 4 | export type MidiLevel = 'Input' | 'Output' | 'Both' | 'None'; 5 | 6 | /** 7 | * Simple logger implementation including integration with the Bitwig 8 | * API's preferences system for setting log level, log filtering via 9 | * regular expressions, and Midi I/O filtering. 10 | */ 11 | export class Logger { 12 | private _levels = ['ERROR', 'WARN', 'INFO', 'DEBUG']; 13 | private _level: Level; 14 | private _midiLevel: MidiLevel; 15 | private _levelSetting: API.SettableEnumValue; 16 | private _filter: string; 17 | private _filterSetting: API.SettableEnumValue; 18 | private _initQueue: [Level | null, any[]][] = []; 19 | private _flushed = false; 20 | 21 | constructor(session: Session) { 22 | session.on('init', () => { 23 | host 24 | .getPreferences() 25 | .getEnumSetting( 26 | 'Log Midi', 27 | 'Development', 28 | ['None', 'Input', 'Output', 'Both'], 29 | 'None' 30 | ) 31 | .addValueObserver(midiLevel => { 32 | this._midiLevel = midiLevel as MidiLevel; 33 | if (this._ready && !this._flushed) this._flushQueue(); 34 | }); 35 | 36 | this._levelSetting = host 37 | .getPreferences() 38 | .getEnumSetting('Log Level', 'Development', this._levels, 'ERROR'); 39 | 40 | this._levelSetting.addValueObserver(level => { 41 | this._level = level as Level; 42 | if (this._ready && !this._flushed) this._flushQueue(); 43 | }); 44 | 45 | this._filterSetting = host 46 | .getPreferences() 47 | .getStringSetting('Log filter (Regex)', 'Development', 1000, ''); 48 | this._filterSetting.addValueObserver(value => { 49 | this._filter = value; 50 | if (this._filter) { 51 | const message = ` Log filter regex set to "${value}"`; 52 | this.log(`╭───┬${'─'.repeat(message.length)}╮`); 53 | this.log(`│ i │${message}` + '│'); // prettier-ignore 54 | this.log(`╰───┴${'─'.repeat(message.length)}╯`); 55 | } 56 | if (this._ready && !this._flushed) this._flushQueue(); 57 | }); 58 | }); 59 | } 60 | 61 | private get _ready() { 62 | return ( 63 | this._filter !== undefined && this._level !== undefined && this._midiLevel !== undefined 64 | ); 65 | } 66 | 67 | set level(level: Level) { 68 | if (this._levelSetting !== undefined) { 69 | this._levelSetting.set(level); 70 | } else { 71 | this._level = level; 72 | } 73 | } 74 | 75 | get level() { 76 | return this._level; 77 | } 78 | 79 | set filter(value) { 80 | if (this._filterSetting !== undefined) { 81 | this._filterSetting.set(value); 82 | } else { 83 | this._filter = value; 84 | } 85 | } 86 | 87 | get filter() { 88 | return this._filter; 89 | } 90 | 91 | log(...messages: any[]) { 92 | this._log(null, ...messages); 93 | } 94 | 95 | error(...messages: any[]) { 96 | this._log('ERROR', ...messages); 97 | } 98 | 99 | warn(...messages: any[]) { 100 | this._log('WARN', ...messages); 101 | } 102 | 103 | info(...messages: any[]) { 104 | this._log('INFO', ...messages); 105 | } 106 | 107 | debug(...messages: any[]) { 108 | this._log('DEBUG', ...messages); 109 | } 110 | 111 | private _log(level: Level | null, ...messages: any[]) { 112 | if (!this._ready) { 113 | this._initQueue.push([level, messages]); 114 | return; 115 | } 116 | 117 | if (level && this._levels.indexOf(level) > this._levels.indexOf(this._level)) return; 118 | 119 | const message = `${level ? `[${level.toUpperCase()}] ` : ''}${messages.join(' ')}`; 120 | if (level && this._filter) { 121 | const re = new RegExp(this._filter, 'gi'); 122 | if (!re.test(message)) return; 123 | } 124 | 125 | const isMidiInput = new RegExp('^\\[(MIDI|SYSEX)\\] ? IN', 'gi').test(message); 126 | const isMidiOutput = new RegExp('^\\[(MIDI|SYSEX)\\] ? OUT', 'gi').test(message); 127 | 128 | if (this._midiLevel === 'None' && (isMidiInput || isMidiOutput)) return; 129 | if (this._midiLevel === 'Input' && isMidiOutput) return; 130 | if (this._midiLevel === 'Output' && isMidiInput) return; 131 | 132 | level === 'ERROR' ? host.errorln(message) : host.println(message); 133 | } 134 | 135 | private _flushQueue() { 136 | while (this._initQueue.length > 0) { 137 | const [level, messages] = this._initQueue.shift() as [Level | null, any[]]; 138 | this._log(level, ...messages); 139 | } 140 | this._flushed = true; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/env/index.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './Logger'; 2 | import { DelayedTask } from './DelayedTask'; 3 | import { Session } from '../session'; 4 | 5 | // setup global env 6 | export const shim = (session: Session) => { 7 | const global = Function('return this')() || (42, eval)('this'); 8 | if (!global.host) return; 9 | 10 | // specific env setup for bitwig environment 11 | // shim Timeout and Interval methods using DelayedTask class 12 | global.setTimeout = function setTimeout( 13 | callback: (...args: any[]) => any, 14 | delay = 0, 15 | ...params: any[] 16 | ) { 17 | return new DelayedTask(callback, delay).start(...params); 18 | }; 19 | 20 | global.clearTimeout = function clearTimeout(timeout: DelayedTask) { 21 | if (timeout) timeout.cancel(); 22 | }; 23 | 24 | global.setInterval = function setInterval( 25 | callback: (...args: any[]) => any, 26 | delay = 0, 27 | ...params: any[] 28 | ) { 29 | return new DelayedTask(callback, delay, true).start(...params); 30 | }; 31 | 32 | global.clearInterval = function clearInterval(interval: DelayedTask) { 33 | if (interval) interval.cancel(); 34 | }; 35 | 36 | // shim console with custom logger 37 | global.console = new Logger(session); 38 | 39 | // hookup dummy function to unsupported logger methods 40 | 41 | // Console-polyfill. MIT license. 42 | // https://github.com/paulmillr/console-polyfill 43 | // Make it safe to do console.log() always. 44 | const con = global.console; 45 | let prop; 46 | let method; 47 | 48 | const dummy = function() {}; 49 | const properties = ['memory']; 50 | const methods = [ 51 | 'assert', 52 | 'clear', 53 | 'count', 54 | 'debug', 55 | 'dir', 56 | 'dirxml', 57 | 'error', 58 | 'exception', 59 | 'group', 60 | 'groupCollapsed', 61 | 'groupEnd', 62 | 'info', 63 | 'log', 64 | 'markTimeline', 65 | 'profile', 66 | 'profiles', 67 | 'profileEnd', 68 | 'show', 69 | 'table', 70 | 'time', 71 | 'timeEnd', 72 | 'timeline', 73 | 'timelineEnd', 74 | 'timeStamp', 75 | 'trace', 76 | 'warn', 77 | ]; 78 | while ((prop = properties.pop())) if (!con[prop]) con[prop] = {}; 79 | while ((method = methods.pop())) if (typeof con[method] !== 'function') con[method] = dummy; 80 | }; 81 | -------------------------------------------------------------------------------- /src/helpers/Color.ts: -------------------------------------------------------------------------------- 1 | export interface Color { 2 | r: number; 3 | g: number; 4 | b: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Color'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Control, ControlState } from './control'; 2 | import { View } from './view'; 3 | import { Session } from './session'; 4 | 5 | // create global session instance 6 | export const session = new Session(); 7 | 8 | // Controls 9 | //////////////////////// 10 | 11 | /** 12 | * Register controls to the session (can only be called once). 13 | * 14 | * @param controls The mapping of control labels to control instances to register 15 | * to the session. 16 | */ 17 | export function registerControls(controls: { [label: string]: Control }) { 18 | session.registerControls(controls); 19 | } 20 | 21 | /** 22 | * Get the mapping of control labels to control instances that have 23 | * been registered to the session. 24 | */ 25 | export function getControls() { 26 | return session.controls; 27 | } 28 | 29 | /** Force re-render all registered controls. */ 30 | export function resetControls() { 31 | return session.resetControls(); 32 | } 33 | 34 | // Views 35 | //////////////////////// 36 | 37 | /** 38 | * Register views to the session (can only be called once). 39 | * 40 | * @param views The mapping of view labels to view classes to register 41 | * to the session. 42 | */ 43 | export function registerViews(views: { [label: string]: typeof View }) { 44 | return session.registerViews(views); 45 | } 46 | 47 | /** 48 | * Get the mapping of view labels to view classes that have been 49 | * registered to the session. 50 | */ 51 | export function getViews() { 52 | return session.views; 53 | } 54 | 55 | /** Get the active view of the session. */ 56 | export function getActiveView() { 57 | return session.activeView; 58 | } 59 | 60 | /** Set the active view of the session. */ 61 | export function activateView(label: string) { 62 | return session.activateView(label); 63 | } 64 | 65 | // Modes 66 | //////////////////////// 67 | 68 | /** 69 | * Get the list of active modes in the order they were activated, 70 | * from last to first. 71 | */ 72 | export function getActiveModes() { 73 | return session.activeModes; 74 | } 75 | 76 | /** Activate a mode, adding it to the active mode list. */ 77 | export function activateMode(mode: string) { 78 | return session.activateMode(mode); 79 | } 80 | 81 | /** Deactivate a given mode, removing it from the active mode list. */ 82 | export function deactivateMode(mode: string) { 83 | return session.deactivateMode(mode); 84 | } 85 | 86 | /** Check if a given mode is active. */ 87 | export function modeIsActive(mode: string) { 88 | return session.modeIsActive(mode); 89 | } 90 | 91 | // Events 92 | //////////////////////// 93 | 94 | export function on( 95 | label: 'activateMode' | 'deactivateMode', 96 | callback: (mode: string) => void 97 | ): void; 98 | export function on(label: 'activateView', callback: (view: typeof View) => void): void; 99 | export function on( 100 | label: 'init' | 'registerControls' | 'registerViews' | 'flush' | 'exit', 101 | callback: () => void 102 | ): void; 103 | export function on(label: any, callback: (...args: any[]) => any) { 104 | session.on(label, callback); 105 | } 106 | 107 | // Core 108 | //////////////////////// 109 | 110 | export * from './component'; 111 | export * from './control'; 112 | export * from './helpers'; 113 | export * from './midi'; 114 | export * from './view'; 115 | -------------------------------------------------------------------------------- /src/midi/MessagePattern.spec.ts: -------------------------------------------------------------------------------- 1 | import { MessagePattern } from './MessagePattern'; 2 | import { SimpleMidiMessage } from './MidiMessage'; 3 | 4 | describe('MessagePattern', () => { 5 | it('should convert to and from pattern string and midi message.', () => { 6 | const msg = { port: 0, status: 0xb4, data1: 0x19 }; 7 | const string = '00B419??'; 8 | expect(MessagePattern.getPatternStringFromMidiMessage(msg)).toBe(string); 9 | expect(MessagePattern.getMidiMessageFromPatternString(string)).toEqual(msg); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/midi/MessagePattern.ts: -------------------------------------------------------------------------------- 1 | import { MidiMessage, SimpleMidiMessage } from './MidiMessage'; 2 | import { SysexMessage } from './SysexMessage'; 3 | 4 | export class MessagePattern { 5 | static getPatternStringFromMidiMessage({ 6 | port, 7 | status, 8 | data1, 9 | data2, 10 | }: Partial) { 11 | return [port, status, data1, data2] 12 | .map(midiByte => { 13 | if (midiByte === undefined) return '??'; 14 | let hexByteString = midiByte.toString(16).toUpperCase(); 15 | if (hexByteString.length === 1) hexByteString = `0${hexByteString}`; 16 | return hexByteString; 17 | }) 18 | .join(''); 19 | } 20 | 21 | static getMidiMessageFromPatternString(pattern: string): Partial { 22 | const string = pattern.length === 6 ? `??${pattern}` : pattern; 23 | const [port, status, data1, data2] = (string.match(/.{1,2}/g) as string[]).map( 24 | byte => (byte.indexOf('?') > -1 ? undefined : parseInt(byte, 16)) 25 | ); 26 | return { port, status, data1, data2 }; 27 | } 28 | 29 | port?: number; 30 | status?: number; 31 | data1?: number; 32 | data2?: number; 33 | 34 | string: string; 35 | regex: RegExp; 36 | 37 | constructor(input: string | Partial) { 38 | let string: string; 39 | if (typeof input === 'string') { 40 | // handel string representation as input (e.g. '00B419??') 41 | string = input.length === 6 ? `00${input}` : input; 42 | if (!/[a-fA-F0-9?]{8,}/.test(string)) 43 | throw new Error(`Invalid message pattern: "${input}"`); 44 | string = string.toUpperCase(); 45 | const message = MessagePattern.getMidiMessageFromPatternString(string); 46 | this.port = message.port; 47 | this.status = message.status; 48 | this.data1 = message.data1; 49 | this.data2 = message.data2; 50 | } else { 51 | // handle Partial as input 52 | string = MessagePattern.getPatternStringFromMidiMessage(input); 53 | this.port = input.port || 0; 54 | this.status = input.status; 55 | this.data1 = input.data1; 56 | this.data2 = input.data2; 57 | } 58 | this.string = string; 59 | this.regex = new RegExp(`^${string.slice().replace(/\?/g, '.')}$`); 60 | } 61 | 62 | toString() { 63 | return this.string; 64 | } 65 | 66 | conflictsWith(pattern: MessagePattern) { 67 | return this.regex.test(pattern.toString()) || pattern.regex.test(this.toString()); 68 | } 69 | 70 | test(message: MidiMessage | SysexMessage) { 71 | const testString: string = 72 | message instanceof SysexMessage 73 | ? message.data.toUpperCase() 74 | : MessagePattern.getPatternStringFromMidiMessage(message); 75 | return this.regex.test(testString); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/midi/MidiMessage.ts: -------------------------------------------------------------------------------- 1 | export interface SimpleMidiMessage { 2 | port: number; 3 | status: number; 4 | data1: number; 5 | data2: number; 6 | } 7 | 8 | export interface MidiMessageConstructor { 9 | port?: number; 10 | status: number; 11 | data1: number; 12 | data2: number; 13 | urgent?: boolean; 14 | } 15 | 16 | export class MidiMessage implements SimpleMidiMessage { 17 | port: number; 18 | status: number; 19 | data1: number; 20 | data2: number; 21 | urgent: boolean; 22 | hex: string; 23 | 24 | constructor({ port = 0, status, data1, data2, urgent = false }: MidiMessageConstructor) { 25 | this.port = port; 26 | this.status = status; 27 | this.data1 = data1; 28 | this.data2 = data2; 29 | this.urgent = urgent; 30 | this.hex = [port, status, data1, data2] 31 | .map(midiByte => { 32 | let hexByteString = midiByte.toString(16).toUpperCase(); 33 | if (hexByteString.length === 1) hexByteString = `0${hexByteString}`; 34 | return hexByteString; 35 | }) 36 | .join(''); 37 | } 38 | 39 | get shortHex() { 40 | return this.hex.slice(2); 41 | } 42 | 43 | get channel() { 44 | return this.status & 0xf; 45 | } 46 | 47 | get pitchBendValue() { 48 | return (this.data2 << 7) | this.data1; 49 | } 50 | 51 | get isNote() { 52 | return (this.status & 0xf0) === 0x80 || (this.status & 0xf0) === 0x90; 53 | } 54 | 55 | get isNoteOff() { 56 | return (this.status & 0xf0) === 0x80 || ((this.status & 0xf0) === 0x90 && this.data2 === 0); 57 | } 58 | 59 | get isNoteOn() { 60 | return (this.status & 0xf0) === 0x90; 61 | } 62 | 63 | get isKeyPressure() { 64 | return (this.status & 0xf0) === 0xa0; 65 | } 66 | 67 | get isControlChange() { 68 | return (this.status & 0xf0) === 0xb0; 69 | } 70 | 71 | get isProgramChange() { 72 | return (this.status & 0xf0) === 0xc0; 73 | } 74 | 75 | get isChannelPressure() { 76 | return (this.status & 0xf0) === 0xd0; 77 | } 78 | 79 | get isPitchBend() { 80 | return (this.status & 0xf0) === 0xe0; 81 | } 82 | 83 | get isMTCQuarterFrame() { 84 | return this.status === 0xf1; 85 | } 86 | 87 | get isSongPositionPointer() { 88 | return this.status === 0xf2; 89 | } 90 | 91 | get isSongSelect() { 92 | return this.status === 0xf3; 93 | } 94 | 95 | get isTuneRequest() { 96 | return this.status === 0xf6; 97 | } 98 | 99 | get isTimingClock() { 100 | return this.status === 0xf8; 101 | } 102 | 103 | get isMIDIStart() { 104 | return this.status === 0xfa; 105 | } 106 | 107 | get isMIDIContinue() { 108 | return this.status === 0xfb; 109 | } 110 | 111 | get isMidiStop() { 112 | return this.status === 0xfc; 113 | } 114 | 115 | get isActiveSensing() { 116 | return this.status === 0xfe; 117 | } 118 | 119 | get isSystemReset() { 120 | return this.status === 0xff; 121 | } 122 | 123 | toString() { 124 | return this.hex; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/midi/MidiOutProxy.ts: -------------------------------------------------------------------------------- 1 | import { MidiMessage, SimpleMidiMessage } from './MidiMessage'; 2 | import { SysexMessage } from './SysexMessage'; 3 | import { Session } from '../session'; 4 | 5 | export interface NaiveMidiMessage extends SimpleMidiMessage { 6 | label?: string; 7 | port: number; 8 | } 9 | 10 | export interface NaiveSysexMessage { 11 | label?: string; 12 | port: number; 13 | data: string; 14 | } 15 | 16 | export class MidiOutProxy { 17 | private _midiQueue: NaiveMidiMessage[] = []; 18 | private _sysexQueue: NaiveSysexMessage[] = []; 19 | 20 | constructor(session: Session) { 21 | session.on('flush', () => this._flushQueues()); 22 | } 23 | 24 | sendMidi({ 25 | label, 26 | port = 0, 27 | status, 28 | data1, 29 | data2, 30 | urgent = false, 31 | }: { 32 | label?: string; 33 | port?: number; 34 | status: number; 35 | data1: number; 36 | data2: number; 37 | urgent?: boolean; 38 | }) { 39 | // if urgent, fire midi message immediately, otherwise queue it up for next flush 40 | if (urgent) { 41 | console.log( 42 | `[MIDI] OUT ${port} <== ${new MidiMessage({ status, data1, data2 }) 43 | .shortHex}${label ? ` "${label}"` : ''}` 44 | ); 45 | host.getMidiOutPort(port).sendMidi(status, data1, data2); 46 | } else { 47 | this._midiQueue.push({ label, port, status, data1, data2 }); 48 | } 49 | } 50 | 51 | sendSysex({ 52 | label, 53 | port = 0, 54 | data, 55 | urgent = false, 56 | }: { 57 | label?: string; 58 | port?: number; 59 | data: string; 60 | urgent?: boolean; 61 | }) { 62 | // if urgent, fire sysex immediately, otherwise queue it up for next flush 63 | if (urgent) { 64 | console.log(`[SYSEX] OUT ${port} <== ${data}${label ? ` "${label}"` : ''}`); 65 | host.getMidiOutPort(port).sendSysex(data); 66 | } else { 67 | this._sysexQueue.push({ label, port, data }); 68 | } 69 | } 70 | 71 | sendNoteOn({ 72 | port = 0, 73 | channel, 74 | key, 75 | velocity, 76 | urgent = false, 77 | }: { 78 | port?: number; 79 | channel: number; 80 | key: number; 81 | velocity: number; 82 | urgent?: boolean; 83 | }) { 84 | this.sendMidi({ urgent, port, status: 0x90 | channel, data1: key, data2: velocity }); 85 | } 86 | 87 | sendNoteOff({ 88 | port = 0, 89 | channel, 90 | key, 91 | velocity, 92 | urgent = false, 93 | }: { 94 | port?: number; 95 | channel: number; 96 | key: number; 97 | velocity: number; 98 | urgent?: boolean; 99 | }) { 100 | this.sendMidi({ urgent, port, status: 0x80 | channel, data1: key, data2: velocity }); 101 | } 102 | 103 | sendKeyPressure({ 104 | port = 0, 105 | channel, 106 | key, 107 | pressure, 108 | urgent = false, 109 | }: { 110 | port?: number; 111 | channel: number; 112 | key: number; 113 | pressure: number; 114 | urgent?: boolean; 115 | }) { 116 | this.sendMidi({ urgent, port, status: 0xa0 | channel, data1: key, data2: pressure }); 117 | } 118 | 119 | sendControlChange({ 120 | port = 0, 121 | channel, 122 | control, 123 | value, 124 | urgent = false, 125 | }: { 126 | port?: number; 127 | channel: number; 128 | control: number; 129 | value: number; 130 | urgent?: boolean; 131 | }) { 132 | this.sendMidi({ urgent, port, status: 0xb0 | channel, data1: control, data2: value }); 133 | } 134 | 135 | sendProgramChange({ 136 | port = 0, 137 | channel, 138 | program, 139 | urgent = false, 140 | }: { 141 | port?: number; 142 | channel: number; 143 | program: number; 144 | urgent?: boolean; 145 | }) { 146 | this.sendMidi({ urgent, port, status: 0xc0 | channel, data1: program, data2: 0 }); 147 | } 148 | 149 | sendChannelPressure({ 150 | port = 0, 151 | channel, 152 | pressure, 153 | urgent = false, 154 | }: { 155 | port?: number; 156 | channel: number; 157 | pressure: number; 158 | urgent?: boolean; 159 | }) { 160 | this.sendMidi({ urgent, port, status: 0xd0 | channel, data1: pressure, data2: 0 }); 161 | } 162 | 163 | sendPitchBend({ 164 | port = 0, 165 | channel, 166 | value, 167 | urgent = false, 168 | }: { 169 | port?: number; 170 | channel: number; 171 | value: number; 172 | urgent?: boolean; 173 | }) { 174 | this.sendMidi({ 175 | urgent, 176 | port, 177 | status: 0xe0 | channel, 178 | data1: value & 0x7f, 179 | data2: (value >> 7) & 0x7f, 180 | }); 181 | } 182 | 183 | // flush queued midi and sysex messages 184 | protected _flushQueues() { 185 | while (this._midiQueue.length > 0 || this._sysexQueue.length > 0) { 186 | const midiMessage = this._midiQueue.shift() as NaiveMidiMessage; 187 | if (midiMessage) { 188 | const { label, port, status, data1, data2 } = midiMessage; 189 | console.log( 190 | `[MIDI] OUT ${port} <== ${new MidiMessage({ status, data1, data2 }) 191 | .shortHex}${label ? ` "${label}"` : ''}` 192 | ); 193 | host.getMidiOutPort(port).sendMidi(status, data1, data2); 194 | } 195 | 196 | const sysexMessage = this._sysexQueue.shift() as NaiveSysexMessage; 197 | if (sysexMessage) { 198 | const { label, port, data } = sysexMessage; 199 | console.log(`[SYSEX] OUT ${port} <== ${data}${label ? ` "${label}"` : ''}`); 200 | host.getMidiOutPort(port).sendSysex(data); 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/midi/SysexMessage.ts: -------------------------------------------------------------------------------- 1 | export class SysexMessage { 2 | port: number; 3 | data: string; 4 | urgent: boolean; 5 | 6 | constructor({ 7 | port = 0, 8 | data, 9 | urgent = false, 10 | }: { 11 | port?: number; 12 | data: string; 13 | urgent?: boolean; 14 | }) { 15 | this.port = port; 16 | this.data = data; 17 | this.urgent = urgent; 18 | } 19 | 20 | toString() { 21 | return this.data.toUpperCase(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/midi/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MidiMessage'; 2 | export * from './SysexMessage'; 3 | export * from './MidiOutProxy'; 4 | export * from './MessagePattern'; 5 | -------------------------------------------------------------------------------- /src/session/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | export class EventEmitter { 2 | listeners: { [key: string]: ((...args: any[]) => void)[] } = {}; 3 | 4 | on void)>(label: string, callback: Callback) { 5 | if (this.listeners[label] && this.listeners[label].indexOf(callback) > -1) { 6 | throw new Error('Duplicate event subscriptions not allowed'); 7 | } 8 | this.listeners = { 9 | ...this.listeners, 10 | [label]: [...(this.listeners[label] || []), callback], 11 | }; 12 | } 13 | 14 | addListener void)>(label: string, callback: Callback) { 15 | this.on(label, callback); 16 | } 17 | 18 | removeListener void)>(label: string, callback: Callback) { 19 | const listeners = this.listeners[label]; 20 | const index = listeners ? listeners.indexOf(callback) : -1; 21 | 22 | if (index > -1) { 23 | this.listeners = { 24 | ...this.listeners, 25 | [label]: [...listeners.slice(0, index), ...listeners.slice(index + 1)], 26 | }; 27 | return true; 28 | } 29 | return false; 30 | } 31 | 32 | emit(label: string, ...args: any[]) { 33 | const listeners = this.listeners[label]; 34 | 35 | if (listeners && listeners.length) { 36 | listeners.forEach(listener => { 37 | listener(...args); 38 | }); 39 | return true; 40 | } 41 | return false; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/session/Session.spec.ts: -------------------------------------------------------------------------------- 1 | import { Session } from './Session'; 2 | import { Control } from '../control'; 3 | import { View } from '../view'; 4 | 5 | describe('Session', () => { 6 | const controls = { CTRL: new Control({ patterns: [{ status: 0xb0, data1: 0xb1 }] }) }; 7 | 8 | class BaseView extends View {} 9 | const views = { BASE: BaseView }; 10 | 11 | describe('Views', () => { 12 | it('should register controls correctly', () => { 13 | const session = new Session(); 14 | session.registerControls(controls); 15 | expect(session.controls.CTRL).toBe(undefined); 16 | session.emit('init'); 17 | expect(session.controls.CTRL).toBe(controls.CTRL); 18 | 19 | const session2 = new Session(); 20 | (session2 as any)._isInit = true; 21 | session2.registerControls(controls); 22 | expect(session2.controls.CTRL).toBe(controls.CTRL); 23 | }); 24 | 25 | it('should register views correctly outside of init', () => { 26 | const viewInit = jest.spyOn(BaseView, 'init'); 27 | 28 | const session = new Session(); 29 | session.registerViews(views); 30 | expect(session.views.BASE).toBe(undefined); 31 | session.emit('init'); 32 | expect(session.views.BASE).toBe(undefined); 33 | 34 | const session2 = new Session(); 35 | session2.registerViews(views); 36 | session2.registerControls(controls); 37 | expect(session2.views.BASE).toBe(undefined); 38 | session2.emit('init'); 39 | expect(session2.views.BASE).toBe(BaseView); 40 | 41 | expect(viewInit).toHaveBeenCalledTimes(1); 42 | viewInit.mockRestore(); 43 | }); 44 | 45 | it('should register views correctly inside of init', () => { 46 | const session = new Session(); 47 | (session as any)._isInit = true; 48 | session.registerViews(views); 49 | expect(Object.keys(session.views).length).toBe(0); 50 | 51 | session.registerControls(controls); 52 | expect(Object.keys(session.views).length).toBe(Object.keys(views).length); 53 | expect(session.views.BASE).toBe(BaseView); 54 | }); 55 | 56 | it('should activate views correctly', () => { 57 | const session = new Session(); 58 | session.registerControls(controls); 59 | session.registerViews(views); 60 | session.emit('init'); 61 | expect(session.activeView).toBe(undefined); 62 | session.activateView('BASE'); 63 | expect(session.activeView).toBe(BaseView); 64 | }); 65 | }); 66 | 67 | describe('Modes', () => { 68 | const session = new Session(); 69 | it('should activate/deactivate modes correctly', () => { 70 | const base = '__BASE__'; 71 | const mode1 = 'TEST1'; 72 | const mode2 = 'TEST2'; 73 | const mode3 = 'TEST3'; 74 | 75 | expect(() => session.activateMode(base)).toThrow(); 76 | 77 | expect(session.modeIsActive(mode1)).toBe(false); 78 | session.activateMode(mode1); 79 | expect(session.activeModes).toEqual([mode1, base]); 80 | expect(session.modeIsActive(mode1)).toBe(true); 81 | 82 | session.activateMode(mode2); 83 | session.activateMode(mode3); 84 | expect(session.activeModes).toEqual([mode3, mode2, mode1, base]); 85 | 86 | session.deactivateMode(mode2); 87 | expect(session.activeModes).toEqual([mode3, mode1, base]); 88 | 89 | session.deactivateMode(mode3); 90 | expect(session.activeModes).toEqual([mode1, base]); 91 | 92 | expect(() => session.deactivateMode(base)).toThrow(); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/session/Session.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from './EventEmitter'; 2 | import { MidiOutProxy, MidiMessage, SysexMessage } from '../midi'; 3 | import { Control, ControlState } from '../control'; 4 | import { View } from '../view'; 5 | import { shim } from '../env'; 6 | 7 | declare const global: { 8 | init: () => void; 9 | flush: () => void; 10 | exit: () => void; 11 | }; 12 | 13 | export interface Session extends EventEmitter { 14 | on(label: 'activateMode' | 'deactivateMode', callback: (mode: string) => void): void; 15 | on(label: 'activateView', callback: (view: typeof View) => void): void; 16 | on( 17 | label: 'init' | 'registerControls' | 'registerViews' | 'flush' | 'exit', 18 | callback: () => void 19 | ): void; 20 | 21 | addListener(label: 'activateMode' | 'deactivateMode', callback: (mode: string) => void): void; 22 | addListener(label: 'activateView', callback: (view: typeof View) => void): void; 23 | addListener( 24 | label: 'init' | 'registerControls' | 'registerViews' | 'flush' | 'exit', 25 | callback: () => void 26 | ): void; 27 | 28 | removeListener( 29 | label: 'activateMode' | 'deactivateMode', 30 | callback: (mode: string) => void 31 | ): boolean; 32 | removeListener(label: 'activateView', callback: (view: typeof View) => void): boolean; 33 | removeListener( 34 | label: 'init' | 'registerControls' | 'registerViews' | 'flush' | 'exit', 35 | callback: () => void 36 | ): boolean; 37 | } 38 | 39 | /** 40 | * A representation of the current project (or active Bitwig 41 | * Studio tab). 42 | * 43 | * Assists in managing shared state and session level event 44 | * subscriptions between Taktil and the control surface script. 45 | */ 46 | export class Session extends EventEmitter { 47 | private _isInit: boolean = false; 48 | private _controls: { [label: string]: Control } = {}; 49 | private _views: { [label: string]: typeof View } = {}; 50 | private _activeView: typeof View; 51 | private _activeModes: string[] = []; 52 | private _eventHandlers: { [key: string]: Function[] } = {}; 53 | 54 | /** Global MidiOutProxy instance */ 55 | midiOut: MidiOutProxy = new MidiOutProxy(this); 56 | 57 | constructor() { 58 | super(); 59 | // shim bitwig scripting env, injecting session 60 | shim(this); 61 | 62 | global.init = () => { 63 | this._isInit = true; 64 | 65 | // call the session init callbacks 66 | this.emit('init'); 67 | 68 | // setup midi/sysex callbacks per port 69 | const midiInPorts = this.midiInPorts; 70 | for (let port = 0; port < midiInPorts.length; port += 1) { 71 | midiInPorts[ 72 | port 73 | ].setMidiCallback((status: number, data1: number, data2: number) => { 74 | this.onMidiInput(new MidiMessage({ port, status, data1, data2 })); 75 | }); 76 | midiInPorts[port].setSysexCallback((data: string) => { 77 | this.onMidiInput(new SysexMessage({ port, data })); 78 | }); 79 | } 80 | 81 | this._isInit = false; 82 | }; 83 | 84 | global.flush = () => { 85 | this.emit('flush'); 86 | }; 87 | 88 | global.exit = () => { 89 | // reset all controls to default state 90 | for (const controlName in this.controls) { 91 | const control = this.controls[controlName]; 92 | control.setState(control.defaultState); 93 | } 94 | // call registered exit callbacks 95 | this.emit('exit'); 96 | }; 97 | } 98 | 99 | /** Check if bitwig is currently in it's init startup phase */ 100 | get isInit(): boolean { 101 | return this._isInit; 102 | } 103 | 104 | // Midi 105 | ////////////////////////////// 106 | 107 | /** The midi in ports available to the session */ 108 | get midiInPorts(): API.MidiIn[] { 109 | const midiInPorts = []; 110 | for (let i = 0; true; i += 1) { 111 | try { 112 | midiInPorts[i] = host.getMidiInPort(i); 113 | } catch (error) { 114 | break; 115 | } 116 | } 117 | return midiInPorts; 118 | } 119 | 120 | /** Handle midi input, routing it to the correct control object */ 121 | onMidiInput(message: MidiMessage | SysexMessage) { 122 | const control = this.findControl(message); 123 | const messageType = message instanceof MidiMessage ? '[MIDI] ' : '[SYSEX]'; 124 | 125 | if (control) control.onMidiInput(message); 126 | 127 | console.log( 128 | `${messageType} IN ${message.port} ==> ${message instanceof MidiMessage 129 | ? message.shortHex 130 | : message.data}${control && control.label ? ` "${control.label}"` : ''}` 131 | ); 132 | } 133 | 134 | // Controls 135 | ////////////////////////////// 136 | 137 | /** 138 | * Register controls to the session (can only be called once). 139 | * 140 | * @param controls The mapping of control labels to control instances to register 141 | * to the session. 142 | */ 143 | registerControls(controls: { [label: string]: Control }) { 144 | if (Object.keys(this.controls).length) { 145 | throw Error("The Session's registerControls method can only be called once."); 146 | } 147 | 148 | // assign view label to and inject session into each view 149 | Object.keys(controls).forEach(controlName => { 150 | controls[controlName].label = controlName; 151 | controls[controlName].session = this; 152 | }); 153 | 154 | const register = () => { 155 | const controlsArray: Control[] = []; 156 | for (const controlName in controls) { 157 | const control = controls[controlName]; 158 | 159 | // make sure patterns don't overlap 160 | for (const existingControl of controlsArray) { 161 | for (const pattern of control.patterns) { 162 | for (const existingPattern of existingControl.patterns) { 163 | if (pattern.conflictsWith(existingPattern)) { 164 | throw new Error( 165 | `Control "${control.label}" conflicts with existing Control "${existingControl.label}".` 166 | ); 167 | } 168 | } 169 | } 170 | } 171 | // add to control array 172 | controlsArray.push(controls[controlName]); 173 | } 174 | 175 | this._controls = controls; 176 | 177 | // emit registerControls event 178 | this.emit('registerControls'); 179 | }; 180 | 181 | // if called during init register immediately 182 | if (this.isInit) return register(); 183 | // otherwise defer until init 184 | this.on('init', register); 185 | } 186 | 187 | /** 188 | * The mapping of control labels to control instances that have 189 | * been registered to the session. 190 | */ 191 | get controls() { 192 | return { ...this._controls }; 193 | } 194 | 195 | /** Find the control (if it exists) associated with an incoming Midi message. */ 196 | findControl(message: MidiMessage | SysexMessage): Control | null { 197 | // look for a matching registered control 198 | for (const controlName in this.controls) { 199 | const control = this.controls[controlName]; 200 | for (const pattern of control.patterns) { 201 | // if pattern matches midiMessage, return control 202 | if (pattern.test(message)) return control; 203 | } 204 | } 205 | // not found, return null 206 | return null; 207 | } 208 | 209 | /** 210 | * Connect each registered control with its corresponding component (if any) 211 | * in the active view stack. 212 | * 213 | * This method is called internally anytime the active view or mode list 214 | * changes to re-associate controls to newly activated components. 215 | */ 216 | associateControlsInView() { 217 | // no view, no components to associate controls with 218 | if (!this.activeView) return; 219 | // connect each control to the corresponding component in view (if any) 220 | for (const controlName in this.controls) { 221 | const control = this.controls[controlName]; 222 | this.activeView.connectControl(control); 223 | } 224 | } 225 | 226 | /** Force re-render all registered controls. */ 227 | resetControls() { 228 | for (const controlName in this.controls) { 229 | const control = this.controls[controlName]; 230 | control.render(true); 231 | } 232 | } 233 | 234 | // Views 235 | ////////////////////////////// 236 | 237 | /** 238 | * Register views to the session (can only be called once). 239 | * 240 | * @param views The mapping of view labels to view classes to register 241 | * to the session. 242 | */ 243 | registerViews(views: { [label: string]: typeof View }) { 244 | if (Object.keys(this.views).length) { 245 | throw Error("The Session's registerViews method can only be called once."); 246 | } 247 | 248 | // assign view label to each view 249 | Object.keys(views).forEach(label => (views[label].label = label)); 250 | 251 | const register = () => { 252 | if (!Object.keys(this.controls).length) { 253 | throw Error('Controls must be registered before views.'); 254 | } 255 | const viewsToRegister = flattenViews(Object.keys(views).map(label => views[label])); 256 | const unvalidatedViews = [...viewsToRegister]; 257 | const attemptedValidations: typeof View[] = []; 258 | const validatedViews: typeof View[] = []; 259 | 260 | validation: while (true) { 261 | const view = unvalidatedViews.shift(); 262 | if (!view) break; // if we've run out of views to register we are done. 263 | 264 | for (const ancestor of view.extends) { 265 | // if the views parent has yet to be registered, push it to the end of the line 266 | if (validatedViews.indexOf(ancestor) === -1) { 267 | unvalidatedViews.push(view); 268 | // catch circular dependency 269 | if (attemptedValidations.indexOf(view) === -1) { 270 | attemptedValidations.push(view); 271 | } else { 272 | throw Error(`Circular dependency detected in ${view.label}.`); 273 | } 274 | continue validation; 275 | } 276 | } 277 | 278 | // everything looks good, register the view 279 | if (validatedViews.indexOf(view) === -1) { 280 | // add to validate views list 281 | validatedViews.push(view); 282 | // initialize view 283 | view.init(this); 284 | } else { 285 | throw Error( 286 | `The same view class (${view.label}) cannot be registered more than once.` 287 | ); 288 | } 289 | } 290 | 291 | // with all views validated, set session views 292 | if (validatedViews.length === viewsToRegister.length) { 293 | this._views = views; 294 | this.emit('registerViews'); // emit registerViews event 295 | } else { 296 | throw Error('Unable to validate views for registration.'); 297 | } 298 | }; 299 | 300 | // if the controls have already been registered, register immediately 301 | if (Object.keys(this.controls).length) return register(); 302 | // otherwise, defer until controls are registered 303 | this.on('registerControls', register); 304 | } 305 | 306 | /** 307 | * The mapping of view labels to view classes that have been registered 308 | * to the session. 309 | */ 310 | get views() { 311 | return { ...this._views }; 312 | } 313 | 314 | /** Set the active view of the session. */ 315 | activateView(label: string) { 316 | const view = this.views[label]; 317 | if (view === undefined) throw new Error(`Cannot find view with label "${label}"`); 318 | 319 | this._activeView = view; 320 | this.emit('activateView', view); 321 | this.associateControlsInView(); // re-associate controls in view 322 | } 323 | 324 | /** The active view of the session. */ 325 | get activeView(): typeof View { 326 | return this._activeView; 327 | } 328 | 329 | // Modes 330 | ////////////////////////////// 331 | 332 | /** The list of active modes in the order they were activated, from last to first. */ 333 | get activeModes() { 334 | return [...this._activeModes, '__BASE__']; 335 | } 336 | 337 | /** Activate a mode, adding it to the active mode list. */ 338 | activateMode(mode: string) { 339 | if (mode === '__BASE__') throw new Error('Mode label "__BASE__" is reserved.'); 340 | const modeIndex = this._activeModes.indexOf(mode); 341 | if (modeIndex > -1) this._activeModes.splice(modeIndex, 1); 342 | this._activeModes.unshift(mode); // prepend to modes 343 | this.emit('activateMode', mode); 344 | this.associateControlsInView(); // re-associate controls in view 345 | } 346 | 347 | /** Deactivate a given mode, removing it from the active mode list. */ 348 | deactivateMode(mode: string) { 349 | if (mode === '__BASE__') throw new Error('Mode label "__BASE__" is reserved.'); 350 | const modeIndex = this._activeModes.indexOf(mode); 351 | if (modeIndex > -1) { 352 | this._activeModes.splice(modeIndex, 1); 353 | this.emit('deactivateMode', mode); 354 | this.associateControlsInView(); // re-associate controls in view 355 | } 356 | } 357 | 358 | /** Check if a given mode is active. */ 359 | modeIsActive(mode: string) { 360 | return this.activeModes.indexOf(mode) > -1; 361 | } 362 | } 363 | 364 | function flattenViews(views: typeof View[], distinct = true): typeof View[] { 365 | const flattenedViews = views 366 | .map(view => [view, ...flattenViews(view.extends, false)]) 367 | .reduce((result, array) => result.concat(array), []); 368 | 369 | if (!distinct) return flattenedViews; 370 | 371 | return flattenedViews.reduce((result, view) => { 372 | if (result.indexOf(view) === -1) result.push(view); 373 | return result; 374 | }, [] as typeof View[]); 375 | } 376 | -------------------------------------------------------------------------------- /src/session/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Session'; 2 | -------------------------------------------------------------------------------- /src/view/View.spec.ts: -------------------------------------------------------------------------------- 1 | import { View } from './View'; 2 | 3 | class TestView extends View {} 4 | 5 | describe('View', () => { 6 | it('placeholder', () => { 7 | expect(true).toBe(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/view/View.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../component'; 2 | import { Control } from '../control'; 3 | import { Session } from '../session'; 4 | 5 | export class View { 6 | private static _componentMap: { 7 | [mode: string]: { 8 | controls: Control[]; 9 | components: Component[]; 10 | }; 11 | }; 12 | static label: string; 13 | static extends: typeof View[] = []; 14 | static session: Session; 15 | 16 | static getComponent(control: Control, mode: string): Component | null { 17 | // check in current view 18 | if (this._componentMap[mode] !== undefined) { 19 | const componentMapIndex = this._componentMap[mode].controls.indexOf(control); 20 | if (componentMapIndex !== -1) { 21 | return this._componentMap[mode].components[componentMapIndex]; 22 | } 23 | } 24 | // component not found in view? check in parents 25 | for (const ancestor of this.extends) { 26 | const component = ancestor.getComponent(control, mode); 27 | if (component) return component; 28 | } 29 | // not in current view, no parent to check? return null 30 | return null; 31 | } 32 | 33 | static connectControl(control: Control) { 34 | // check view modes in order for component/control registration 35 | let component = null; 36 | for (const activeMode of this.session.activeModes) { 37 | component = this.getComponent(control, activeMode); 38 | // if component is not null, we're done looking 39 | if (component) break; 40 | } 41 | // only set the component when it has changed 42 | if (control.activeComponent !== component) control.activeComponent = component; 43 | } 44 | 45 | static init(session: Session) { 46 | // connect the session 47 | this.session = session; 48 | // instance gives us component mapping 49 | const instance = new this(); 50 | // give each subclass its own componentMap 51 | this._componentMap = {}; 52 | 53 | Object.getOwnPropertyNames(instance).map(key => { 54 | let value = instance[key]; 55 | value = typeof value === 'function' ? value() : value; 56 | const components = value instanceof Array ? value : [value]; 57 | for (let i = 0; i < components.length; i += 1) { 58 | const component = components[i]; 59 | const isSingleComponent = components.length === 1; 60 | // skip non-component properties 61 | if (component instanceof Component === false) continue; 62 | // set component label and view 63 | component.label = isSingleComponent ? key : `${key}[${i}]`; 64 | 65 | // register components and controls in view 66 | const { control, params: { mode } } = component; 67 | // register control with view/mode 68 | if (!this._componentMap[mode]) 69 | this._componentMap[mode] = { 70 | controls: [], 71 | components: [], 72 | }; 73 | 74 | // if control already registered in view mode, throw error 75 | if (this._componentMap[mode].controls.indexOf(control) > -1) 76 | throw Error(`Duplicate Control "${control.label}" registration in view mode.`); 77 | 78 | // add control and component pair to component map 79 | this._componentMap[mode].controls.push(control); 80 | this._componentMap[mode].components.push(component); 81 | // initialize component 82 | if (component.onInit) component.onInit(); 83 | } 84 | }); 85 | } 86 | 87 | // view should not be instantiated by user 88 | protected constructor() {} 89 | 90 | [key: string]: Component | Component[] | (() => Component | Component[]); 91 | } 92 | -------------------------------------------------------------------------------- /src/view/ViewStack.ts: -------------------------------------------------------------------------------- 1 | import { View } from './View'; 2 | 3 | export function ViewStack(...views: typeof View[]): typeof View { 4 | return class extends View { 5 | static extends = views; 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/view/index.ts: -------------------------------------------------------------------------------- 1 | export * from './View'; 2 | export * from './ViewStack'; 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "src/**/*.spec.ts" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "rootDir": "./src", 6 | "sourceMap": false, 7 | "declaration": true, 8 | "baseUrl": "./src", 9 | "moduleResolution": "node", 10 | "outDir": "./", 11 | "lib": [ 12 | "dom", 13 | "es6", 14 | "dom.iterable", 15 | "scripthost" 16 | ], 17 | "importHelpers": true, 18 | "types": [ 19 | "typed-bitwig-api", 20 | "@types/jest", 21 | "@types/node" 22 | ], 23 | "strict": true, 24 | "strictNullChecks": false 25 | }, 26 | "compileOnSave": false, 27 | "include": [ 28 | "src/**/*.ts" 29 | ], 30 | "exclude": [ 31 | "node_modules/**/*" 32 | ] 33 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-airbnb", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "import-name": false, 8 | "function-name": [ 9 | true, 10 | { 11 | "method-regex": "^[a-z][\\w\\d]+$", 12 | "private-method-regex": "^[a-z_][\\w\\d]+$", 13 | "protected-method-regex": "^[a-z_][\\w\\d]+$", 14 | "static-method-regex": "^[a-z][\\w\\d]+$", 15 | "function-regex": "^[a-zA-Z][\\w\\d]+$" 16 | } 17 | ], 18 | "variable-name": [ 19 | true, 20 | "check-format", 21 | "allow-leading-underscore", 22 | "allow-trailing-underscore", 23 | "allow-pascal-case" 24 | ], 25 | "no-param-reassign": false 26 | } 27 | } --------------------------------------------------------------------------------