├── .gitignore ├── README.md ├── max4node.coffee ├── max4node.js ├── max_device ├── main.js └── max4node.amxd ├── package.json └── test └── main-test.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Ableton Live API for Node.js (through Max for Live) 3 | 4 | This module exposes the [Live Object Model](https://cycling74.com/docs/max6/dynamic/c74_docs.html#live_object_model) 5 | so that it can be consumed directly from Node.js. It works by communicating with a Max for Live device (included in the repo) 6 | through udp sockets. 7 | 8 | 9 | ### Requirements 10 | 11 | * Ableton Live 9 12 | * Max for Live (tested with version 7, might work with 6) 13 | 14 | By default, the module binds on ports __9000__ and __9001__, so they need to be free. 15 | 16 | 17 | ### Install 18 | 19 | `npm install max4node` 20 | 21 | 22 | ### Setup 23 | 24 | The Max for Live device is located in `max_device/max4node.amxd`. 25 | Drop the device in a MIDI track (doesn't matter which one). 26 | 27 | 28 | ### Usage 29 | 30 | ```javascript 31 | var Max4Node = require('max4node'); 32 | 33 | var max = new Max4Node(); 34 | max.bind(); 35 | ``` 36 | 37 | ##### Get values 38 | 39 | Get Master Track volume. 40 | 41 | ```javascript 42 | max.get({ 43 | path: 'live_set master_track mixer_device volume', 44 | property: 'value' 45 | }) 46 | .once('value', function(val) { 47 | console.log('Master track volume: ' + val); 48 | }); 49 | ``` 50 | 51 | ##### Set values 52 | 53 | Arm the first track. 54 | 55 | ```javascript 56 | max.set({ 57 | path: 'live_set tracks 0', 58 | property: 'arm', 59 | value: true 60 | }); 61 | ``` 62 | 63 | ##### Call functions 64 | 65 | Play a clip. 66 | 67 | ```javascript 68 | max.call({ 69 | path: 'live_set tracks 0 clip_slots 3 clip', 70 | method: 'fire' 71 | }); 72 | ``` 73 | 74 | ##### Observe a value 75 | 76 | Fire the callback with the updated position of the clip (if it's playing). 77 | 78 | ```javascript 79 | max.observe({ 80 | path: 'live_set 0 clip_slots 3 clip', 81 | property: 'playing_position' 82 | }) 83 | .on('value', function(val) { 84 | console.log('Playing position: ' + val); 85 | }); 86 | ``` 87 | 88 | ##### Count 89 | 90 | Number of clips in the track. 91 | 92 | ```javascript 93 | max.count({ 94 | path: 'live_set tracks 0', 95 | property: 'clip_slots' 96 | }) 97 | .once('value', function(count) { 98 | console.log(count + ' clips'); 99 | }); 100 | ``` 101 | 102 | ##### Promises 103 | 104 | Promise based versions of `get` and `count` are available through `max.promise()`. 105 | 106 | ```javascript 107 | max.promise().get({ 108 | path: 'live_set master_track mixer_device volume', 109 | property: 'value' 110 | }) 111 | .then(function(val) { 112 | console.log('Master track volume: ' + val); 113 | }); 114 | 115 | max.promise().count({ 116 | path: 'live_set tracks 0', 117 | property: 'clip_slots' 118 | }) 119 | .then(function(count) { 120 | console.log(count + ' clips'); 121 | }); 122 | ``` 123 | 124 | ### Testing 125 | 126 | Testing is done with fake sockets, so you don't need to open Ableton and Max. 127 | 128 | `npm test` 129 | 130 | 131 | ### Big ups 132 | 133 | I would have never been able to come up with the Max device without looking at the code of [Fingz](http://www.atmosphery.com/#fingz), an awesome project that you should definitely check out. 134 | I learned a lot about Max from it, debugging in Max is as painful as listening to Justin Bieber, but it's the only 135 | way we have to access the Ableton API in a reliable manner (control surfaces programming is a joke, and 136 | not officially supported). 137 | 138 | 139 | ### License 140 | 141 | 142 | > Copyright (c) 2015, Marco Sampellegrini 143 | 144 | 145 | > Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 146 | 147 | > THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 148 | -------------------------------------------------------------------------------- /max4node.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Copyright (c) 2015, Marco Sampellegrini 4 | # 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee 7 | # is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE 10 | # INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 11 | # FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, 13 | # ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | 16 | 17 | osc = require 'osc-min' 18 | udp = require 'dgram' 19 | 20 | { EventEmitter } = require 'events' 21 | Promise = require 'bluebird' 22 | 23 | 24 | class Max4Node 25 | 26 | constructor: -> 27 | @read = null # input socket 28 | @write = null # output socket 29 | @ports = {} 30 | @emitters = {} 31 | 32 | bind: (ports = {}) -> 33 | ports.send ||= 9000 34 | ports.receive ||= 9001 35 | @ports = ports 36 | 37 | @read = @create_input_socket ports.receive 38 | @write = udp.createSocket 'udp4' 39 | 40 | 41 | create_input_socket: (port) -> 42 | socket = udp.createSocket 'udp4' 43 | socket.bind port 44 | 45 | socket.on 'message', (msg, rinfo) => 46 | obj = @parse_message msg 47 | 48 | if obj.is_get_reply or obj.is_observer_reply 49 | try 50 | @emitters[obj.callback].emit 'value', obj.value 51 | catch err 52 | 53 | if obj.is_get_reply 54 | delete @emitters[obj.callback] 55 | 56 | socket 57 | 58 | 59 | parse_message: (msg) -> 60 | obj = osc.fromBuffer msg 61 | args = obj.args.map (item) -> item.value 62 | 63 | switch obj.address 64 | when '/_get_reply' 65 | obj.is_get_reply = true 66 | obj.callback = args[0] 67 | obj.value = args[1] 68 | 69 | when '/_observer_reply' 70 | obj.is_observer_reply = true 71 | obj.callback = args[0] 72 | obj.value = args[2] 73 | 74 | obj 75 | 76 | 77 | send_message: (address, args) -> 78 | buf = osc.toBuffer 79 | address: '/' + address, 80 | args: args 81 | 82 | @write.send buf, 0, buf.length, @ports.send, 'localhost' 83 | 84 | 85 | observer_emitter: (msg, action = 'observe') -> 86 | emitter = new EventEmitter() 87 | callback = @callbackHash() 88 | @emitters[callback] = emitter 89 | 90 | args = [msg.path, msg.property, callback] 91 | @send_message action, args 92 | emitter 93 | 94 | callbackHash: -> 95 | (new Date()).getTime().toString() + Math.random().toString() 96 | 97 | 98 | get: (msg) -> 99 | @observer_emitter msg, 'get' 100 | 101 | set: (msg) -> 102 | args = [msg.path, msg.property, msg.value] 103 | @send_message 'set', args 104 | 105 | call: (msg) -> 106 | args = [msg.path, msg.method] 107 | @send_message 'call', args 108 | 109 | observe: (msg) -> 110 | @observer_emitter msg, 'observe' 111 | 112 | count: (msg) -> 113 | @observer_emitter msg, 'count' 114 | 115 | promise: -> 116 | return @promisedFn if @promisedFn 117 | @promisedFn = 118 | get: promiseMessage.bind(@, 'get') 119 | count: promiseMessage.bind(@, 'count') 120 | 121 | 122 | 123 | promiseMessage = (method, msg) -> 124 | new Promise (resolve, reject) => 125 | emitter = @[method] msg 126 | emitter.on 'value', resolve 127 | 128 | module.exports = Max4Node 129 | -------------------------------------------------------------------------------- /max4node.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.8.0 2 | (function() { 3 | var EventEmitter, Max4Node, Promise, osc, promiseMessage, udp; 4 | 5 | osc = require('osc-min'); 6 | 7 | udp = require('dgram'); 8 | 9 | EventEmitter = require('events').EventEmitter; 10 | 11 | Promise = require('bluebird'); 12 | 13 | Max4Node = (function() { 14 | function Max4Node() { 15 | this.read = null; 16 | this.write = null; 17 | this.ports = {}; 18 | this.emitters = {}; 19 | } 20 | 21 | Max4Node.prototype.bind = function(ports) { 22 | if (ports == null) { 23 | ports = {}; 24 | } 25 | ports.send || (ports.send = 9000); 26 | ports.receive || (ports.receive = 9001); 27 | this.ports = ports; 28 | this.read = this.create_input_socket(ports.receive); 29 | return this.write = udp.createSocket('udp4'); 30 | }; 31 | 32 | Max4Node.prototype.create_input_socket = function(port) { 33 | var socket; 34 | socket = udp.createSocket('udp4'); 35 | socket.bind(port); 36 | socket.on('message', (function(_this) { 37 | return function(msg, rinfo) { 38 | var obj; 39 | obj = _this.parse_message(msg); 40 | if (obj.is_get_reply || obj.is_observer_reply) { 41 | try { 42 | _this.emitters[obj.callback].emit('value', obj.value); 43 | } catch (_error) { 44 | err = _error; 45 | } 46 | } 47 | if (obj.is_get_reply) { 48 | return delete _this.emitters[obj.callback]; 49 | } 50 | }; 51 | })(this)); 52 | return socket; 53 | }; 54 | 55 | Max4Node.prototype.parse_message = function(msg) { 56 | var args, obj; 57 | obj = osc.fromBuffer(msg); 58 | args = obj.args.map(function(item) { 59 | return item.value; 60 | }); 61 | switch (obj.address) { 62 | case '/_get_reply': 63 | obj.is_get_reply = true; 64 | obj.callback = args[0]; 65 | obj.value = args[1]; 66 | break; 67 | case '/_observer_reply': 68 | obj.is_observer_reply = true; 69 | obj.callback = args[0]; 70 | obj.value = args[2]; 71 | } 72 | return obj; 73 | }; 74 | 75 | Max4Node.prototype.send_message = function(address, args) { 76 | var buf; 77 | buf = osc.toBuffer({ 78 | address: '/' + address, 79 | args: args 80 | }); 81 | return this.write.send(buf, 0, buf.length, this.ports.send, 'localhost'); 82 | }; 83 | 84 | Max4Node.prototype.observer_emitter = function(msg, action) { 85 | var args, callback, emitter; 86 | if (action == null) { 87 | action = 'observe'; 88 | } 89 | emitter = new EventEmitter(); 90 | callback = this.callbackHash(); 91 | this.emitters[callback] = emitter; 92 | args = [msg.path, msg.property, callback]; 93 | this.send_message(action, args); 94 | return emitter; 95 | }; 96 | 97 | Max4Node.prototype.callbackHash = function() { 98 | return (new Date()).getTime().toString() + Math.random().toString(); 99 | }; 100 | 101 | Max4Node.prototype.get = function(msg) { 102 | return this.observer_emitter(msg, 'get'); 103 | }; 104 | 105 | Max4Node.prototype.set = function(msg) { 106 | var args; 107 | args = [msg.path, msg.property, msg.value]; 108 | return this.send_message('set', args); 109 | }; 110 | 111 | Max4Node.prototype.call = function(msg) { 112 | var args; 113 | args = [msg.path, msg.method]; 114 | return this.send_message('call', args); 115 | }; 116 | 117 | Max4Node.prototype.observe = function(msg) { 118 | return this.observer_emitter(msg, 'observe'); 119 | }; 120 | 121 | Max4Node.prototype.count = function(msg) { 122 | return this.observer_emitter(msg, 'count'); 123 | }; 124 | 125 | Max4Node.prototype.promise = function() { 126 | if (this.promisedFn) { 127 | return this.promisedFn; 128 | } 129 | return this.promisedFn = { 130 | get: promiseMessage.bind(this, 'get'), 131 | count: promiseMessage.bind(this, 'count') 132 | }; 133 | }; 134 | 135 | return Max4Node; 136 | 137 | })(); 138 | 139 | promiseMessage = function(method, msg) { 140 | return new Promise((function(_this) { 141 | return function(resolve, reject) { 142 | var emitter; 143 | emitter = _this[method](msg); 144 | return emitter.on('value', resolve); 145 | }; 146 | })(this)); 147 | }; 148 | 149 | module.exports = Max4Node; 150 | 151 | }).call(this); 152 | -------------------------------------------------------------------------------- /max_device/main.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var isReady = false, 4 | actions = {}, 5 | apis = {}; 6 | 7 | 8 | function get(action) { 9 | if (!isReady) return false; 10 | 11 | var json = Array.prototype.slice.call(arguments); 12 | json = json.slice(1); 13 | 14 | action = action.slice(1); 15 | var ret = actions[action](json); 16 | } 17 | 18 | 19 | function on() { 20 | isReady = true; 21 | } 22 | 23 | 24 | 25 | actions['get'] = function(obj) { 26 | var path = obj[0], 27 | property = obj[1], 28 | callback = obj[2]; 29 | 30 | var api = getApi(path); 31 | outlet(0, '/_get_reply', callback, api.get(property)); 32 | }; 33 | 34 | 35 | 36 | actions['set'] = function(obj) { 37 | var path = obj[0], 38 | property = obj[1], 39 | value = obj[2]; 40 | 41 | var api = getApi(path); 42 | api.set(property, value); 43 | }; 44 | 45 | 46 | actions['call'] = function(obj) { 47 | var path = obj[0], 48 | method = obj[1]; 49 | 50 | var api = getApi(path); 51 | api.call(method); 52 | }; 53 | 54 | 55 | actions['observe'] = function(obj) { 56 | var path = obj[0], 57 | property = obj[1], 58 | callback = obj[2]; 59 | 60 | var handler = handleCallbacks(callback); 61 | 62 | var api = new LiveAPI(handler, path); 63 | api.property = property; 64 | }; 65 | 66 | 67 | actions['count'] = function(obj) { 68 | var path = obj[0], 69 | property = obj[1], 70 | callback = obj[2]; 71 | 72 | var api = getApi(path); 73 | outlet(0, '/_get_reply', callback, api.getcount(property)); 74 | }; 75 | 76 | 77 | function getApi(path) { 78 | if (apis[path]) 79 | return apis[path]; 80 | 81 | apis[path] = new LiveAPI(path); 82 | return apis[path]; 83 | } 84 | 85 | 86 | function handleCallbacks(callback) { 87 | return function(value) { 88 | outlet(0, '/_observer_reply', callback, value); 89 | } 90 | } 91 | 92 | 93 | function log() { 94 | for(var i=0,len=arguments.length; i= 0) { 99 | s = JSON.stringify(message); 100 | } 101 | post(s); 102 | } 103 | else if(message === null) { 104 | post(""); 105 | } 106 | else { 107 | post(message); 108 | } 109 | } 110 | post("\n"); 111 | } 112 | -------------------------------------------------------------------------------- /max_device/max4node.amxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacaaa/max4node/1c3754049de74f2349c4ff1bd613da17ca096a77/max_device/max4node.amxd -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "max4node", 3 | "description": "Communicate with Ableton Live through Max for Live.", 4 | "author": "Marco Sampellegrini ", 5 | "version": "0.1.2", 6 | "main": "max4node.js", 7 | "scripts": { 8 | "prepublish": "./node_modules/.bin/coffee -o . -c max4node.coffee", 9 | "test": "./node_modules/.bin/mocha --compilers coffee:coffee-script/register" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/alpacaaa/max4node" 14 | }, 15 | "license": "ISC", 16 | "dependencies": { 17 | "osc-min": "^0.2.0", 18 | "bluebird": "^2.6.4" 19 | }, 20 | "devDependencies": { 21 | "coffee-script": "^1.8.0", 22 | "mocha": "^2.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/main-test.coffee: -------------------------------------------------------------------------------- 1 | 2 | assert = require 'assert' 3 | 4 | 5 | osc = require 'osc-min' 6 | udp = require 'dgram' 7 | Max4Node = require '../max4node' 8 | 9 | 10 | createTestSocket = (port) -> 11 | socket = udp.createSocket "udp4" 12 | socket.bind port 13 | socket 14 | 15 | 16 | checkArrays = (arr1, arr2) -> 17 | assert.equal arr1.toString(), arr2.toString() 18 | 19 | normalizeArgs = (obj) -> 20 | obj.args.map (item) -> item.value 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | describe 'Max4Node API', -> 29 | 30 | sendSocket = null 31 | receiveSocket = null 32 | 33 | sendPort = 12000 34 | receivePort = 12001 35 | 36 | max = null 37 | 38 | 39 | value = 'should be equal' 40 | path = 'live_set master_track mixer_device volume' 41 | 42 | 43 | before -> 44 | sendSocket = udp.createSocket 'udp4' 45 | receiveSocket = createTestSocket sendPort 46 | 47 | 48 | max = new Max4Node() 49 | max.bind 50 | send: sendPort 51 | receive: receivePort 52 | 53 | 54 | afterEach -> 55 | receiveSocket.removeAllListeners 'message' 56 | 57 | 58 | describe 'Setup', -> 59 | 60 | it 'is configured correctly', -> 61 | assert.equal sendPort, max.ports.send 62 | assert.equal receivePort, max.read.address().port 63 | 64 | 65 | describe 'Communication with Ableton', -> 66 | 67 | it 'should get values', (done) -> 68 | getTest 'get', done 69 | 70 | 71 | 72 | it 'should set values', (done) -> 73 | 74 | receiveSocket.on 'message', (msg) -> 75 | obj = osc.fromBuffer msg 76 | args = normalizeArgs obj 77 | 78 | assert.equal '/set', obj.address 79 | checkArrays [path, 'value', value], args 80 | done() 81 | 82 | max.set 83 | path: path 84 | property: 'value' 85 | value: value 86 | 87 | 88 | 89 | it 'should fire actions', (done) -> 90 | 91 | receiveSocket.on 'message', (msg) -> 92 | obj = osc.fromBuffer msg 93 | args = normalizeArgs obj 94 | 95 | assert.equal '/call', obj.address 96 | checkArrays [path, value], args 97 | done() 98 | 99 | 100 | max.call 101 | path: path 102 | method: value 103 | 104 | 105 | 106 | it 'should observe properties', (done) -> 107 | 108 | expected = [0.25, 0.5, 0.85] 109 | good = 0 110 | 111 | receiveSocket.on 'message', (msg) -> 112 | obj = osc.fromBuffer msg 113 | args = normalizeArgs obj 114 | 115 | assert.equal '/observe', obj.address 116 | assert.equal 3, args.length 117 | assert.ok args[2] 118 | 119 | expected.forEach (item, index) -> 120 | setTimeout (-> 121 | buf = osc.toBuffer 122 | address: '/_observer_reply', 123 | args: [args[2], 'for some reason, it returns the property', item] 124 | 125 | sendSocket.send buf, 0, buf.length, receivePort, 'localhost' 126 | ), index * 200 127 | 128 | 129 | max.observe 130 | path: path 131 | property: value 132 | .on 'value', (check) -> 133 | ret = expected.filter (item) -> 134 | item.toFixed(2) == parseFloat(check).toFixed(2) 135 | 136 | unless ret.length 137 | assert.fail check, expected, 'Wrong value received' 138 | 139 | done() if ++good == expected.length 140 | 141 | 142 | 143 | it 'should count stuff', (done) -> 144 | getTest 'count', done 145 | 146 | 147 | 148 | 149 | getTest = (method, done) -> 150 | 151 | receiveSocket.on 'message', (msg) -> 152 | obj = osc.fromBuffer msg 153 | args = normalizeArgs obj 154 | 155 | assert.equal '/' + method, obj.address 156 | checkArrays [path, 'value'], args.slice(0, 2) 157 | assert.ok args[2] 158 | 159 | buf = osc.toBuffer 160 | address: '/_get_reply', 161 | args: [args[2], value] 162 | 163 | sendSocket.send buf, 0, buf.length, receivePort, 'localhost' 164 | 165 | 166 | max[method] 167 | path: path 168 | property: 'value' 169 | .once 'value', (check) -> 170 | assert.equal value, check 171 | done() 172 | --------------------------------------------------------------------------------